From 8f5c59683a9672e72079768fe4c5af45e3acd678 Mon Sep 17 00:00:00 2001 From: zarzet Date: Sun, 10 May 2026 18:50:49 +0700 Subject: [PATCH] fix: force native FLAC muxer when decrypting to .flac output Downloads from providers that stream FLAC inside an fMP4 container (e.g. Amazon Music) were being written to disk with a .flac extension while the payload still carried ISO-BMFF atoms. The container-conversion guard then saw codec=flac and skipped the remux, leaving native FLAC tag writers to fail with 'fLaC head incorrect'. Force '-f flac' on the decryption command whenever the target extension is .flac so FFmpeg emits a real FLAC stream, and add an 'fLaC' magic-byte probe on both the Dart and Kotlin container-conversion guards so a FLAC-in-MP4 source is remuxed rather than silently passed through as a tag-writer hazard. --- .../zarz/spotiflac/NativeDownloadFinalizer.kt | 39 +++++++++++++++++-- lib/providers/download_queue_provider.dart | 16 ++++++-- lib/services/ffmpeg_service.dart | 33 +++++++++++++++- 3 files changed, 80 insertions(+), 8 deletions(-) diff --git a/android/app/src/main/kotlin/com/zarz/spotiflac/NativeDownloadFinalizer.kt b/android/app/src/main/kotlin/com/zarz/spotiflac/NativeDownloadFinalizer.kt index b42adf5e..b64527c0 100644 --- a/android/app/src/main/kotlin/com/zarz/spotiflac/NativeDownloadFinalizer.kt +++ b/android/app/src/main/kotlin/com/zarz/spotiflac/NativeDownloadFinalizer.kt @@ -16,6 +16,7 @@ import com.antonkarpenko.ffmpegkit.ReturnCode import gobackend.Gobackend import org.json.JSONObject import java.io.File +import java.io.RandomAccessFile import java.nio.ByteBuffer import java.util.Locale import java.util.concurrent.CancellationException @@ -419,7 +420,13 @@ object NativeDownloadFinalizer { for ((candidateOutput, mapAudioOnly) in attempts) { try { val audioMap = if (mapAudioOnly) "-map 0:a " else "" - val command = "-v error -decryption_key ${q(candidate)} -f $inputFormat -i ${q(localInput)} ${audioMap}-c copy ${q(candidateOutput)} -y" + // Force the flac muxer when the target extension is + // .flac. Without this override FFmpeg keeps the ISO-BMFF + // stream layout, producing FLAC-in-MP4 under a .flac + // filename which downstream native FLAC tag writers + // cannot read. + val muxerOverride = if (candidateOutput.lowercase(Locale.ROOT).endsWith(".flac")) "-f flac " else "" + val command = "-v error -decryption_key ${q(candidate)} -f $inputFormat -i ${q(localInput)} ${audioMap}-c copy ${muxerOverride}${q(candidateOutput)} -y" val result = runFFmpeg(command, shouldCancel) lastOutput = result.second if (result.first && File(candidateOutput).exists()) { @@ -514,8 +521,10 @@ object NativeDownloadFinalizer { var adoptedOutput = false try { val codec = probePrimaryAudioCodec(localInput, shouldCancel) - if (!isLosslessAudioCodec(codec) || codec == "flac") { - Log.d(TAG, "Preserving native container; audio codec is ${codec.ifBlank { "unknown" }}") + val isAlreadyNativeFlac = codec == "flac" && isNativeFlacFile(localInput) + if (!isLosslessAudioCodec(codec) || isAlreadyNativeFlac) { + val suffix = if (isAlreadyNativeFlac) " (native FLAC)" else "" + Log.d(TAG, "Preserving native container; audio codec is ${codec.ifBlank { "unknown" }}$suffix") return } val result = runFFmpeg( @@ -1339,6 +1348,30 @@ object NativeDownloadFinalizer { .orEmpty() } + /** + * Returns true when the file on [path] starts with the native FLAC magic + * bytes (`fLaC`). A file may contain a FLAC audio stream yet live inside + * an MP4/fMP4 container (e.g. some Amazon Music downloads); native FLAC + * tag writers require the raw fLaC header, so we must detect that mismatch + * before skipping the container conversion step. + */ + private fun isNativeFlacFile(path: String): Boolean { + return try { + RandomAccessFile(path, "r").use { raf -> + if (raf.length() < 4L) return false + val header = ByteArray(4) + raf.readFully(header) + header[0] == 0x66.toByte() && // 'f' + header[1] == 0x4C.toByte() && // 'L' + header[2] == 0x61.toByte() && // 'a' + header[3] == 0x43.toByte() // 'C' + } + } catch (e: Exception) { + Log.w(TAG, "Native FLAC magic probe failed for $path: ${e.message}") + false + } + } + private fun isLosslessAudioCodec(codec: String): Boolean { val normalized = codec.trim().lowercase(Locale.ROOT).replace('-', '_') if (normalized.isBlank()) return false diff --git a/lib/providers/download_queue_provider.dart b/lib/providers/download_queue_provider.dart index 396a8096..7413a6b4 100644 --- a/lib/providers/download_queue_provider.dart +++ b/lib/providers/download_queue_provider.dart @@ -6160,9 +6160,13 @@ class DownloadQueueNotifier extends Notifier { String? flacPath; try { final codec = await FFmpegService.probePrimaryAudioCodec(tempPath); - if (!FFmpegService.isLosslessAudioCodec(codec) || codec == 'flac') { + final isAlreadyNativeFlac = + codec == 'flac' && await FFmpegService.isNativeFlacFile(tempPath); + if (!FFmpegService.isLosslessAudioCodec(codec) || isAlreadyNativeFlac) { _log.d( - 'Preserving native container; audio codec is ${codec ?? 'unknown'}, not a lossless source needing FLAC conversion.', + 'Preserving native container; audio codec is ${codec ?? 'unknown'}' + '${isAlreadyNativeFlac ? ' (native FLAC)' : ''}, ' + 'no FLAC container conversion needed.', ); return filePath; } @@ -6203,9 +6207,13 @@ class DownloadQueueNotifier extends Notifier { } final codec = await FFmpegService.probePrimaryAudioCodec(filePath); - if (!FFmpegService.isLosslessAudioCodec(codec) || codec == 'flac') { + final isAlreadyNativeFlac = + codec == 'flac' && await FFmpegService.isNativeFlacFile(filePath); + if (!FFmpegService.isLosslessAudioCodec(codec) || isAlreadyNativeFlac) { _log.d( - 'Preserving native container; audio codec is ${codec ?? 'unknown'}, not a lossless source needing FLAC conversion.', + 'Preserving native container; audio codec is ${codec ?? 'unknown'}' + '${isAlreadyNativeFlac ? ' (native FLAC)' : ''}, ' + 'no FLAC container conversion needed.', ); return filePath; } diff --git a/lib/services/ffmpeg_service.dart b/lib/services/ffmpeg_service.dart index 88aa7238..97c50e43 100644 --- a/lib/services/ffmpeg_service.dart +++ b/lib/services/ffmpeg_service.dart @@ -283,6 +283,28 @@ class FFmpegService { }.contains(normalized); } + /// Returns `true` when [filePath] starts with the native FLAC magic bytes + /// (`fLaC`). Useful to distinguish a real FLAC file from a FLAC-in-MP4 + /// container that carries a `.flac` extension or claims codec=flac. + static Future isNativeFlacFile(String filePath) async { + try { + final raf = await File(filePath).open(); + try { + final header = await raf.read(4); + return header.length == 4 && + header[0] == 0x66 && // 'f' + header[1] == 0x4C && // 'L' + header[2] == 0x61 && // 'a' + header[3] == 0x43; // 'C' + } finally { + await raf.close(); + } + } catch (e) { + _log.w('Native FLAC magic probe failed for $filePath: $e'); + return false; + } + } + static Future convertM4aToFlac(String inputPath) async { final outputPath = _buildOutputPath(inputPath, '.flac'); @@ -435,7 +457,16 @@ class FFmpegService { // Force MOV demuxer: -decryption_key is only supported by the MOV/MP4 // demuxer. The input may carry a .flac extension (SAF mode) while actually // containing an encrypted M4A stream, so we must override auto-detection. - return '-v error -decryption_key "$key" -f $demuxerFormat -i "$inputPath" $audioMap-c copy "$outputPath" -y'; + // + // When the requested output is a native .flac we also force the flac + // muxer (-f flac). Without it, FFmpeg infers the muxer from the output + // extension AND keeps the input container's stream layout, which for + // FLAC-in-MP4 sources would still emit an ISO-BMFF payload under a + // .flac filename. That file fails native FLAC tag writers later on. + final muxerOverride = outputPath.toLowerCase().endsWith('.flac') + ? '-f flac ' + : ''; + return '-v error -decryption_key "$key" -f $demuxerFormat -i "$inputPath" $audioMap-c copy $muxerOverride"$outputPath" -y'; } final keyCandidates = _buildDecryptionKeyCandidates(decryptionKey);