diff --git a/lib/providers/download_queue_provider_single_item.dart b/lib/providers/download_queue_provider_single_item.dart index f5d15948..841da951 100644 --- a/lib/providers/download_queue_provider_single_item.dart +++ b/lib/providers/download_queue_provider_single_item.dart @@ -1002,13 +1002,14 @@ class _DownloadRun { (localFile.uri.pathSegments.isEmpty ? 'track$safOutputExt' : localFile.uri.pathSegments.last); - final localExt = n._downloadResultOutputExt(result, filePath: localPath); - if (localExt != null && localExt.isNotEmpty) { - finalName = finalName.replaceFirst(RegExp(r'\.[^.]+$'), localExt); - if (!finalName.toLowerCase().endsWith(localExt.toLowerCase())) { - finalName = '$finalName$localExt'; - } - } + finalName = finalizedAudioFileName( + fileName: finalName, + localPath: localPath, + fallbackExtension: + n._downloadResultOutputExt(result, filePath: localPath) ?? + safOutputExt, + ); + final localExt = finalName.substring(finalName.lastIndexOf('.')); final measured = probedFinalMetadata; final qualityLabel = buildQualityVariantFilenameLabel( @@ -1039,7 +1040,11 @@ class _DownloadRun { String? publishedName; var alreadyExists = false; if (item.preserveQualityVariant && qualityVariantCollisionOnly) { - final logicalName = safFileName ?? finalName; + final logicalName = finalizedAudioFileName( + fileName: safFileName ?? finalName, + localPath: localPath, + fallbackExtension: localExt, + ); final stagingLabel = qualityVariantStagingLabel(item.id); final cleanName = removeQualityVariantStagingLabel( fileName: logicalName, @@ -1057,7 +1062,7 @@ class _DownloadRun { relativeDir: effectiveOutputDir, cleanFileName: cleanName, variantFileName: variantName, - mimeType: n._mimeTypeForExt(localExt ?? safOutputExt), + mimeType: n._mimeTypeForExt(localExt), srcPath: localPath, preservedSuffix: qualityLabel ?? '', ); @@ -1068,7 +1073,7 @@ class _DownloadRun { treeUri: settings.downloadTreeUri, relativeDir: effectiveOutputDir, fileName: finalName, - mimeType: n._mimeTypeForExt(localExt ?? safOutputExt), + mimeType: n._mimeTypeForExt(localExt), srcPath: localPath, preservedSuffix: qualityLabel ?? '', ); @@ -1079,7 +1084,7 @@ class _DownloadRun { treeUri: settings.downloadTreeUri, relativeDir: effectiveOutputDir, fileName: finalName, - mimeType: n._mimeTypeForExt(localExt ?? safOutputExt), + mimeType: n._mimeTypeForExt(localExt), srcPath: localPath, ); publishedUri = published?.uri; diff --git a/lib/utils/audio_format_utils.dart b/lib/utils/audio_format_utils.dart index 5bfb6fe6..f8b14848 100644 --- a/lib/utils/audio_format_utils.dart +++ b/lib/utils/audio_format_utils.dart @@ -134,6 +134,23 @@ String isoBmffAudioExtensionForCodec(String? codec) { return normalizeAudioFormatValue(codec) == 'ac4' ? '.mp4' : '.m4a'; } +/// Uses the finalized local container after conversion, before publishing to +/// SAF. Provider output fields still describe the original download here. +String finalizedAudioFileName({ + required String fileName, + required String localPath, + required String fallbackExtension, +}) { + final extension = + RegExp( + r'\.(flac|m4a|mp4|mp3|opus|ogg|aac|wav|aiff)$', + caseSensitive: false, + ).firstMatch(localPath)?.group(0)?.toLowerCase() ?? + fallbackExtension; + final stem = fileName.replaceFirst(RegExp(r'\.[^.]+$'), ''); + return '$stem$extension'; +} + /// 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 dfe39a80..92aabcd8 100644 --- a/test/models_and_utils_test.dart +++ b/test/models_and_utils_test.dart @@ -23,6 +23,47 @@ import 'package:spotiflac_android/utils/path_match_keys.dart'; import 'package:spotiflac_android/utils/string_utils.dart'; void main() { + group('Finalized SAF audio names', () { + for (final extension in ['mp3', 'opus', 'flac', 'ogg', 'm4a', 'mp4']) { + test( + 'publishes the final $extension container instead of source M4A', + () { + expect( + finalizedAudioFileName( + fileName: 'Example song.m4a', + localPath: '/cache/final_audio.$extension', + fallbackExtension: '.m4a', + ), + 'Example song.$extension', + ); + }, + ); + } + test('preserves variant labels and normalizes uppercase suffixes', () { + expect( + finalizedAudioFileName( + fileName: 'Song [OPUS 256kbps].m4a', + localPath: '/cache/final.OPUS', + fallbackExtension: '.m4a', + ), + 'Song [OPUS 256kbps].opus', + ); + }); + test( + 'uses the reported container when the temporary path has no suffix', + () { + expect( + finalizedAudioFileName( + fileName: 'Song', + localPath: '/cache/final_audio', + fallbackExtension: '.m4a', + ), + 'Song.m4a', + ); + }, + ); + }); + group('file deletion', () { test('confirms a local file is absent before reporting success', () async { final tempDir = await Directory.systemTemp.createTemp(