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 24c5d6c6..b42adf5e 100644 --- a/android/app/src/main/kotlin/com/zarz/spotiflac/NativeDownloadFinalizer.kt +++ b/android/app/src/main/kotlin/com/zarz/spotiflac/NativeDownloadFinalizer.kt @@ -503,13 +503,21 @@ object NativeDownloadFinalizer { if (requestQuality(input) == "HIGH" || outputExt(input) != ".flac") return val requestedDecryptionExt = requestedDecryptionOutputExt(input) if (requestedDecryptionExt.isNotBlank() && requestedDecryptionExt != ".flac") return - if (!looksLikeM4a(state.filePath, state.fileName) && !shouldForceContainerConversion(input, state)) return + val mayNeedContainerConversion = shouldForceContainerConversion(input, state) || + looksLikeM4a(state.filePath, state.fileName) || + state.filePath.startsWith("content://") + if (!mayNeedContainerConversion) return val localInput = materializeForFFmpeg(context, input, state) val deleteLocalInput = state.filePath.startsWith("content://") val output = buildOutputPath(localInput, ".flac") 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" }}") + return + } val result = runFFmpeg( "-v error -xerror -i ${q(localInput)} -c:a flac -compression_level 8 ${q(output)} -y", shouldCancel, @@ -1317,19 +1325,34 @@ object NativeDownloadFinalizer { private fun shouldForceContainerConversion(input: FinalizeInput, state: FinalizeState): Boolean { if (input.result.optBoolean("requires_container_conversion", false)) return true if (input.request.optBoolean("requires_container_conversion", false)) return true + return false + } - val actualExt = normalizeExt( - input.result.optString("actual_extension", "") - .ifBlank { input.result.optString("output_extension", "") } + private fun probePrimaryAudioCodec(path: String, shouldCancel: () -> Boolean = { false }): String { + val result = runFFmpeg("-hide_banner -nostdin -i ${q(path)} -map 0:a:0 -frames:a 1 -f null -", shouldCancel) + val output = result.second + val match = Regex("Audio:\\s*([^,\\s]+)", RegexOption.IGNORE_CASE).find(output) + return match?.groupValues?.getOrNull(1) + ?.trim() + ?.lowercase(Locale.ROOT) + ?.replace('-', '_') + .orEmpty() + } + + private fun isLosslessAudioCodec(codec: String): Boolean { + val normalized = codec.trim().lowercase(Locale.ROOT).replace('-', '_') + if (normalized.isBlank()) return false + if (normalized.startsWith("pcm_")) return true + return normalized in setOf( + "alac", + "flac", + "wavpack", + "ape", + "tta", + "mlp", + "truehd", + "shorten" ) - if (actualExt == ".m4a" || actualExt == ".mp4") return true - - val container = input.result.optString("actual_container", "") - .ifBlank { input.result.optString("container", "") } - .trim() - .lowercase(Locale.ROOT) - .removePrefix(".") - return container == "m4a" || container == "mp4" || container == "mov" || container == "aac" } private fun requestedDecryptionOutputExt(input: FinalizeInput): String { diff --git a/lib/providers/download_queue_provider.dart b/lib/providers/download_queue_provider.dart index ed85b11c..396a8096 100644 --- a/lib/providers/download_queue_provider.dart +++ b/lib/providers/download_queue_provider.dart @@ -3029,6 +3029,47 @@ class DownloadQueueNotifier extends Notifier { } } + String? _normalizeAudioExt(Object? value) { + final raw = value?.toString().trim().toLowerCase(); + if (raw == null || raw.isEmpty) return null; + final normalized = raw.startsWith('.') ? raw : '.$raw'; + const allowed = {'.flac', '.m4a', '.mp4', '.mp3', '.opus', '.ogg', '.aac'}; + return allowed.contains(normalized) ? normalized : null; + } + + String? _downloadResultOutputExt( + Map result, { + String? filePath, + }) { + final explicit = + _normalizeAudioExt(result['actual_extension']) ?? + _normalizeAudioExt(result['output_extension']) ?? + _normalizeAudioExt(result['actual_container']) ?? + _normalizeAudioExt(result['container']); + if (explicit != null) return explicit; + + for (final candidate in [ + result['file_name'] as String?, + filePath, + result['file_path'] as String?, + ]) { + if (candidate == null) continue; + final lower = candidate.trim().toLowerCase(); + for (final ext in const [ + '.flac', + '.m4a', + '.mp4', + '.mp3', + '.opus', + '.ogg', + '.aac', + ]) { + if (lower.endsWith(ext)) return ext; + } + } + return null; + } + Future _getSafMimeType(String uri) async { try { final stat = await PlatformBridge.safStat(uri); @@ -6049,6 +6090,25 @@ class DownloadQueueNotifier extends Notifier { if (context.quality == 'HIGH' || context.outputExt != '.flac') { return filePath; } + final requiresContainerConversion = + result['requires_container_conversion'] == true || + result['requiresContainerConversion'] == true; + final resultOutputExt = _downloadResultOutputExt( + result, + filePath: filePath, + ); + final lowerPath = filePath.toLowerCase(); + final resultFileName = (result['file_name'] as String?)?.toLowerCase(); + final mayNeedContainerConversion = + requiresContainerConversion || + lowerPath.endsWith('.m4a') || + lowerPath.endsWith('.mp4') || + resultOutputExt == '.m4a' || + resultOutputExt == '.mp4' || + isContentUri(filePath); + if (!mayNeedContainerConversion) { + return filePath; + } final requestedDecryptionExt = DownloadDecryptionDescriptor.fromDownloadResult( result, @@ -6059,15 +6119,17 @@ class DownloadQueueNotifier extends Notifier { ); return filePath; } - final lowerPath = filePath.toLowerCase(); - final resultFileName = (result['file_name'] as String?)?.toLowerCase(); final looksLikeM4a = lowerPath.endsWith('.m4a') || lowerPath.endsWith('.mp4') || + resultOutputExt == '.m4a' || + resultOutputExt == '.mp4' || (resultFileName != null && (resultFileName.endsWith('.m4a') || resultFileName.endsWith('.mp4'))); - if (!looksLikeM4a && !isContentUri(filePath)) { + if (!requiresContainerConversion && + !looksLikeM4a && + !isContentUri(filePath)) { return filePath; } @@ -6097,6 +6159,13 @@ class DownloadQueueNotifier extends Notifier { String? flacPath; try { + final codec = await FFmpegService.probePrimaryAudioCodec(tempPath); + if (!FFmpegService.isLosslessAudioCodec(codec) || codec == 'flac') { + _log.d( + 'Preserving native container; audio codec is ${codec ?? 'unknown'}, not a lossless source needing FLAC conversion.', + ); + return filePath; + } flacPath = await FFmpegService.convertM4aToFlac(tempPath); if (flacPath == null) { return null; @@ -6133,6 +6202,13 @@ class DownloadQueueNotifier extends Notifier { } } + final codec = await FFmpegService.probePrimaryAudioCodec(filePath); + if (!FFmpegService.isLosslessAudioCodec(codec) || codec == 'flac') { + _log.d( + 'Preserving native container; audio codec is ${codec ?? 'unknown'}, not a lossless source needing FLAC conversion.', + ); + return filePath; + } final flacPath = await FFmpegService.convertM4aToFlac(filePath); if (flacPath == null) { return null; @@ -7010,6 +7086,9 @@ class DownloadQueueNotifier extends Notifier { safRelativeDir: relativeDir, safFileName: fileName, safOutputExt: outputExt, + requiresContainerConversion: + outputExt == '.flac' && + _extensionRequiresNativeContainerConversion(item.service), songLinkRegion: settings.songLinkRegion, ); @@ -7120,8 +7199,14 @@ class DownloadQueueNotifier extends Notifier { final actualService = ((result['service'] as String?)?.toLowerCase()) ?? item.service.toLowerCase(); + final resultOutputExt = _downloadResultOutputExt( + result, + filePath: filePath, + ); final preferredOutputExt = _extensionPreferredOutputExt(actualService); final shouldPreserveNativeM4a = + resultOutputExt == '.m4a' || + resultOutputExt == '.mp4' || preferredOutputExt == '.m4a' || preferredOutputExt == '.mp4' || _extensionPreservesNativeOutputExt(actualService, '.m4a') || @@ -7258,10 +7343,13 @@ class DownloadQueueNotifier extends Notifier { filePath != null && (filePath.endsWith('.m4a') || filePath.endsWith('.mp4') || + resultOutputExt == '.m4a' || + resultOutputExt == '.mp4' || (mimeType != null && mimeType.contains('mp4'))); final isFlacFile = filePath != null && (filePath.endsWith('.flac') || + resultOutputExt == '.flac' || (mimeType != null && mimeType.contains('flac'))); final shouldForceTidalSafM4aHandling = !wasExisting && @@ -7756,10 +7844,15 @@ class DownloadQueueNotifier extends Notifier { !isM4aFile && !wasExisting) { final currentFilePath = filePath; - final isOpusFile = filePath.endsWith('.opus'); - final isMp3File = filePath.endsWith('.mp3'); + final isOpusFile = + filePath.endsWith('.opus') || + filePath.endsWith('.ogg') || + resultOutputExt == '.opus' || + resultOutputExt == '.ogg'; + final isMp3File = + filePath.endsWith('.mp3') || resultOutputExt == '.mp3'; final ext = isOpusFile - ? '.opus' + ? (resultOutputExt == '.ogg' ? '.ogg' : '.opus') : isMp3File ? '.mp3' : '.flac'; diff --git a/lib/services/ffmpeg_service.dart b/lib/services/ffmpeg_service.dart index 4cc5080a..88aa7238 100644 --- a/lib/services/ffmpeg_service.dart +++ b/lib/services/ffmpeg_service.dart @@ -5,6 +5,7 @@ import 'dart:typed_data'; import 'package:ffmpeg_kit_flutter_new_full/ffmpeg_kit.dart'; import 'package:ffmpeg_kit_flutter_new_full/ffmpeg_kit_config.dart'; import 'package:ffmpeg_kit_flutter_new_full/ffmpeg_session.dart'; +import 'package:ffmpeg_kit_flutter_new_full/ffprobe_kit.dart'; import 'package:ffmpeg_kit_flutter_new_full/return_code.dart'; import 'package:ffmpeg_kit_flutter_new_full/session_state.dart'; import 'package:path_provider/path_provider.dart'; @@ -248,6 +249,40 @@ class FFmpegService { } } + static Future probePrimaryAudioCodec(String filePath) async { + try { + final session = await FFprobeKit.getMediaInformation(filePath); + final info = session.getMediaInformation(); + if (info == null) return null; + + for (final stream in info.getStreams()) { + final props = stream.getAllProperties() ?? const {}; + if (props['codec_type']?.toString() != 'audio') continue; + final codec = props['codec_name']?.toString().trim().toLowerCase(); + return codec == null || codec.isEmpty ? null : codec; + } + } catch (e) { + _log.w('Audio codec probe failed for $filePath: $e'); + } + return null; + } + + static bool isLosslessAudioCodec(String? codec) { + final normalized = codec?.trim().toLowerCase().replaceAll('-', '_') ?? ''; + if (normalized.isEmpty) return false; + if (normalized.startsWith('pcm_')) return true; + return const { + 'alac', + 'flac', + 'wavpack', + 'ape', + 'tta', + 'mlp', + 'truehd', + 'shorten', + }.contains(normalized); + } + static Future convertM4aToFlac(String inputPath) async { final outputPath = _buildOutputPath(inputPath, '.flac');