From 81547013f957e8d89edf8ad0002679d8fc0353b5 Mon Sep 17 00:00:00 2001 From: zarzet Date: Mon, 11 May 2026 00:52:02 +0700 Subject: [PATCH] fix: gate M4A to FLAC conversion on a codec probe in every branch The SAF and local post-download branches used to rush an ffmpeg 'M4A to FLAC' remux whenever the output extension was .flac, which silently upscaled AAC or EAC3 streams into a lossless container. Each branch now mirrors the native worker by probing the primary audio codec before converting: lossless sources (and true FLAC-in-MP4 files) stay in their native container with the right extension, while genuine ALAC/WAV payloads still get remuxed. Add an outputExt field to DownloadRequestPayload so the Go backend always knows the user-requested container, and use it together with _shouldRequestContainerConversion to pick the right behaviour for shouldPreserveNativeM4a and the Kotlin finalizer. Decryption descriptors no longer force M4A preservation on their own; the codec probe already makes that call correctly. --- .../zarz/spotiflac/NativeDownloadFinalizer.kt | 5 +- lib/providers/download_queue_provider.dart | 271 +++++++++++------- lib/services/download_request_payload.dart | 4 + test/models_and_utils_test.dart | 2 + 4 files changed, 176 insertions(+), 106 deletions(-) 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 4aff6ae6..0113a5e1 100644 --- a/android/app/src/main/kotlin/com/zarz/spotiflac/NativeDownloadFinalizer.kt +++ b/android/app/src/main/kotlin/com/zarz/spotiflac/NativeDownloadFinalizer.kt @@ -517,8 +517,9 @@ object NativeDownloadFinalizer { ) { if (requestQuality(input) == "HIGH" || outputExt(input) != ".flac") return val requestedDecryptionExt = requestedDecryptionOutputExt(input) - if (requestedDecryptionExt.isNotBlank() && requestedDecryptionExt != ".flac") return - val mayNeedContainerConversion = shouldForceContainerConversion(input, state) || + val forceContainerConversion = shouldForceContainerConversion(input, state) + if (!forceContainerConversion && requestedDecryptionExt.isNotBlank() && requestedDecryptionExt != ".flac") return + val mayNeedContainerConversion = forceContainerConversion || looksLikeM4a(state.filePath, state.fileName) || state.filePath.startsWith("content://") if (!mayNeedContainerConversion) return diff --git a/lib/providers/download_queue_provider.dart b/lib/providers/download_queue_provider.dart index 44ac748a..51442574 100644 --- a/lib/providers/download_queue_provider.dart +++ b/lib/providers/download_queue_provider.dart @@ -3060,6 +3060,11 @@ class DownloadQueueNotifier extends Notifier { ); } + bool _shouldRequestContainerConversion(String service, String outputExt) { + return outputExt.trim().toLowerCase() == '.flac' && + _extensionRequiresNativeContainerConversion(service); + } + String _determineOutputExt(String quality, String service) { final extensionPreferred = _extensionPreferredOutputExt(service); if (extensionPreferred != null) { @@ -5585,11 +5590,13 @@ class DownloadQueueNotifier extends Notifier { safRelativeDir: isSafMode ? outputDir : '', safFileName: safFileName ?? '', safOutputExt: safOutputExt, + outputExt: outputExt, stageSafOutput: isSafMode, deferSafPublish: isSafMode, - requiresContainerConversion: - outputExt == '.flac' && - _extensionRequiresNativeContainerConversion(item.service), + requiresContainerConversion: _shouldRequestContainerConversion( + item.service, + outputExt, + ), songLinkRegion: settings.songLinkRegion, ).withStrategy(useExtensions: true, useFallback: state.autoFallback); @@ -6238,7 +6245,9 @@ class DownloadQueueNotifier extends Notifier { DownloadDecryptionDescriptor.fromDownloadResult( result, )?.normalizedOutputExtension; - if (requestedDecryptionExt != null && requestedDecryptionExt != '.flac') { + if (!requiresContainerConversion && + requestedDecryptionExt != null && + requestedDecryptionExt != '.flac') { _log.d( 'Native-worker decrypted output requested $requestedDecryptionExt; preserving native container.', ); @@ -7118,7 +7127,8 @@ class DownloadQueueNotifier extends Notifier { final treeUri = useSaf ? settings.downloadTreeUri : ''; final relativeDir = useSaf ? outputDir : ''; final fileName = useSaf ? (safFileName ?? '') : ''; - final outputExt = useSaf ? safOutputExt : ''; + final outputExt = safOutputExt; + final safPayloadOutputExt = useSaf ? outputExt : ''; final shouldUseExtensions = useExtensions; final shouldUseFallback = state.autoFallback; @@ -7218,10 +7228,12 @@ class DownloadQueueNotifier extends Notifier { safTreeUri: treeUri, safRelativeDir: relativeDir, safFileName: fileName, - safOutputExt: outputExt, - requiresContainerConversion: - outputExt == '.flac' && - _extensionRequiresNativeContainerConversion(item.service), + safOutputExt: safPayloadOutputExt, + outputExt: outputExt, + requiresContainerConversion: _shouldRequestContainerConversion( + item.service, + outputExt, + ), songLinkRegion: settings.songLinkRegion, ); @@ -7336,14 +7348,19 @@ class DownloadQueueNotifier extends Notifier { result, filePath: filePath, ); + final requiresContainerConversion = + result['requires_container_conversion'] == true || + result['requiresContainerConversion'] == true || + _shouldRequestContainerConversion(actualService, safOutputExt); final preferredOutputExt = _extensionPreferredOutputExt(actualService); final shouldPreserveNativeM4a = - resultOutputExt == '.m4a' || - resultOutputExt == '.mp4' || - preferredOutputExt == '.m4a' || - preferredOutputExt == '.mp4' || - _extensionPreservesNativeOutputExt(actualService, '.m4a') || - _extensionPreservesNativeOutputExt(actualService, '.mp4'); + !requiresContainerConversion && + (resultOutputExt == '.m4a' || + resultOutputExt == '.mp4' || + preferredOutputExt == '.m4a' || + preferredOutputExt == '.mp4' || + _extensionPreservesNativeOutputExt(actualService, '.m4a') || + _extensionPreservesNativeOutputExt(actualService, '.mp4')); final decryptionDescriptor = DownloadDecryptionDescriptor.fromDownloadResult(result); trackToDownload = _buildTrackForMetadataEmbedding( @@ -7611,9 +7628,7 @@ class DownloadQueueNotifier extends Notifier { } } } - } else if (shouldPreserveNativeM4a || - currentFilePath.toLowerCase().endsWith('.mp4') || - decryptionDescriptor != null) { + } else if (shouldPreserveNativeM4a) { // Decrypted streams are already in their final format. // Converting e.g. eac3 M4A to FLAC would produce fake upscaled output. _log.d( @@ -7689,60 +7704,94 @@ class DownloadQueueNotifier extends Notifier { if (length < 1024) { _log.w('Temp M4A is too small (<1KB), skipping conversion'); } else { - updateItemStatus( - item.id, - DownloadStatus.finalizing, - progress: 0.95, + final codec = await FFmpegService.probePrimaryAudioCodec( + tempPath, ); - flacPath = await FFmpegService.convertM4aToFlac(tempPath); - if (flacPath != null) { - _log.d('Converted to FLAC (temp): $flacPath'); + final isAlreadyNativeFlac = + codec == 'flac' && + await FFmpegService.isNativeFlacFile(tempPath); + if (!FFmpegService.isLosslessAudioCodec(codec) || + isAlreadyNativeFlac) { _log.d( - 'Embedding metadata and cover to converted FLAC...', + 'Preserving native container; audio codec is ${codec ?? 'unknown'}' + '${isAlreadyNativeFlac ? ' (native FLAC)' : ''}, ' + 'no FLAC container conversion needed.', ); - final finalTrack = _buildTrackForMetadataEmbedding( - trackToDownload, - result, - resolvedAlbumArtist, - ); - - final backendGenre = result['genre'] as String?; - final backendLabel = result['label'] as String?; - final backendCopyright = result['copyright'] as String?; - - await _embedMetadataToFile( - flacPath, - finalTrack, - format: 'flac', - genre: backendGenre ?? genre, - label: backendLabel ?? label, - copyright: backendCopyright, - downloadService: item.service, - writeExternalLrc: false, - ); - - final newFileName = '${safBaseName ?? 'track'}.flac'; + final preserveExt = resultOutputExt == '.mp4' + ? '.mp4' + : '.m4a'; + final newFileName = + '${safBaseName ?? 'track'}$preserveExt'; final newUri = await _writeTempToSaf( treeUri: settings.downloadTreeUri, relativeDir: effectiveOutputDir, fileName: newFileName, - mimeType: _mimeTypeForExt('.flac'), - srcPath: flacPath, + mimeType: _mimeTypeForExt(preserveExt), + srcPath: tempPath, ); - if (newUri != null) { if (newUri != currentFilePath) { await _deleteSafFile(currentFilePath); } filePath = newUri; finalSafFileName = newFileName; - } else { - _log.w('Failed to write FLAC to SAF, keeping M4A'); } } else { - _log.w( - 'FFmpeg conversion returned null, keeping M4A file', + updateItemStatus( + item.id, + DownloadStatus.finalizing, + progress: 0.95, ); + flacPath = await FFmpegService.convertM4aToFlac(tempPath); + if (flacPath != null) { + _log.d('Converted to FLAC (temp): $flacPath'); + _log.d( + 'Embedding metadata and cover to converted FLAC...', + ); + final finalTrack = _buildTrackForMetadataEmbedding( + trackToDownload, + result, + resolvedAlbumArtist, + ); + + final backendGenre = result['genre'] as String?; + final backendLabel = result['label'] as String?; + final backendCopyright = result['copyright'] as String?; + + await _embedMetadataToFile( + flacPath, + finalTrack, + format: 'flac', + genre: backendGenre ?? genre, + label: backendLabel ?? label, + copyright: backendCopyright, + downloadService: item.service, + writeExternalLrc: false, + ); + + final newFileName = '${safBaseName ?? 'track'}.flac'; + final newUri = await _writeTempToSaf( + treeUri: settings.downloadTreeUri, + relativeDir: effectiveOutputDir, + fileName: newFileName, + mimeType: _mimeTypeForExt('.flac'), + srcPath: flacPath, + ); + + if (newUri != null) { + if (newUri != currentFilePath) { + await _deleteSafFile(currentFilePath); + } + filePath = newUri; + finalSafFileName = newFileName; + } else { + _log.w('Failed to write FLAC to SAF, keeping M4A'); + } + } else { + _log.w( + 'FFmpeg conversion returned null, keeping M4A file', + ); + } } } } catch (e) { @@ -7834,9 +7883,7 @@ class DownloadQueueNotifier extends Notifier { _log.w('M4A conversion process failed: $e, keeping M4A file'); actualQuality = 'AAC 320kbps'; } - } else if (shouldPreserveNativeM4a || - currentFilePath.toLowerCase().endsWith('.mp4') || - decryptionDescriptor != null) { + } else if (shouldPreserveNativeM4a) { _log.d('M4A/MP4 file detected, preserving native container...'); try { @@ -7909,58 +7956,74 @@ class DownloadQueueNotifier extends Notifier { 'File is too small (<1KB), skipping conversion. Download might be corrupt.', ); } else { - updateItemStatus( - item.id, - DownloadStatus.finalizing, - progress: 0.95, - ); - final flacPath = await FFmpegService.convertM4aToFlac( + final codec = await FFmpegService.probePrimaryAudioCodec( currentFilePath, ); - - if (flacPath != null) { - filePath = flacPath; - _log.d('Converted to FLAC: $flacPath'); - + final isAlreadyNativeFlac = + codec == 'flac' && + await FFmpegService.isNativeFlacFile(currentFilePath); + if (!FFmpegService.isLosslessAudioCodec(codec) || + isAlreadyNativeFlac) { _log.d( - 'Embedding metadata and cover to converted FLAC...', + 'Preserving native container; audio codec is ${codec ?? 'unknown'}' + '${isAlreadyNativeFlac ? ' (native FLAC)' : ''}, ' + 'no FLAC container conversion needed.', ); - try { - final finalTrack = _buildTrackForMetadataEmbedding( - trackToDownload, - result, - resolvedAlbumArtist, - ); - - final backendGenre = result['genre'] as String?; - final backendLabel = result['label'] as String?; - final backendCopyright = result['copyright'] as String?; - - if (backendGenre != null || - backendLabel != null || - backendCopyright != null) { - _log.d( - 'Extended metadata from backend - Genre: $backendGenre, Label: $backendLabel, Copyright: $backendCopyright', - ); - } - - await _embedMetadataToFile( - flacPath, - finalTrack, - format: 'flac', - genre: backendGenre ?? genre, - label: backendLabel ?? label, - copyright: backendCopyright, - downloadService: item.service, - ); - _log.d('Metadata and cover embedded successfully'); - } catch (e) { - _log.w('Warning: Failed to embed metadata/cover: $e'); - } } else { - _log.w( - 'FFmpeg conversion returned null, keeping M4A file', + updateItemStatus( + item.id, + DownloadStatus.finalizing, + progress: 0.95, ); + final flacPath = await FFmpegService.convertM4aToFlac( + currentFilePath, + ); + + if (flacPath != null) { + filePath = flacPath; + _log.d('Converted to FLAC: $flacPath'); + + _log.d( + 'Embedding metadata and cover to converted FLAC...', + ); + try { + final finalTrack = _buildTrackForMetadataEmbedding( + trackToDownload, + result, + resolvedAlbumArtist, + ); + + final backendGenre = result['genre'] as String?; + final backendLabel = result['label'] as String?; + final backendCopyright = + result['copyright'] as String?; + + if (backendGenre != null || + backendLabel != null || + backendCopyright != null) { + _log.d( + 'Extended metadata from backend - Genre: $backendGenre, Label: $backendLabel, Copyright: $backendCopyright', + ); + } + + await _embedMetadataToFile( + flacPath, + finalTrack, + format: 'flac', + genre: backendGenre ?? genre, + label: backendLabel ?? label, + copyright: backendCopyright, + downloadService: item.service, + ); + _log.d('Metadata and cover embedded successfully'); + } catch (e) { + _log.w('Warning: Failed to embed metadata/cover: $e'); + } + } else { + _log.w( + 'FFmpeg conversion returned null, keeping M4A file', + ); + } } } } diff --git a/lib/services/download_request_payload.dart b/lib/services/download_request_payload.dart index 7bd3aa8e..d803169f 100644 --- a/lib/services/download_request_payload.dart +++ b/lib/services/download_request_payload.dart @@ -43,6 +43,7 @@ class DownloadRequestPayload { final String safRelativeDir; final String safFileName; final String safOutputExt; + final String outputExt; final bool stageSafOutput; final bool deferSafPublish; final bool requiresContainerConversion; @@ -91,6 +92,7 @@ class DownloadRequestPayload { this.safRelativeDir = '', this.safFileName = '', this.safOutputExt = '', + this.outputExt = '', this.stageSafOutput = false, this.deferSafPublish = false, this.requiresContainerConversion = false, @@ -141,6 +143,7 @@ class DownloadRequestPayload { 'saf_relative_dir': safRelativeDir, 'saf_file_name': safFileName, 'saf_output_ext': safOutputExt, + 'output_ext': outputExt, 'stage_saf_output': stageSafOutput, 'defer_saf_publish': deferSafPublish, 'requires_container_conversion': requiresContainerConversion, @@ -195,6 +198,7 @@ class DownloadRequestPayload { safRelativeDir: safRelativeDir, safFileName: safFileName, safOutputExt: safOutputExt, + outputExt: outputExt, stageSafOutput: stageSafOutput, deferSafPublish: deferSafPublish, requiresContainerConversion: requiresContainerConversion, diff --git a/test/models_and_utils_test.dart b/test/models_and_utils_test.dart index 86367785..d4082e26 100644 --- a/test/models_and_utils_test.dart +++ b/test/models_and_utils_test.dart @@ -328,6 +328,7 @@ void main() { safRelativeDir: 'Album', safFileName: 'Song.flac', safOutputExt: 'flac', + outputExt: '.flac', songLinkRegion: 'ID', ); @@ -374,6 +375,7 @@ void main() { 'saf_relative_dir': 'Album', 'saf_file_name': 'Song.flac', 'saf_output_ext': 'flac', + 'output_ext': '.flac', 'stage_saf_output': false, 'defer_saf_publish': false, 'requires_container_conversion': false,