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.
This commit is contained in:
zarzet 2026-05-10 18:50:49 +07:00
parent 4b7146afe4
commit 8f5c59683a
3 changed files with 80 additions and 8 deletions

View file

@ -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

View file

@ -6160,9 +6160,13 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
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<DownloadQueueState> {
}
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;
}

View file

@ -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<bool> 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<String?> 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);