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 ed4597b5..a1fa9283 100644 --- a/android/app/src/main/kotlin/com/zarz/spotiflac/NativeDownloadFinalizer.kt +++ b/android/app/src/main/kotlin/com/zarz/spotiflac/NativeDownloadFinalizer.kt @@ -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( diff --git a/android/app/src/main/kotlin/com/zarz/spotiflac/NativeFinalizationPolicy.kt b/android/app/src/main/kotlin/com/zarz/spotiflac/NativeFinalizationPolicy.kt index f3dfb4c0..1c930178 100644 --- a/android/app/src/main/kotlin/com/zarz/spotiflac/NativeFinalizationPolicy.kt +++ b/android/app/src/main/kotlin/com/zarz/spotiflac/NativeFinalizationPolicy.kt @@ -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" diff --git a/android/app/src/test/kotlin/com/zarz/spotiflac/NativeFinalizationPolicyTest.kt b/android/app/src/test/kotlin/com/zarz/spotiflac/NativeFinalizationPolicyTest.kt index e09fdb0b..cf2bc816 100644 --- a/android/app/src/test/kotlin/com/zarz/spotiflac/NativeFinalizationPolicyTest.kt +++ b/android/app/src/test/kotlin/com/zarz/spotiflac/NativeFinalizationPolicyTest.kt @@ -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( diff --git a/lib/providers/download_queue_provider_finalization.dart b/lib/providers/download_queue_provider_finalization.dart index 038f8344..df16c0be 100644 --- a/lib/providers/download_queue_provider_finalization.dart +++ b/lib/providers/download_queue_provider_finalization.dart @@ -44,6 +44,44 @@ bool _isMp4Container(String path) { return lower.endsWith('.m4a') || lower.endsWith('.mp4'); } +Future _normalizeDecryptedIsoBmffAudioPath( + String path, + Map 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 diff --git a/lib/utils/audio_format_utils.dart b/lib/utils/audio_format_utils.dart index c7395b75..0bd44c94 100644 --- a/lib/utils/audio_format_utils.dart +++ b/lib/utils/audio_format_utils.dart @@ -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. /// diff --git a/test/models_and_utils_test.dart b/test/models_and_utils_test.dart index de89cd28..8e16a36e 100644 --- a/test/models_and_utils_test.dart +++ b/test/models_and_utils_test.dart @@ -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', () {