mirror of
https://github.com/zarzet/SpotiFLAC-Mobile.git
synced 2026-08-26 12:52:40 +02:00
fix(download): normalize MP4 audio extension by codec (#512)
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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.
|
||||
///
|
||||
|
||||
@@ -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',
|
||||
() {
|
||||
|
||||
Reference in New Issue
Block a user