fix: Samsung SAF library scan, Qobuz album cover, M4A metadata save and log improvements

- Fix M4A/ALAC scan silently failing on Samsung by adding proper fallback
  to scanFromFilename when ReadM4ATags fails (consistent with MP3/FLAC/Ogg)
- Propagate displayNameHint to all format scanners so fd numbers (214, 207)
  no longer appear as track names when /proc/self/fd/ paths are used
- Cache /proc/self/fd/ readability in Kotlin to skip failed attempts after
  first failure, reducing error log noise and improving scan speed on Samsung
- Fix Qobuz download returning wrong album cover when track exists on
  multiple albums by preferring req.CoverURL over API default
- Fix FFmpeg M4A metadata save failing with 'codec not currently supported
  in container' by forcing mp4 muxer instead of ipod when cover art present
- Clean up FLAC SAF temp file after metadata write-back (was leaking)
- Update LRC lyrics tag to credit Paxsenix API
- Remove log message truncation, defer to UI preview truncation instead
This commit is contained in:
zarzet
2026-03-30 18:12:20 +07:00
parent 67daefdf60
commit e42e44f28b
8 changed files with 89 additions and 58 deletions

View File

@@ -56,6 +56,8 @@ class MainActivity: FlutterFragmentActivity() {
private var flutterBackCallback: OnBackPressedCallback? = null
@Volatile private var safScanCancel = false
@Volatile private var safScanActive = false
/** Tri-state: null = untested, true = works, false = fails (Samsung SELinux). */
@Volatile private var procSelfFdReadable: Boolean? = null
private val safTreeLauncher = registerForActivityResult(
ActivityResultContracts.StartActivityForResult()
) { activityResult ->
@@ -375,6 +377,8 @@ class MainActivity: FlutterFragmentActivity() {
synchronized(safScanLock) {
safScanProgress = SafScanProgress()
}
// Allow re-probing /proc/self/fd readability on every new scan session.
procSelfFdReadable = null
}
private fun updateSafScanProgress(block: (SafScanProgress) -> Unit) {
@@ -804,27 +808,45 @@ class MainActivity: FlutterFragmentActivity() {
): JSONObject? {
val displayName = buildUriDisplayName(uri, displayNameHint, fallbackExt)
try {
contentResolver.openFileDescriptor(uri, "r")?.use { pfd ->
val directPath = "/proc/self/fd/${pfd.fd}"
val metadataJson = Gobackend.readAudioMetadataWithHintAndCoverCacheKeyJSON(
directPath,
displayName,
coverCacheKey,
)
if (metadataJson.isNotBlank()) {
val obj = JSONObject(metadataJson)
val filenameFallback = obj.optBoolean("metadataFromFilename", false)
if (!obj.has("error") && !filenameFallback) {
return obj
// Skip /proc/self/fd/ attempt when known to fail (e.g. Samsung SELinux).
if (procSelfFdReadable != false) {
try {
contentResolver.openFileDescriptor(uri, "r")?.use { pfd ->
val directPath = "/proc/self/fd/${pfd.fd}"
val metadataJson = Gobackend.readAudioMetadataWithHintAndCoverCacheKeyJSON(
directPath,
displayName,
coverCacheKey,
)
if (metadataJson.isNotBlank()) {
val obj = JSONObject(metadataJson)
val filenameFallback = obj.optBoolean("metadataFromFilename", false)
if (!obj.has("error") && !filenameFallback) {
procSelfFdReadable = true
return obj
}
// Go could not read real metadata from the fd path
// remember so we skip the attempt for remaining files.
if (procSelfFdReadable == null) {
procSelfFdReadable = false
android.util.Log.d(
"SpotiFLAC",
"Direct /proc/self/fd read not usable on this device, " +
"using temp-file fallback for remaining files",
)
}
}
}
} catch (e: Exception) {
if (procSelfFdReadable == null) {
procSelfFdReadable = false
android.util.Log.d(
"SpotiFLAC",
"Direct /proc/self/fd read not usable on this device, " +
"using temp-file fallback for remaining files",
)
}
}
} catch (e: Exception) {
android.util.Log.d(
"SpotiFLAC",
"Direct SAF metadata read fallback for $uri: ${e.message}",
)
}
val tempPath = try {
@@ -2411,11 +2433,13 @@ class MainActivity: FlutterFragmentActivity() {
return@withContext obj.toString()
// Note: temp file NOT deleted here - Dart will clean up after FFmpeg + writeTempToSaf
}
// FLAC: Go wrote directly to temp, copy back now
if (!writeUriFromPath(uri, tempPath)) {
return@withContext """{"error":"Failed to write metadata back to SAF file"}"""
}
raw
// FLAC: Go wrote directly to temp, copy back now
if (!writeUriFromPath(uri, tempPath)) {
try { File(tempPath).delete() } catch (_: Exception) {}
return@withContext """{"error":"Failed to write metadata back to SAF file"}"""
}
try { File(tempPath).delete() } catch (_: Exception) {}
raw
} catch (e: Exception) {
try { File(tempPath).delete() } catch (_: Exception) {}
throw e

View File

@@ -311,11 +311,11 @@ func scanAudioFileWithKnownModTimeAndDisplayNameAndCoverCacheKey(filePath, displ
switch ext {
case ".flac":
return scanFLACFile(filePath, result)
return scanFLACFile(filePath, result, displayNameHint)
case ".m4a":
return scanM4AFile(filePath, result)
return scanM4AFile(filePath, result, displayNameHint)
case ".mp3":
return scanMP3File(filePath, result)
return scanMP3File(filePath, result, displayNameHint)
case ".opus", ".ogg":
return scanOggFile(filePath, result, displayNameHint)
default:
@@ -351,10 +351,10 @@ func applyDefaultLibraryMetadata(filePath, displayNameHint string, result *Libra
}
}
func scanFLACFile(filePath string, result *LibraryScanResult) (*LibraryScanResult, error) {
func scanFLACFile(filePath string, result *LibraryScanResult, displayNameHint string) (*LibraryScanResult, error) {
metadata, err := ReadMetadata(filePath)
if err != nil {
return scanFromFilename(filePath, "", result)
return scanFromFilename(filePath, displayNameHint, result)
}
result.TrackName = metadata.Title
@@ -376,14 +376,19 @@ func scanFLACFile(filePath string, result *LibraryScanResult) (*LibraryScanResul
}
}
applyDefaultLibraryMetadata(filePath, "", result)
applyDefaultLibraryMetadata(filePath, displayNameHint, result)
return result, nil
}
func scanM4AFile(filePath string, result *LibraryScanResult) (*LibraryScanResult, error) {
func scanM4AFile(filePath string, result *LibraryScanResult, displayNameHint string) (*LibraryScanResult, error) {
metadata, err := ReadM4ATags(filePath)
if err == nil && metadata != nil {
if err != nil {
GoLog("[LibraryScan] M4A read error for %s: %v\n", filePath, err)
return scanFromFilename(filePath, displayNameHint, result)
}
if metadata != nil {
result.TrackName = metadata.Title
result.ArtistName = metadata.Artist
result.AlbumName = metadata.Album
@@ -404,15 +409,15 @@ func scanM4AFile(filePath string, result *LibraryScanResult) (*LibraryScanResult
result.SampleRate = quality.SampleRate
}
applyDefaultLibraryMetadata(filePath, "", result)
applyDefaultLibraryMetadata(filePath, displayNameHint, result)
return result, nil
}
func scanMP3File(filePath string, result *LibraryScanResult) (*LibraryScanResult, error) {
func scanMP3File(filePath string, result *LibraryScanResult, displayNameHint string) (*LibraryScanResult, error) {
metadata, err := ReadID3Tags(filePath)
if err != nil {
GoLog("[LibraryScan] ID3 read error for %s: %v\n", filePath, err)
return scanFromFilename(filePath, "", result)
return scanFromFilename(filePath, displayNameHint, result)
}
result.TrackName = metadata.Title
@@ -439,7 +444,7 @@ func scanMP3File(filePath string, result *LibraryScanResult) (*LibraryScanResult
}
}
applyDefaultLibraryMetadata(filePath, "", result)
applyDefaultLibraryMetadata(filePath, displayNameHint, result)
return result, nil
}

View File

@@ -25,7 +25,6 @@ type LogBuffer struct {
const (
defaultLogBufferSize = 500
maxLogMessageLength = 500
)
var (
@@ -58,14 +57,6 @@ func GetLogBuffer() *LogBuffer {
return globalLogBuffer
}
func truncateLogMessage(message string) string {
runes := []rune(message)
if len(runes) <= maxLogMessageLength {
return message
}
return string(runes[:maxLogMessageLength]) + "...[truncated]"
}
func (lb *LogBuffer) SetLoggingEnabled(enabled bool) {
lb.mu.Lock()
defer lb.mu.Unlock()
@@ -87,7 +78,6 @@ func (lb *LogBuffer) Add(level, tag, message string) {
}
message = sanitizeSensitiveLogText(message)
message = truncateLogMessage(message)
entry := LogEntry{
Timestamp: time.Now().Format("15:04:05.000"),

View File

@@ -982,7 +982,7 @@ func convertToLRCWithMetadata(lyrics *LyricsResponse, trackName, artistName stri
builder.WriteString(fmt.Sprintf("[ti:%s]\n", trackName))
builder.WriteString(fmt.Sprintf("[ar:%s]\n", artistName))
builder.WriteString("[by:SpotiFLAC-Mobile]\n")
builder.WriteString("[by:Implemented by SpotiFLAC-Mobile using Paxsenix API]\n")
builder.WriteString("\n")
if lyrics.SyncType == "LINE_SYNCED" {

View File

@@ -2662,6 +2662,15 @@ func downloadFromQobuz(req DownloadRequest) (QobuzDownloadResult, error) {
req.DiscNumber,
)
// Prefer the cover URL the frontend sent (user-selected album) over the
// track's default album cover returned by the Qobuz track/get API, which
// may belong to a different album when the same track appears on multiple
// releases.
resultCoverURL := strings.TrimSpace(req.CoverURL)
if resultCoverURL == "" {
resultCoverURL = strings.TrimSpace(qobuzTrackAlbumImage(track))
}
return QobuzDownloadResult{
FilePath: outputPath,
BitDepth: actualBitDepth,
@@ -2673,7 +2682,7 @@ func downloadFromQobuz(req DownloadRequest) (QobuzDownloadResult, error) {
TrackNumber: resultTrackNumber,
DiscNumber: resultDiscNumber,
ISRC: track.ISRC,
CoverURL: strings.TrimSpace(qobuzTrackAlbumImage(track)),
CoverURL: resultCoverURL,
LyricsLRC: lyricsLRC,
}, nil
}

View File

@@ -519,7 +519,7 @@ class _LogEntryTile extends StatelessWidget {
),
const SizedBox(height: 6),
Text(
entry.message,
entry.previewMessage,
style: TextStyle(
fontSize: 13,
fontFamily: 'monospace',
@@ -527,10 +527,10 @@ class _LogEntryTile extends StatelessWidget {
height: 1.4,
),
),
if (entry.error != null) ...[
if (entry.previewError != null) ...[
const SizedBox(height: 4),
Text(
entry.error!,
entry.previewError!,
style: TextStyle(
fontSize: 12,
fontFamily: 'monospace',

View File

@@ -1158,8 +1158,11 @@ class FFmpegService {
// For M4A/MP4, cover art is mapped as a video stream and stored in the
// 'covr' atom automatically by FFmpeg. The '-disposition attached_pic'
// flag is only valid for Matroska/WebM containers and must NOT be used here.
// Force the mp4 muxer when cover art is present because the default ipod
// muxer (auto-selected for .m4a) does not register a codec tag for mjpeg,
// causing "codec not currently supported in container" on FFmpeg 8.0+.
if (hasCover) {
cmdBuffer.write('-map 1:v -c:v copy ');
cmdBuffer.write('-map 1:v -c:v copy -f mp4 ');
}
cmdBuffer.write('-c:a copy ');

View File

@@ -89,6 +89,10 @@ class LogEntry {
return '$h:$m:$s.$ms';
}
String get previewMessage => _truncateLogText(message);
String? get previewError => error == null ? null : _truncateLogText(error!);
@override
String toString() {
final errorPart = error != null ? ' | $error' : '';
@@ -128,11 +132,9 @@ class LogBuffer extends ChangeNotifier {
return;
}
final sanitizedMessage = _truncateLogText(
_redactSensitiveText(entry.message),
);
final sanitizedMessage = _redactSensitiveText(entry.message);
final sanitizedError = entry.error != null
? _truncateLogText(_redactSensitiveText(entry.error!))
? _redactSensitiveText(entry.error!)
: null;
final sanitizedEntry =
(sanitizedMessage == entry.message && sanitizedError == entry.error)
@@ -381,9 +383,7 @@ class BufferedOutput extends LogOutput {
}
final level = _levelToString(event.level);
final message = _truncateLogText(
_redactSensitiveText(event.lines.join('\n')),
);
final message = _redactSensitiveText(event.lines.join('\n'));
LogBuffer().add(
LogEntry(