mirror of
https://github.com/spotiflacapp/SpotiFLAC-Mobile.git
synced 2026-06-01 03:15:17 +07:00
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:
parent
4b7146afe4
commit
8f5c59683a
3 changed files with 80 additions and 8 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
Loading…
Reference in a new issue