diff --git a/android/app/src/main/kotlin/com/zarz/spotiflac/MainActivity.kt b/android/app/src/main/kotlin/com/zarz/spotiflac/MainActivity.kt index c607317..fbb6b0f 100644 --- a/android/app/src/main/kotlin/com/zarz/spotiflac/MainActivity.kt +++ b/android/app/src/main/kotlin/com/zarz/spotiflac/MainActivity.kt @@ -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 diff --git a/go_backend/library_scan.go b/go_backend/library_scan.go index 58d8e65..085d8f6 100644 --- a/go_backend/library_scan.go +++ b/go_backend/library_scan.go @@ -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 } diff --git a/go_backend/logbuffer.go b/go_backend/logbuffer.go index 9501259..bae68c0 100644 --- a/go_backend/logbuffer.go +++ b/go_backend/logbuffer.go @@ -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"), diff --git a/go_backend/lyrics.go b/go_backend/lyrics.go index ef3d855..cd4c790 100644 --- a/go_backend/lyrics.go +++ b/go_backend/lyrics.go @@ -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" { diff --git a/go_backend/qobuz.go b/go_backend/qobuz.go index ca1c1f6..b5f449d 100644 --- a/go_backend/qobuz.go +++ b/go_backend/qobuz.go @@ -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 } diff --git a/lib/screens/settings/log_screen.dart b/lib/screens/settings/log_screen.dart index 06c0173..d1537ae 100644 --- a/lib/screens/settings/log_screen.dart +++ b/lib/screens/settings/log_screen.dart @@ -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', diff --git a/lib/services/ffmpeg_service.dart b/lib/services/ffmpeg_service.dart index 2496020..8355f66 100644 --- a/lib/services/ffmpeg_service.dart +++ b/lib/services/ffmpeg_service.dart @@ -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 '); diff --git a/lib/utils/logger.dart b/lib/utils/logger.dart index 2601c70..6c0a9db 100644 --- a/lib/utils/logger.dart +++ b/lib/utils/logger.dart @@ -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(