fix(download): normalize MP4 audio extension by codec (#512)

This commit is contained in:
zarzet
2026-08-11 19:07:00 +07:00
parent 4693e2f1d2
commit 5374883875
6 changed files with 146 additions and 13 deletions
@@ -542,7 +542,12 @@ object NativeDownloadFinalizer {
if (successPath != null) break
}
val decryptedPath = successPath ?: throw IllegalStateException("decrypt failed: $lastOutput")
val rawDecryptedPath = successPath ?: throw IllegalStateException("decrypt failed: $lastOutput")
val decryptedPath = normalizeDecryptedIsoBmffAudioPath(
rawDecryptedPath,
state,
shouldCancel,
)
replaceStatePath(context, input, state, decryptedPath, deleteOld = true)
} finally {
if (successPath == null) {
@@ -554,6 +559,37 @@ object NativeDownloadFinalizer {
}
}
private fun normalizeDecryptedIsoBmffAudioPath(
path: String,
state: FinalizeState,
shouldCancel: () -> Boolean,
): String {
if (!isMP4ContainerFile(path)) return path
val probedCodec = probePrimaryAudioCodec(path, shouldCancel)
val codec = normalizeAudioCodec(probedCodec.ifBlank { state.audioCodec.orEmpty() })
val desiredExt = NativeFinalizationPolicy.isoBmffAudioExtension(codec)
state.audioCodec = codec
if (path.lowercase(Locale.ROOT).endsWith(desiredExt)) return path
val target = File(buildOutputPath(path, desiredExt))
if (target.exists()) {
Log.w(TAG, "Cannot normalize ISO-BMFF audio extension; ${target.name} already exists")
return path
}
val source = File(path)
if (!source.renameTo(target)) {
Log.w(TAG, "Failed to normalize ISO-BMFF audio extension to ${target.name}")
return path
}
Log.i(
TAG,
"ISO-BMFF audio renamed: ${source.name} -> ${target.name} " +
"(codec=${codec.orEmpty().ifBlank { "unknown" }})",
)
return target.absolutePath
}
private fun finalizeHighConversion(
context: Context,
input: FinalizeInput,
@@ -814,10 +850,10 @@ object NativeDownloadFinalizer {
if (!currentFile.name.lowercase(Locale.ROOT).endsWith(".flac")) return
val newExt = when {
codec == "aac" && isMP4ContainerFile(localInput) -> ".m4a"
isMP4ContainerFile(localInput) ->
NativeFinalizationPolicy.isoBmffAudioExtension(codec)
codec == "mp3" -> ".mp3"
codec == "opus" -> ".opus"
isMP4ContainerFile(localInput) -> ".m4a"
else -> return
}
val renamed = File(
@@ -68,6 +68,15 @@ internal object NativeFinalizationPolicy {
}
}
/**
* Audio-only ISO-BMFF files conventionally use .m4a. AC-4 remains .mp4
* because its passthrough and standards-repair path depends on that
* container identity.
*/
fun isoBmffAudioExtension(audioCodec: String?): String {
return if (normalizeAudioCodec(audioCodec) == "ac4") ".mp4" else ".m4a"
}
fun audioFormatForCodec(codec: String?): String? {
return when (normalizeAudioCodec(codec)) {
"flac" -> "FLAC"
@@ -76,6 +76,26 @@ class NativeFinalizationPolicyTest {
assertFalse(NativeFinalizationPolicy.isLosslessAudioCodec("aac"))
}
@Test
fun isoBmffAudioUsesM4aExceptForAc4Passthrough() {
assertEquals(
".m4a",
NativeFinalizationPolicy.isoBmffAudioExtension("opus"),
)
assertEquals(
".m4a",
NativeFinalizationPolicy.isoBmffAudioExtension("ec-3"),
)
assertEquals(
".m4a",
NativeFinalizationPolicy.isoBmffAudioExtension("aac"),
)
assertEquals(
".mp4",
NativeFinalizationPolicy.isoBmffAudioExtension("ac-4"),
)
}
@Test
fun displayQualityUsesMeasuredLosslessSpecifications() {
assertEquals(
@@ -44,6 +44,44 @@ bool _isMp4Container(String path) {
return lower.endsWith('.m4a') || lower.endsWith('.mp4');
}
Future<String> _normalizeDecryptedIsoBmffAudioPath(
String path,
Map<String, dynamic> result,
) async {
if (!_isMp4Container(path)) return path;
final probedCodec = await FFmpegService.probePrimaryAudioCodec(path);
final reportedCodec =
result['audio_codec']?.toString() ??
result['actual_audio_codec']?.toString() ??
result['format']?.toString();
final desiredExt = isoBmffAudioExtensionForCodec(
probedCodec ?? reportedCodec,
);
if (path.toLowerCase().endsWith(desiredExt)) return path;
final targetPath = path.replaceFirst(
RegExp(r'\.(?:m4a|mp4)$', caseSensitive: false),
desiredExt,
);
if (targetPath == path) return path;
try {
final target = File(targetPath);
if (await target.exists()) {
_log.w(
'Cannot normalize ISO-BMFF audio extension; target already exists: '
'$targetPath',
);
return path;
}
final renamed = await File(path).rename(targetPath);
return renamed.path;
} catch (e) {
_log.w('Failed to normalize ISO-BMFF audio extension for $path: $e');
return path;
}
}
extension _DownloadQueueFinalization on DownloadQueueNotifier {
Future<_AutoConversionOutcome> _autoConvertDownloadedFile({
required String itemId,
@@ -770,16 +808,24 @@ extension _DownloadQueueFinalization on DownloadQueueNotifier {
relativeDir: safRelativeDir,
op: (tempPath, addCleanup) async {
opStarted = true;
final decryptedTempPath = await FFmpegService.decryptWithDescriptor(
inputPath: tempPath,
descriptor: descriptor,
deleteOriginal: false,
);
if (decryptedTempPath == null) {
final rawDecryptedTempPath =
await FFmpegService.decryptWithDescriptor(
inputPath: tempPath,
descriptor: descriptor,
deleteOriginal: false,
);
if (rawDecryptedTempPath == null) {
failStage = DownloadQueueNotifier._decryptStageDecrypt;
return null;
}
addCleanup(decryptedTempPath);
addCleanup(rawDecryptedTempPath);
final decryptedTempPath = await _normalizeDecryptedIsoBmffAudioPath(
rawDecryptedTempPath,
result,
);
if (decryptedTempPath != rawDecryptedTempPath) {
addCleanup(decryptedTempPath);
}
if (repairAc4 && _isMp4Container(decryptedTempPath)) {
try {
await PlatformBridge.ensureAC4Config(decryptedTempPath, tempPath);
@@ -814,12 +860,12 @@ extension _DownloadQueueFinalization on DownloadQueueNotifier {
}
if (repairAc4) {
final decryptedPath = await FFmpegService.decryptWithDescriptor(
final rawDecryptedPath = await FFmpegService.decryptWithDescriptor(
inputPath: filePath,
descriptor: descriptor,
deleteOriginal: false,
);
if (decryptedPath == null) {
if (rawDecryptedPath == null) {
try {
await deleteFile(filePath);
} catch (_) {}
@@ -828,6 +874,10 @@ extension _DownloadQueueFinalization on DownloadQueueNotifier {
failStage: DownloadQueueNotifier._decryptStageDecrypt,
);
}
final decryptedPath = await _normalizeDecryptedIsoBmffAudioPath(
rawDecryptedPath,
result,
);
if (_isMp4Container(decryptedPath)) {
try {
await PlatformBridge.ensureAC4Config(decryptedPath, filePath);
@@ -841,11 +891,14 @@ extension _DownloadQueueFinalization on DownloadQueueNotifier {
return _DecryptOutcome(decryptedPath);
}
final decryptedPath = await FFmpegService.decryptWithDescriptor(
final rawDecryptedPath = await FFmpegService.decryptWithDescriptor(
inputPath: filePath,
descriptor: descriptor,
deleteOriginal: true,
);
final decryptedPath = rawDecryptedPath == null
? null
: await _normalizeDecryptedIsoBmffAudioPath(rawDecryptedPath, result);
return _DecryptOutcome(
decryptedPath,
failStage: decryptedPath == null
+8
View File
@@ -118,6 +118,14 @@ String? normalizeAudioFormatValue(String? value) {
};
}
/// Chooses the user-facing extension for an audio-only ISO-BMFF container.
/// AC-4 stays `.mp4` because its compatibility repair and passthrough path
/// depend on that container identity; other audio codecs use the conventional
/// `.m4a` extension, including Opus and E-AC-3 streams carried inside MP4.
String isoBmffAudioExtensionForCodec(String? codec) {
return normalizeAudioFormatValue(codec) == 'ac4' ? '.mp4' : '.m4a';
}
/// Resolves the actual audio codec reported by native metadata probing, while
/// falling back to the container format when the codec is absent or generic.
///
+7
View File
@@ -1038,6 +1038,13 @@ void main() {
);
});
test('uses m4a for ISO-BMFF audio except AC-4 passthrough', () {
expect(isoBmffAudioExtensionForCodec('opus'), '.m4a');
expect(isoBmffAudioExtensionForCodec('ec-3'), '.m4a');
expect(isoBmffAudioExtensionForCodec('aac'), '.m4a');
expect(isoBmffAudioExtensionForCodec('ac-4'), '.mp4');
});
test(
'detects Dolby formats from stored scan format before file extension',
() {