From b8787168d14886ebda54df0991818af4db69451c Mon Sep 17 00:00:00 2001 From: zarzet <42882290+zarzet@users.noreply.github.com> Date: Tue, 6 Oct 2026 13:09:26 +0700 Subject: [PATCH] refactor(downloads): isolate typed results and audio finalization --- .../spotiflac/NativeFinalizationPolicyTest.kt | 20 + .../finalization_container_cases.tsv | 17 + lib/models/download_result.dart | 135 ++++ lib/providers/download_queue_provider.dart | 8 +- .../download_queue_provider_finalization.dart | 733 ++++-------------- .../download_queue_provider_hires_check.dart | 13 +- ...download_queue_provider_native_worker.dart | 33 +- .../download_queue_provider_paths.dart | 64 -- .../download_queue_provider_single_item.dart | 143 ++-- .../download_container_finalizer.dart | 122 +++ lib/services/download_file_finalizer.dart | 311 ++++++++ lib/services/download_saf_file_replacer.dart | 84 ++ lib/services/ffmpeg_probe.dart | 17 +- lib/utils/audio_format_utils.dart | 24 + test/download_container_finalizer_test.dart | 241 ++++++ test/download_container_policy_test.dart | 28 + test/download_file_finalizer_test.dart | 397 ++++++++++ test/download_result_test.dart | 108 +++ test/download_saf_file_replacer_test.dart | 147 ++++ 19 files changed, 1875 insertions(+), 770 deletions(-) create mode 100644 android/app/src/test/resources/finalization_container_cases.tsv create mode 100644 lib/models/download_result.dart create mode 100644 lib/services/download_container_finalizer.dart create mode 100644 lib/services/download_file_finalizer.dart create mode 100644 lib/services/download_saf_file_replacer.dart create mode 100644 test/download_container_finalizer_test.dart create mode 100644 test/download_container_policy_test.dart create mode 100644 test/download_file_finalizer_test.dart create mode 100644 test/download_result_test.dart create mode 100644 test/download_saf_file_replacer_test.dart 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 b68c3215..41734573 100644 --- a/android/app/src/test/kotlin/com/zarz/spotiflac/NativeFinalizationPolicyTest.kt +++ b/android/app/src/test/kotlin/com/zarz/spotiflac/NativeFinalizationPolicyTest.kt @@ -55,6 +55,26 @@ class NativeFinalizationPolicyTest { assertEquals(0L, NativeFinalizationPolicy.durationMilliseconds(0, -1)) } + @Test + fun matchesSharedContainerConversionCases() { + val stream = checkNotNull(javaClass.getResourceAsStream("/finalization_container_cases.tsv")) + stream.bufferedReader().useLines { lines -> + for (line in lines) { + if (line.isBlank() || line.startsWith("#")) continue + val fields = line.split('\t') + assertEquals("invalid shared fixture: $line", 3, fields.size) + assertEquals( + line, + fields[2].toBooleanStrict(), + NativeFinalizationPolicy.shouldAttemptLosslessContainerConversion( + forceConversion = fields[0].toBooleanStrict(), + probedCodec = fields[1], + ), + ) + } + } + } + @Test fun lateAlbumMetadataResolvesOnlyThePendingFolderLeaf() { assertEquals( diff --git a/android/app/src/test/resources/finalization_container_cases.tsv b/android/app/src/test/resources/finalization_container_cases.tsv new file mode 100644 index 00000000..6dffbb5f --- /dev/null +++ b/android/app/src/test/resources/finalization_container_cases.tsv @@ -0,0 +1,17 @@ +# force_conversion codec expected +false false +true true +true null true +false m4a false +true m4a true +true MP4 true +false flac true +false ALAC true +false pcm_s24le true +false wavpack true +true aac false +true mp4a false +true mp3 false +true ogg false +true ac-4 false +true unknown false diff --git a/lib/models/download_result.dart b/lib/models/download_result.dart new file mode 100644 index 00000000..ab67b066 --- /dev/null +++ b/lib/models/download_result.dart @@ -0,0 +1,135 @@ +import 'dart:collection'; + +import 'package:spotiflac_android/models/download_item.dart'; +import 'package:spotiflac_android/services/ffmpeg_models.dart'; +import 'package:spotiflac_android/utils/audio_format_utils.dart'; +import 'package:spotiflac_android/utils/download_error_type.dart'; +import 'package:spotiflac_android/utils/int_utils.dart'; + +/// Typed access to the download protocol, retaining extension-specific fields. +/// Wraps the original map so metadata helpers and native reconciliation observe +/// the same finalization updates without translating or dropping unknown keys. +class DownloadResult extends MapBase { + factory DownloadResult.fromMap(Map values) => + values is DownloadResult ? values : DownloadResult._(values); + + DownloadResult._(this._values); + final Map _values; + + bool get success => _values['success'] == true; + bool get alreadyExists => _values['already_exists'] == true; + bool get nativeFinalized => _values['native_finalized'] == true; + bool get deferredSafPublish => _values['saf_deferred_publish'] == true; + String? get filePath => _values['file_path'] as String?; + set filePath(String? value) => _values['file_path'] = value; + String? get fileName => _values['file_name'] as String?; + set fileName(String? value) => _values['file_name'] = value; + String? get service => _values['service'] as String?; + String? get resolvedSafDirectory { + final directory = _values['saf_relative_dir']; + return directory is String ? directory : null; + } + + int? get actualBitDepth => readPositiveInt(_values['actual_bit_depth']); + int? get actualSampleRate => readPositiveInt(_values['actual_sample_rate']); + int? get bitrateKbps => + readPositiveBitrateKbps(_values['bitrate'] ?? _values['actual_bitrate']); + String? get audioCodec => + (_values['audio_codec'] ?? _values['actual_audio_codec'])?.toString(); + String? get reportedFormat => audioCodec ?? _values['format']?.toString(); + bool get requiresContainerConversion => + _values['requires_container_conversion'] == true || + _values['requiresContainerConversion'] == true; + DownloadDecryptionDescriptor? get decryption => + DownloadDecryptionDescriptor.fromDownloadResult(this); + DownloadFailure? get failure => success ? null : DownloadFailure(this); + + void markConvertedAudio({ + required String path, + required String? name, + required String format, + required int bitrateKbps, + }) { + filePath = path; + fileName = name; + _values['audio_codec'] = format; + _values['format'] = format; + _values['bitrate'] = bitrateKbps; + _values.remove('actual_bit_depth'); + _values.remove('actual_sample_rate'); + } + + void markFlacContainer() { + _values['audio_codec'] = 'flac'; + _values['format'] = 'flac'; + _values['actual_extension'] = '.flac'; + _values['output_extension'] = '.flac'; + } + + String? outputExtension({String? path}) { + const extensions = { + '.flac', + '.m4a', + '.mp4', + '.mp3', + '.opus', + '.ogg', + '.aac', + }; + for (final key in [ + 'actual_extension', + 'output_extension', + 'actual_container', + 'container', + ]) { + final value = _values[key]?.toString().trim().toLowerCase(); + if (value == null || value.isEmpty) continue; + final extension = value.startsWith('.') ? value : '.$value'; + if (extensions.contains(extension)) return extension; + } + for (final candidate in [fileName, path, filePath]) { + if (candidate == null) continue; + final lower = candidate.trim().toLowerCase(); + for (final extension in extensions) { + if (lower.endsWith(extension)) return extension; + } + } + // SAF URIs may hide the suffix. Prefer actual codec over requested format. + return switch (normalizeAudioFormatValue(reportedFormat)) { + 'opus' => '.opus', + 'mp3' => '.mp3', + 'aac' || 'alac' || 'm4a' => '.m4a', + 'flac' => '.flac', + _ => null, + }; + } + + @override + dynamic operator [](Object? key) => _values[key]; + @override + void operator []=(String key, dynamic value) => _values[key] = value; + @override + Iterable get keys => _values.keys; + @override + dynamic remove(Object? key) => _values.remove(key); + @override + void clear() => _values.clear(); +} + +class DownloadFailure { + DownloadFailure(DownloadResult result) + : message = result['error'] as String? ?? 'Download failed', + backendType = result['error_type'] as String? ?? 'unknown', + retryAfterSeconds = readPositiveInt(result['retry_after_seconds']), + service = result.service; + + final String message; + final String backendType; + final int? retryAfterSeconds; + final String? service; + bool get cancelled => backendType == 'cancelled'; + DownloadErrorType? get type => downloadErrorTypeFromBackend(backendType); + String get retryMessage => retryAfterSeconds == null + ? message + : '$message retry-after: $retryAfterSeconds'; +} diff --git a/lib/providers/download_queue_provider.dart b/lib/providers/download_queue_provider.dart index 21dbb285..f2077178 100644 --- a/lib/providers/download_queue_provider.dart +++ b/lib/providers/download_queue_provider.dart @@ -10,6 +10,7 @@ import 'package:spotiflac_android/services/network_download_staging.dart'; import 'package:connectivity_plus/connectivity_plus.dart'; import 'package:shared_preferences/shared_preferences.dart'; import 'package:spotiflac_android/models/download_item.dart'; +import 'package:spotiflac_android/models/download_result.dart'; import 'package:spotiflac_android/models/settings.dart'; import 'package:spotiflac_android/models/track.dart'; import 'package:spotiflac_android/providers/settings_provider.dart'; @@ -19,6 +20,9 @@ import 'package:spotiflac_android/providers/download_verification_retry_guard.da import 'package:spotiflac_android/providers/download_queue_state.dart'; import 'package:spotiflac_android/services/download_queue_persistence.dart'; import 'package:spotiflac_android/services/download_progress.dart'; +import 'package:spotiflac_android/services/download_file_finalizer.dart'; +import 'package:spotiflac_android/services/download_container_finalizer.dart'; +import 'package:spotiflac_android/services/download_saf_file_replacer.dart'; import 'package:spotiflac_android/services/download_connectivity_policy.dart'; import 'package:spotiflac_android/services/platform_bridge.dart'; import 'package:spotiflac_android/services/download_request_payload.dart'; @@ -34,7 +38,6 @@ import 'package:spotiflac_android/utils/file_access.dart'; import 'package:spotiflac_android/utils/string_utils.dart'; import 'package:spotiflac_android/utils/artist_utils.dart'; import 'package:spotiflac_android/utils/audio_format_utils.dart'; -import 'package:spotiflac_android/utils/audio_conversion_utils.dart'; import 'package:spotiflac_android/utils/int_utils.dart'; import 'package:spotiflac_android/utils/extension_auth_launcher.dart'; import 'package:spotiflac_android/utils/download_error_type.dart'; @@ -314,9 +317,6 @@ class DownloadQueueNotifier extends Notifier { static const _nativePreparationWindowSize = 128; static const _nativeWorkerRunIdPrefsKey = 'download_queue_native_worker_run_id'; - static const _decryptStageSafAccess = 'safAccess'; - static const _decryptStageDecrypt = 'decrypt'; - static const _decryptStageSafWrite = 'safWrite'; final NotificationService _notificationService = NotificationService(); // Shared across tracks in a batch: an album's tracks embed the same cover, // so fetch it once instead of once per track. LRU-capped; files are deleted diff --git a/lib/providers/download_queue_provider_finalization.dart b/lib/providers/download_queue_provider_finalization.dart index c644d468..a638b654 100644 --- a/lib/providers/download_queue_provider_finalization.dart +++ b/lib/providers/download_queue_provider_finalization.dart @@ -1,16 +1,5 @@ part of 'download_queue_provider.dart'; -/// Result of [DownloadQueueNotifier._finalizeDecryption]. [failStage] is -/// only meaningful to the inline single-item pipeline, which surfaces a -/// distinct error message per stage; the native-worker pipeline uses one -/// generic message and ignores it. -class _DecryptOutcome { - final String? path; - final String? newFileName; - final String? failStage; - const _DecryptOutcome(this.path, {this.newFileName, this.failStage}); -} - class _QualityVariantFileOutcome { final String filePath; final String? fileName; @@ -23,20 +12,6 @@ class _QualityVariantFileOutcome { }); } -class _AutoConversionOutcome { - final String filePath; - final String? fileName; - final String quality; - final bool converted; - - const _AutoConversionOutcome({ - required this.filePath, - required this.fileName, - required this.quality, - required this.converted, - }); -} - /// AC-4 repair only applies to MP4 containers; decrypt can also emit raw /// FLAC, which the native MP4 box parser would reject as corrupt. bool _isMp4Container(String path) { @@ -44,45 +19,31 @@ 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 { + DownloadFileFinalizer get _fileFinalizer => DownloadFileFinalizer( + decryptFile: FFmpegService.decryptWithDescriptor, + probeCodec: FFmpegService.probePrimaryAudioCodec, + repairAc4: (path, source) async { + await PlatformBridge.ensureAC4Config(path, source); + }, + convertAudio: + ({ + required inputPath, + required targetFormat, + required bitrate, + required deleteOriginal, + }) => FFmpegService.convertAudioFormat( + inputPath: inputPath, + targetFormat: targetFormat, + bitrate: bitrate, + metadata: const {}, + deleteOriginal: deleteOriginal, + ), + replaceSafFile: _replaceSafFileVia, + warn: _log.w, + info: _log.i, + ); + Future _saveDownloadedMotionArtwork( Ref ref, DownloadItem item, @@ -124,7 +85,7 @@ extension _DownloadQueueFinalization on DownloadQueueNotifier { } } - Future<_AutoConversionOutcome> _autoConvertDownloadedFile({ + Future _autoConvertDownloadedFile({ required String itemId, required String filePath, required String? fileName, @@ -136,154 +97,36 @@ extension _DownloadQueueFinalization on DownloadQueueNotifier { required String storageMode, String? downloadTreeUri, String? safRelativeDir, - }) async { - if (!settings.autoConvertDownloads) { - return _AutoConversionOutcome( - filePath: filePath, - fileName: fileName, - quality: currentQuality, - converted: false, - ); - } - - final targetFormat = normalizeAutoConvertFormat(settings.autoConvertFormat); - final targetBitrate = normalizeAutoConvertBitrate( - settings.autoConvertBitrate, - ); - final targetBitrateKbps = autoConvertBitrateKbps(targetBitrate); - if (autoConversionAlreadySatisfied( - filePath: filePath, - fileName: fileName, - targetFormat: targetFormat, - targetBitrate: targetBitrate, - quality: currentQuality, - bitrateKbps: readPositiveBitrateKbps( - result['bitrate'] ?? result['actual_bitrate'], - ), - )) { - return _AutoConversionOutcome( - filePath: filePath, - fileName: fileName, - quality: currentQuality, - converted: false, - ); - } - - final baseFileName = (fileName?.trim().isNotEmpty == true - ? fileName!.trim() - : File(filePath).uri.pathSegments.last); - final convertedFileName = convertedOutputFileName( - originalFileName: baseFileName, - targetFormat: targetFormat, - ); - final convertedQuality = - '${displayFormatForLossyFormat(targetFormat)} ${targetBitrateKbps}kbps'; - - Future embedConvertedMetadata(String convertedPath) async { - if (!settings.embedMetadata) return; - try { - await _embedMetadataToFile( - convertedPath, - track, - format: metadataFormatForLossyFormat(targetFormat), - genre: result['genre'] as String?, - label: result['label'] as String?, - copyright: result['copyright'] as String?, - comment: result['comment'] as String?, - lyricsLrc: result['lyrics_lrc'] as String?, - downloadService: downloadService, - writeExternalLrc: storageMode != 'saf', - ); - } catch (e) { - // The audio conversion itself is still valid. Preserve the converted - // file if an optional tag/cover write fails. - _log.w('Automatic conversion metadata embed failed: $e'); - result['auto_conversion_metadata_warning'] = e.toString(); - } - } - - try { - updateItemStatus(itemId, DownloadStatus.finalizing, progress: 0.97); - String? convertedPath; - String? publishedFileName = convertedFileName; - if (storageMode == 'saf' && isContentUri(filePath)) { - if (downloadTreeUri == null || downloadTreeUri.isEmpty) { - throw StateError('Missing SAF tree for automatic conversion'); - } - convertedPath = await _replaceSafFileVia( - uri: filePath, - treeUri: downloadTreeUri, - relativeDir: safRelativeDir ?? '', - avoidOverwrite: - convertedFileName.toLowerCase() != baseFileName.toLowerCase(), - onPublishedFileName: (value) => publishedFileName = value, - op: (tempPath, addCleanup) async { - final output = await FFmpegService.convertAudioFormat( - inputPath: tempPath, - targetFormat: targetFormat, - bitrate: targetBitrate, - metadata: const {}, - deleteOriginal: false, + }) => _fileFinalizer.autoConvert( + result: DownloadResult.fromMap(result), + filePath: filePath, + fileName: fileName, + quality: currentQuality, + enabled: settings.autoConvertDownloads, + targetFormat: settings.autoConvertFormat, + targetBitrate: settings.autoConvertBitrate, + useSaf: storageMode == 'saf', + treeUri: downloadTreeUri, + relativeDir: safRelativeDir ?? '', + onStart: () => + updateItemStatus(itemId, DownloadStatus.finalizing, progress: 0.97), + embedMetadata: settings.embedMetadata + ? (path, format) async { + await _embedMetadataToFile( + path, + track, + format: format, + genre: result['genre'] as String?, + label: result['label'] as String?, + copyright: result['copyright'] as String?, + comment: result['comment'] as String?, + lyricsLrc: result['lyrics_lrc'] as String?, + downloadService: downloadService, + writeExternalLrc: storageMode != 'saf', ); - if (output == null) return null; - addCleanup(output); - await embedConvertedMetadata(output); - return (output, convertedFileName); - }, - ); - } else { - convertedPath = await FFmpegService.convertAudioFormat( - inputPath: filePath, - targetFormat: targetFormat, - bitrate: targetBitrate, - metadata: const {}, - deleteOriginal: true, - ); - if (convertedPath != null) { - // Deferred SAF downloads are converted in the cache before being - // published. Keep their logical filename instead of the temporary - // FFmpeg output basename (native_saf_work_...). - if (storageMode != 'saf') { - publishedFileName = File(convertedPath).uri.pathSegments.last; } - await embedConvertedMetadata(convertedPath); - } - } - - if (convertedPath == null || convertedPath.isEmpty) { - throw StateError('FFmpeg returned no automatic conversion output'); - } - - result['file_path'] = convertedPath; - result['file_name'] = publishedFileName; - result['audio_codec'] = targetFormat; - result['format'] = targetFormat; - result['bitrate'] = targetBitrateKbps; - result.remove('actual_bit_depth'); - result.remove('actual_sample_rate'); - _log.i( - 'Automatic conversion completed: ${autoConvertFormatLabel(targetFormat)} @ $targetBitrate', - ); - return _AutoConversionOutcome( - filePath: convertedPath, - fileName: publishedFileName, - quality: convertedQuality, - converted: true, - ); - } catch (e) { - // A successful download remains usable when the optional conversion - // fails. Conversion helpers only remove the source after atomic output - // promotion, so returning the original path is safe here. - result['auto_conversion_warning'] = e.toString(); - _log.w('Automatic conversion failed; keeping downloaded source: $e'); - return _AutoConversionOutcome( - filePath: filePath, - fileName: fileName, - quality: currentQuality, - converted: false, - ); - } - } + : null, + ); /// Builds the [DownloadHistoryItem] shared by the native-worker and inline /// completion paths. Fields whose source/derivation legitimately differs @@ -634,101 +477,74 @@ extension _DownloadQueueFinalization on DownloadQueueNotifier { } } - /// Shared "SAF roundtrip" used by every finalize step that needs to - /// transform a SAF file: copies [uri] to a local temp file, lets [op] - /// transform it (returning the local path to publish plus the file name - /// to publish it under, or null to abort), writes that file back into the - /// SAF tree, deletes the original SAF file if its URI changed, and always - /// cleans up the local temp file(s). Returns the new content:// URI, or - /// null if the temp copy, [op], or the SAF write failed. Future _replaceSafFileVia({ required String uri, required String treeUri, required String relativeDir, - required Future<(String path, String fileName)?> Function( - String tempPath, - void Function(String path) addCleanup, - ) - op, + required SafFileOperation op, bool avoidOverwrite = false, String preservedSuffix = '', String? collisionCleanFileName, String? collisionVariantFileName, void Function(String fileName)? onPublishedFileName, - }) async { - final tempPath = await _copySafToTemp(uri); - if (tempPath == null) return null; - // Files op produces are registered here the moment they exist, so they - // are cleaned up even if op throws before returning. - final producedTemps = {}; - String? outPath; - try { - final produced = await op(tempPath, producedTemps.add); - if (produced == null) return null; - outPath = produced.$1; - final fileName = produced.$2; - final dotIndex = fileName.lastIndexOf('.'); - final ext = dotIndex >= 0 ? fileName.substring(dotIndex) : ''; - String? newUri; - if (collisionCleanFileName != null && collisionVariantFileName != null) { - final published = await _writeTempToSafCollisionAware( - treeUri: treeUri, - relativeDir: relativeDir, - cleanFileName: collisionCleanFileName, - variantFileName: collisionVariantFileName, - mimeType: _mimeTypeForExt(ext), - srcPath: outPath, - preservedSuffix: preservedSuffix, - ); - newUri = published?.uri; - if (published != null) { - onPublishedFileName?.call(published.fileName); - } - } else if (avoidOverwrite) { - final published = await _writeTempToSafUnique( - treeUri: treeUri, - relativeDir: relativeDir, - fileName: fileName, - mimeType: _mimeTypeForExt(ext), - srcPath: outPath, - preservedSuffix: preservedSuffix, - ); - newUri = published?.uri; - if (published != null) { - onPublishedFileName?.call(published.fileName); - } - } else { - newUri = await _writeTempToSaf( - treeUri: treeUri, - relativeDir: relativeDir, - fileName: fileName, - mimeType: _mimeTypeForExt(ext), - srcPath: outPath, - ); - if (newUri != null) { - onPublishedFileName?.call(fileName); - } - } - if (newUri == null) return null; - if (newUri != uri) { - await _deleteSafFile(uri); - } - return newUri; - } finally { - try { - await File(tempPath).delete(); - } catch (_) {} - if (outPath != null) { - producedTemps.add(outPath); - } - for (final path in producedTemps) { - if (path == tempPath) continue; - try { - await File(path).delete(); - } catch (_) {} - } - } - } + }) => + DownloadSafFileReplacer( + copyToTemp: _copySafToTemp, + deleteSource: _deleteSafFile, + publish: + ({ + required treeUri, + required relativeDir, + required fileName, + required srcPath, + required avoidOverwrite, + required preservedSuffix, + collisionCleanFileName, + collisionVariantFileName, + }) async { + final dotIndex = fileName.lastIndexOf('.'); + final ext = dotIndex >= 0 ? fileName.substring(dotIndex) : ''; + if (collisionCleanFileName != null && + collisionVariantFileName != null) { + return _writeTempToSafCollisionAware( + treeUri: treeUri, + relativeDir: relativeDir, + cleanFileName: collisionCleanFileName, + variantFileName: collisionVariantFileName, + mimeType: _mimeTypeForExt(ext), + srcPath: srcPath, + preservedSuffix: preservedSuffix, + ); + } else if (avoidOverwrite) { + return _writeTempToSafUnique( + treeUri: treeUri, + relativeDir: relativeDir, + fileName: fileName, + mimeType: _mimeTypeForExt(ext), + srcPath: srcPath, + preservedSuffix: preservedSuffix, + ); + } + final newUri = await _writeTempToSaf( + treeUri: treeUri, + relativeDir: relativeDir, + fileName: fileName, + mimeType: _mimeTypeForExt(ext), + srcPath: srcPath, + ); + return newUri == null ? null : (uri: newUri, fileName: fileName); + }, + ).replace( + uri: uri, + treeUri: treeUri, + relativeDir: relativeDir, + op: op, + avoidOverwrite: avoidOverwrite, + preservedSuffix: preservedSuffix, + collisionCleanFileName: collisionCleanFileName, + collisionVariantFileName: collisionVariantFileName, + onPublishedFileName: onPublishedFileName, + ); Future<_QualityVariantFileOutcome> _finalizeQualityVariantFilename({ required DownloadItem item, @@ -932,306 +748,49 @@ extension _DownloadQueueFinalization on DownloadQueueNotifier { }); } - /// Shared decrypt finalize used by both the inline single-item pipeline - /// and the native-worker pipeline. Divergences captured as parameters: - /// [repairAc4] (inline repairs AC-4 containers using the still-encrypted - /// source; native-worker does not) and [onStart] (inline logs its own - /// "detected" message; native-worker logs a differently worded one). - Future<_DecryptOutcome> _finalizeDecryption({ - required Map result, - required String filePath, - required String storageMode, - String? downloadTreeUri, - required String safRelativeDir, - required String baseName, - required String extFallback, - required bool repairAc4, - void Function(String strategy)? onStart, - }) async { - if (result['already_exists'] == true) { - return _DecryptOutcome(filePath); - } - - final descriptor = DownloadDecryptionDescriptor.fromDownloadResult(result); - if (descriptor == null) { - return _DecryptOutcome(filePath); - } - onStart?.call(descriptor.normalizedStrategy); - - if (storageMode == 'saf' && isContentUri(filePath)) { - if (downloadTreeUri == null || downloadTreeUri.isEmpty) { - return const _DecryptOutcome( - null, - failStage: DownloadQueueNotifier._decryptStageSafAccess, - ); - } - String? failStage; - var opStarted = false; - String? producedFileName; - final newUri = await _replaceSafFileVia( - uri: filePath, - treeUri: downloadTreeUri, - relativeDir: safRelativeDir, - op: (tempPath, addCleanup) async { - opStarted = true; - final rawDecryptedTempPath = - await FFmpegService.decryptWithDescriptor( - inputPath: tempPath, - descriptor: descriptor, - deleteOriginal: false, - ); - if (rawDecryptedTempPath == null) { - failStage = DownloadQueueNotifier._decryptStageDecrypt; - return null; - } - addCleanup(rawDecryptedTempPath); - final decryptedTempPath = await _normalizeDecryptedIsoBmffAudioPath( - rawDecryptedTempPath, - result, - ); - if (decryptedTempPath != rawDecryptedTempPath) { - addCleanup(decryptedTempPath); - } - if (repairAc4 && _isMp4Container(decryptedTempPath)) { - try { - await PlatformBridge.ensureAC4Config(decryptedTempPath, tempPath); - } catch (e) { - _log.w('AC-4 container repair skipped: $e'); - } - } - final dotIndex = decryptedTempPath.lastIndexOf('.'); - final decryptedExt = dotIndex >= 0 - ? decryptedTempPath.substring(dotIndex).toLowerCase() - : extFallback; - const allowedExt = {'.flac', '.m4a', '.mp4', '.mp3', '.opus'}; - final finalExt = allowedExt.contains(decryptedExt) - ? decryptedExt - : extFallback; - final newFileName = '$baseName$finalExt'; - producedFileName = newFileName; - return (decryptedTempPath, newFileName); - }, - ); - if (newUri == null) { - return _DecryptOutcome( - null, - failStage: - failStage ?? - (opStarted - ? DownloadQueueNotifier._decryptStageSafWrite - : DownloadQueueNotifier._decryptStageSafAccess), - ); - } - return _DecryptOutcome(newUri, newFileName: producedFileName); - } - - final rawDecryptedPath = await FFmpegService.decryptWithDescriptor( - inputPath: filePath, - descriptor: descriptor, - deleteOriginal: true, - // Repair while the encrypted source still exists, then publish under - // its original name instead of keeping a conversion suffix. - prepareOutput: repairAc4 - ? (path) async { - if (!_isMp4Container(path)) return; - try { - await PlatformBridge.ensureAC4Config(path, filePath); - } catch (e) { - _log.w('AC-4 container repair skipped: $e'); - } - } - : null, - ); - final decryptedPath = rawDecryptedPath == null - ? null - : await _normalizeDecryptedIsoBmffAudioPath(rawDecryptedPath, result); - return _DecryptOutcome( - decryptedPath, - failStage: decryptedPath == null - ? DownloadQueueNotifier._decryptStageDecrypt - : null, - ); - } - Future _finalizeNativeWorkerContainerConversion({ required _NativeWorkerRequestContext context, - required Map result, + required DownloadResult result, required AppSettings settings, required Track track, required String filePath, - }) async { - if (context.outputExt != '.flac') { - return filePath; - } - final resultAudioFormat = normalizeAudioFormatValue( - result['audio_codec']?.toString() ?? - result['actual_audio_codec']?.toString(), - ); - final requiresContainerConversion = - result['requires_container_conversion'] == true || - result['requiresContainerConversion'] == true || - _shouldRequestContainerConversion( + }) => + DownloadContainerFinalizer( + probeCodec: FFmpegService.probePrimaryAudioCodec, + isNativeFlac: FFmpegService.isNativeFlacFile, + ensureFlacExtension: FFmpegService.ensureNativeFlacExtension, + convertToFlac: FFmpegService.convertM4aToFlac, + replaceSafFile: _replaceSafFileVia, + debug: _log.d, + ).finalize( + result: result, + filePath: filePath, + requestedExtension: context.outputExt, + forceConversion: _shouldRequestContainerConversion( context.item.service, context.outputExt, - ); - // M4A/MP4 identifies a container, not necessarily a lossy codec. When an - // extension explicitly requests conversion, probe the stream below rather - // than treating the container label as proof that its audio is lossy. - if (!requiresContainerConversion && isLossyAudioFormat(resultAudioFormat)) { - _log.d( - 'Native-worker output is $resultAudioFormat; preserving native container.', - ); - return filePath; - } - 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, - )?.normalizedOutputExtension; - if (!requiresContainerConversion && - requestedDecryptionExt != null && - requestedDecryptionExt != '.flac') { - _log.d( - 'Native-worker decrypted output requested $requestedDecryptionExt; preserving native container.', - ); - return filePath; - } - final looksLikeM4a = - lowerPath.endsWith('.m4a') || - lowerPath.endsWith('.mp4') || - resultOutputExt == '.m4a' || - resultOutputExt == '.mp4' || - (resultFileName != null && - (resultFileName.endsWith('.m4a') || - resultFileName.endsWith('.mp4'))); - if (!requiresContainerConversion && - !looksLikeM4a && - !isContentUri(filePath)) { - return filePath; - } - - Future embedFlacMetadata(String flacPath) async { - if (!settings.embedMetadata) return; - await _embedMetadataToFile( - flacPath, - track, - format: 'flac', - genre: result['genre'] as String?, - label: result['label'] as String?, - copyright: result['copyright'] as String?, - comment: result['comment'] as String?, - lyricsLrc: result['lyrics_lrc'] as String?, - downloadService: context.item.service, - writeExternalLrc: context.storageMode != 'saf', - ); - } - - void markFinalOutputAsFlac() { - result['audio_codec'] = 'flac'; - result['format'] = 'flac'; - result['actual_extension'] = '.flac'; - result['output_extension'] = '.flac'; - } - - if (context.storageMode == 'saf' && isContentUri(filePath)) { - final treeUri = context.downloadTreeUri; - if (treeUri == null || treeUri.isEmpty) { - return null; - } - var preserve = false; - final rawFileName = - (result['file_name'] as String?) ?? context.safFileName ?? 'track'; - final newFileName = - '${rawFileName.replaceFirst(RegExp(r'\.[^.]+$'), '')}.flac'; - final newUri = await _replaceSafFileVia( - uri: filePath, - treeUri: treeUri, + ), + useSaf: context.storageMode == 'saf', + treeUri: context.downloadTreeUri, relativeDir: context.safRelativeDir ?? '', - op: (tempPath, addCleanup) async { - final codec = await FFmpegService.probePrimaryAudioCodec(tempPath); - final isAlreadyNativeFlac = - codec == 'flac' && await FFmpegService.isNativeFlacFile(tempPath); - final shouldAttemptConversion = - FFmpegService.isLosslessAudioCodec(codec) || - (requiresContainerConversion && isInconclusiveAudioCodec(codec)); - if (!shouldAttemptConversion) { - _log.d( - 'Preserving native container; audio codec is ${codec ?? 'unknown'}, ' - 'no FLAC container conversion needed.', - ); - preserve = true; - return null; - } - if (isAlreadyNativeFlac) { - _log.d( - 'Native FLAC payload detected in temporary container; publishing ' - 'as FLAC and embedding metadata.', - ); - await embedFlacMetadata(tempPath); - return (tempPath, newFileName); - } - final flacPath = await FFmpegService.convertM4aToFlac(tempPath); - if (flacPath == null) { - return null; - } - addCleanup(flacPath); - await embedFlacMetadata(flacPath); - return (flacPath, newFileName); - }, + fallbackFileName: context.safFileName, + embedMetadata: settings.embedMetadata + ? (path) async { + await _embedMetadataToFile( + path, + track, + format: 'flac', + genre: result['genre'] as String?, + label: result['label'] as String?, + copyright: result['copyright'] as String?, + comment: result['comment'] as String?, + lyricsLrc: result['lyrics_lrc'] as String?, + downloadService: context.item.service, + writeExternalLrc: context.storageMode != 'saf', + ); + } + : null, ); - if (preserve) { - return filePath; - } - if (newUri == null) { - return null; - } - result['file_name'] = newFileName; - markFinalOutputAsFlac(); - return newUri; - } - - final codec = await FFmpegService.probePrimaryAudioCodec(filePath); - final isAlreadyNativeFlac = - codec == 'flac' && await FFmpegService.isNativeFlacFile(filePath); - final shouldAttemptConversion = - FFmpegService.isLosslessAudioCodec(codec) || - (requiresContainerConversion && isInconclusiveAudioCodec(codec)); - if (!shouldAttemptConversion) { - _log.d( - 'Preserving native container; audio codec is ${codec ?? 'unknown'}, ' - 'no FLAC container conversion needed.', - ); - return filePath; - } - if (isAlreadyNativeFlac) { - final flacPath = await FFmpegService.ensureNativeFlacExtension(filePath); - await embedFlacMetadata(flacPath); - markFinalOutputAsFlac(); - return flacPath; - } - final flacPath = await FFmpegService.convertM4aToFlac(filePath); - if (flacPath == null) { - return null; - } - await embedFlacMetadata(flacPath); - markFinalOutputAsFlac(); - return flacPath; - } /// Shared external-LRC finalize used by both the inline single-item /// pipeline (SAF only; the local-file case is already handled during diff --git a/lib/providers/download_queue_provider_hires_check.dart b/lib/providers/download_queue_provider_hires_check.dart index be594c02..13691ead 100644 --- a/lib/providers/download_queue_provider_hires_check.dart +++ b/lib/providers/download_queue_provider_hires_check.dart @@ -40,7 +40,6 @@ class _FakeHiResRunState { actualQuality = run.actualQuality, resultOutputExt = run.resultOutputExt, shouldPreserveNativeM4a = run.shouldPreserveNativeM4a, - decryptionDescriptor = run.decryptionDescriptor, probedFinalMetadata = run.probedFinalMetadata, externalLrcWritten = run.externalLrcWritten; @@ -59,12 +58,11 @@ class _FakeHiResRunState { final String actualQuality; final String? resultOutputExt; final bool shouldPreserveNativeM4a; - final DownloadDecryptionDescriptor? decryptionDescriptor; final Map? probedFinalMetadata; final bool externalLrcWritten; void restore(_DownloadRun run) { - run.result = result; + run.result = DownloadResult.fromMap(result); run.quality = quality; run.safOutputExt = safOutputExt; run.safFileName = safFileName; @@ -79,7 +77,6 @@ class _FakeHiResRunState { run.actualQuality = actualQuality; run.resultOutputExt = resultOutputExt; run.shouldPreserveNativeM4a = shouldPreserveNativeM4a; - run.decryptionDescriptor = decryptionDescriptor; run.probedFinalMetadata = probedFinalMetadata; run.externalLrcWritten = externalLrcWritten; } @@ -152,7 +149,7 @@ extension _DownloadRunFakeHiRes on _DownloadRun { ) async { fakeHiResOriginalPath = backup; trackToDownload = requestTrack; - result = {}; + result = DownloadResult.fromMap({}); filePath = null; probedFinalMetadata = null; externalLrcWritten = false; @@ -166,9 +163,9 @@ extension _DownloadRunFakeHiRes on _DownloadRun { } aborted = await _shouldAbort('during fake Hi-Res re-download'); if (aborted) return false; - final replacementPath = result['file_path'] as String?; - if (result['success'] != true || - result['already_exists'] == true || + final replacementPath = result.filePath; + if (!result.success || + result.alreadyExists || normalizeOptionalString(replacementPath) == null || isContentUri(replacementPath!)) { return false; diff --git a/lib/providers/download_queue_provider_native_worker.dart b/lib/providers/download_queue_provider_native_worker.dart index 7ac025fc..09c06e1a 100644 --- a/lib/providers/download_queue_provider_native_worker.dart +++ b/lib/providers/download_queue_provider_native_worker.dart @@ -1247,12 +1247,13 @@ extension _DownloadQueueNativeWorker on DownloadQueueNotifier { Future _completeAndroidNativeWorkerItem( _NativeWorkerRequestContext context, - Map result, + Map rawResult, AppSettings settings, ) async { + final result = DownloadResult.fromMap(rawResult); context = context.withResolvedFolder(result); final item = context.item; - var filePath = result['file_path'] as String?; + var filePath = result.filePath; if (filePath == null || filePath.isEmpty) { updateItemStatus( item.id, @@ -1264,7 +1265,7 @@ extension _DownloadQueueNativeWorker on DownloadQueueNotifier { return; } - if (result['native_finalized'] == true) { + if (result.nativeFinalized) { final nativeFinalizedFilePath = filePath; await _saveDownloadedMotionArtwork(ref, item, item.track, result); await persistBeforePublishingDownloadCompletion( @@ -1300,16 +1301,16 @@ extension _DownloadQueueNativeWorker on DownloadQueueNotifier { } final rawDecryptFileName = - (result['file_name'] as String?) ?? context.safFileName ?? 'track'; - final decryptOutcome = await _finalizeDecryption( + result.fileName ?? context.safFileName ?? 'track'; + final decryptOutcome = await _fileFinalizer.decrypt( result: result, filePath: filePath, - storageMode: context.storageMode, - downloadTreeUri: context.downloadTreeUri, - safRelativeDir: context.safRelativeDir ?? '', + useSaf: context.storageMode == 'saf', + treeUri: context.downloadTreeUri, + relativeDir: context.safRelativeDir ?? '', baseName: rawDecryptFileName.replaceFirst(RegExp(r'\.[^.]+$'), ''), - extFallback: context.outputExt, - repairAc4: false, + extensionFallback: context.outputExt, + repairContainer: false, onStart: (strategy) => _log.i( 'Native-worker encrypted stream detected, decrypting via $strategy...', ), @@ -1325,13 +1326,13 @@ extension _DownloadQueueNativeWorker on DownloadQueueNotifier { return; } filePath = decryptOutcome.path!; - if (decryptOutcome.newFileName != null) { - result['file_name'] = decryptOutcome.newFileName; + if (decryptOutcome.fileName != null) { + result.fileName = decryptOutcome.fileName; } var actualQuality = context.quality; - var actualBitDepth = result['actual_bit_depth'] as int?; - var actualSampleRate = result['actual_sample_rate'] as int?; + var actualBitDepth = result.actualBitDepth; + var actualSampleRate = result.actualSampleRate; var actualFormat = normalizeAudioFormatValue( result['audio_codec']?.toString() ?? result['format']?.toString(), @@ -1392,7 +1393,7 @@ extension _DownloadQueueNativeWorker on DownloadQueueNotifier { final autoConvertOutcome = await _autoConvertDownloadedFile( itemId: item.id, filePath: filePath, - fileName: result['file_name'] as String? ?? context.safFileName, + fileName: result.fileName ?? context.safFileName, currentQuality: actualQuality, settings: settings, track: trackToDownload, @@ -1405,7 +1406,7 @@ extension _DownloadQueueNativeWorker on DownloadQueueNotifier { filePath = autoConvertOutcome.filePath; actualQuality = autoConvertOutcome.quality; if (autoConvertOutcome.fileName != null) { - result['file_name'] = autoConvertOutcome.fileName; + result.fileName = autoConvertOutcome.fileName; } if (autoConvertOutcome.converted) { actualBitDepth = null; diff --git a/lib/providers/download_queue_provider_paths.dart b/lib/providers/download_queue_provider_paths.dart index 90d254e7..581e0cbd 100644 --- a/lib/providers/download_queue_provider_paths.dart +++ b/lib/providers/download_queue_provider_paths.dart @@ -633,70 +633,6 @@ extension _DownloadQueuePaths on DownloadQueueNotifier { } } - 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; - } - } - - // Generic safety net: when neither an explicit extension field nor a - // recognizable path suffix is available (e.g. SAF content URIs that drop - // the suffix), fall back to the actual audio codec reported by the backend - // probe. This keeps any extension that returns a non-FLAC container (Opus, - // MP3, AAC) from being mislabeled as FLAC. - final codec = normalizeAudioFormatValue( - result['audio_codec']?.toString() ?? - result['actual_audio_codec']?.toString() ?? - result['format']?.toString(), - ); - switch (codec) { - case 'opus': - return '.opus'; - case 'mp3': - return '.mp3'; - case 'aac': - case 'alac': - case 'm4a': - return '.m4a'; - case 'flac': - return '.flac'; - } - return null; - } - Future _getSafMimeType(String uri) async { try { final stat = await PlatformBridge.safStat(uri); diff --git a/lib/providers/download_queue_provider_single_item.dart b/lib/providers/download_queue_provider_single_item.dart index 64378ed3..38c318b3 100644 --- a/lib/providers/download_queue_provider_single_item.dart +++ b/lib/providers/download_queue_provider_single_item.dart @@ -85,7 +85,7 @@ class _DownloadRun { bool pausedDuringThisRun = false; late AppSettings settings; - late bool metadataEmbeddingEnabled; + bool get metadataEmbeddingEnabled => settings.embedMetadata; late Track trackToDownload; String? resolvedAlbumArtist; late String quality; @@ -110,7 +110,7 @@ class _DownloadRun { String? label; String? copyright; - late Map result; + late DownloadResult result; // Success-path state shared between finalization stages. String? filePath; @@ -118,7 +118,6 @@ class _DownloadRun { String actualQuality = ''; String? resultOutputExt; bool shouldPreserveNativeM4a = false; - DownloadDecryptionDescriptor? decryptionDescriptor; /// Filled by the SAF embed op from the local temp so the final quality /// probe doesn't have to copy the published file back out of SAF. @@ -177,7 +176,6 @@ class _DownloadRun { try { settings = n.ref.read(settingsProvider); - metadataEmbeddingEnabled = settings.embedMetadata; trackToDownload = item.track; if (isNetworkDownload) { @@ -186,7 +184,9 @@ class _DownloadRun { trackToDownload = Track.fromJson( Map.from(prepared['track'] as Map), ); - result = Map.from(prepared['result'] as Map); + result = DownloadResult.fromMap( + Map.from(prepared['result'] as Map), + ); filePath = prepared['localPath'] as String; actualQuality = prepared['quality'] as String; externalLrcWritten = prepared['externalLrcWritten'] == true; @@ -235,10 +235,10 @@ class _DownloadRun { if (!await _downloadAndMaybeFallback()) return; _log.d( - 'Native download result: success=${result['success'] == true}, ' - 'service=${result['service'] ?? item.service}, ' + 'Native download result: success=${result.success}, ' + 'service=${result.service ?? item.service}, ' 'errorType=${result['error_type'] ?? 'none'}, ' - 'filePresent=${(result['file_path'] as String?)?.isNotEmpty == true}', + 'filePresent=${result.filePath?.isNotEmpty == true}', ); final extendedMetadata = await extendedMetadataFuture; @@ -248,9 +248,8 @@ class _DownloadRun { copyright = extendedMetadata.copyright; } - final resultFilePath = result['file_path'] as String?; - final resultFileToCleanup = - (resultFilePath != null && result['success'] == true) + final resultFilePath = result.filePath; + final resultFileToCleanup = (resultFilePath != null && result.success) ? resultFilePath : null; if (await _shouldAbort( @@ -260,11 +259,10 @@ class _DownloadRun { return; } - if (result['success'] == true) { - if (effectiveSafMode && result['saf_relative_dir'] is String) { - effectiveOutputDir = n._sanitizeSafRelativeDir( - result['saf_relative_dir'] as String, - ); + if (result.success) { + final resolvedDirectory = result.resolvedSafDirectory; + if (effectiveSafMode && resolvedDirectory != null) { + effectiveOutputDir = n._sanitizeSafRelativeDir(resolvedDirectory); _log.d('Resolved output dir: $effectiveOutputDir'); } if (!await _handleDownloadSuccess()) return; @@ -482,7 +480,7 @@ class _DownloadRun { return true; } - Future> _invokeDownload({ + Future _invokeDownload({ required bool useSaf, required String outputDir, }) async { @@ -531,10 +529,12 @@ class _DownloadRun { qualityVariantCollisionOnly: qualityVariantCollisionOnly, ); - return PlatformBridge.downloadByStrategy( - payload: payload, - useExtensions: shouldUseExtensions, - useFallback: shouldUseFallback, + return DownloadResult.fromMap( + await PlatformBridge.downloadByStrategy( + payload: payload, + useExtensions: shouldUseExtensions, + useFallback: shouldUseFallback, + ), ); } @@ -545,7 +545,7 @@ class _DownloadRun { ); if (!isNetworkDownload && - result['success'] != true && + !result.success && isStorageWriteFailure( errorType: result['error_type']?.toString(), errorMessage: (result['error'] ?? result['message'])?.toString(), @@ -559,12 +559,12 @@ class _DownloadRun { _log.w( 'SAF write failed; preserving the selected destination for reauthorization', ); - result = { + result = DownloadResult.fromMap({ ...result, 'success': false, 'error': safPermissionLostErrorMessage, 'error_type': 'permission', - }; + }); return true; } _log.w('Storage write failed, retrying with a writable app folder'); @@ -592,7 +592,7 @@ class _DownloadRun { outputDir: fallbackDir, ); result = fallbackResult; - if (fallbackResult['success'] == true) { + if (fallbackResult.success) { effectiveSafMode = false; effectiveOutputDir = fallbackDir; finalSafFileName = null; @@ -614,19 +614,19 @@ class _DownloadRun { stageWatch.reset(); } - filePath = result['file_path'] as String?; - final reportedFileName = result['file_name'] as String?; + filePath = result.filePath; + final reportedFileName = result.fileName; if (effectiveSafMode && reportedFileName != null && reportedFileName.isNotEmpty) { finalSafFileName = reportedFileName; } - wasExisting = result['already_exists'] == true; + wasExisting = result.alreadyExists; _log.i('Download completed (existing=$wasExisting)'); - final actualBitDepth = result['actual_bit_depth'] as int?; - final actualSampleRate = result['actual_sample_rate'] as int?; + final actualBitDepth = result.actualBitDepth; + final actualSampleRate = result.actualSampleRate; actualQuality = quality; if (actualBitDepth != null && actualBitDepth > 0) { @@ -640,19 +640,14 @@ class _DownloadRun { } final actualService = - ((result['service'] as String?)?.toLowerCase()) ?? - item.service.toLowerCase(); - resultOutputExt = n._downloadResultOutputExt(result, filePath: filePath); - final resultAudioFormat = normalizeAudioFormatValue( - result['audio_codec']?.toString() ?? - result['actual_audio_codec']?.toString(), - ); + result.service?.toLowerCase() ?? item.service.toLowerCase(); + resultOutputExt = result.outputExtension(path: filePath); + final resultAudioFormat = normalizeAudioFormatValue(result.audioCodec); final resultIsKnownLossyAudio = isLossyAudioFormat(resultAudioFormat) && !isInconclusiveAudioCodec(resultAudioFormat); final requiresContainerConversion = - result['requires_container_conversion'] == true || - result['requiresContainerConversion'] == true || + result.requiresContainerConversion || (!resultIsKnownLossyAudio && n._shouldRequestContainerConversion(actualService, safOutputExt)); final preferredOutputExt = n._extensionPreferredOutputExt(actualService); @@ -664,9 +659,6 @@ class _DownloadRun { preferredOutputExt == '.mp4' || n._extensionPreservesNativeOutputExt(actualService, '.m4a') || n._extensionPreservesNativeOutputExt(actualService, '.mp4')); - decryptionDescriptor = DownloadDecryptionDescriptor.fromDownloadResult( - result, - ); final requestTrack = trackToDownload; trackToDownload = buildTrackForMetadataEmbedding( trackToDownload, @@ -714,7 +706,7 @@ class _DownloadRun { final deferredSafPublish = effectiveSafMode && - result['saf_deferred_publish'] == true && + result.deferredSafPublish && filePath != null && !isContentUri(filePath!); if (!deferredSafPublish) { @@ -738,7 +730,7 @@ class _DownloadRun { await deleteFile(hookInput); } filePath = postProcessedPath; - result['file_path'] = postProcessedPath; + result.filePath = postProcessedPath; } if (await _shouldAbort( 'during post-processing', @@ -757,7 +749,7 @@ class _DownloadRun { final outcome = await n._autoConvertDownloadedFile( itemId: item.id, filePath: autoConvertInput, - fileName: finalSafFileName ?? result['file_name'] as String?, + fileName: finalSafFileName ?? result.fileName, currentQuality: actualQuality, settings: settings, track: trackToDownload, @@ -879,7 +871,7 @@ class _DownloadRun { Future _decryptIfNeeded() async { final path = filePath; - final descriptor = decryptionDescriptor; + final descriptor = result.decryption; if (wasExisting || descriptor == null || path == null) { return true; } @@ -889,24 +881,24 @@ class _DownloadRun { n.updateItemStatus(item.id, DownloadStatus.finalizing, progress: 0.9); final isSafSource = effectiveSafMode && isContentUri(path); - final decryptOutcome = await n._finalizeDecryption( + final decryptOutcome = await n._fileFinalizer.decrypt( result: result, filePath: path, - storageMode: effectiveSafMode ? 'saf' : 'app', - downloadTreeUri: settings.downloadTreeUri, - safRelativeDir: effectiveOutputDir, + useSaf: effectiveSafMode, + treeUri: settings.downloadTreeUri, + relativeDir: effectiveOutputDir, baseName: safBaseName ?? 'track', - extFallback: '.flac', - repairAc4: true, + extensionFallback: '.flac', + repairContainer: true, ); if (decryptOutcome.path == null) { final String errorMsg; - switch (decryptOutcome.failStage) { - case DownloadQueueNotifier._decryptStageSafAccess: + switch (decryptOutcome.failure) { + case DownloadDecryptionFailure.safAccess: _log.e('Failed to copy encrypted SAF file to temp for decrypt'); errorMsg = 'Failed to access encrypted SAF file'; break; - case DownloadQueueNotifier._decryptStageSafWrite: + case DownloadDecryptionFailure.safWrite: _log.e('Failed to write decrypted stream back to SAF'); errorMsg = 'Failed to write decrypted file to storage'; break; @@ -928,8 +920,8 @@ class _DownloadRun { return false; } filePath = decryptOutcome.path; - if (decryptOutcome.newFileName != null) { - finalSafFileName = decryptOutcome.newFileName; + if (decryptOutcome.fileName != null) { + finalSafFileName = decryptOutcome.fileName; } _log.i( isSafSource ? 'SAF decryption completed' : 'Local decryption completed', @@ -994,7 +986,7 @@ class _DownloadRun { } else if (metadataEmbeddingEnabled && !isContentUriPath && effectiveSafMode && - result['saf_deferred_publish'] == true && + result.deferredSafPublish && !isFlacFile && !isM4aFile && !wasExisting) { @@ -1012,10 +1004,7 @@ class _DownloadRun { } void _markFinalOutputAsFlac() { - result['audio_codec'] = 'flac'; - result['format'] = 'flac'; - result['actual_extension'] = '.flac'; - result['output_extension'] = '.flac'; + result.markFlacContainer(); resultOutputExt = '.flac'; } @@ -1039,8 +1028,7 @@ class _DownloadRun { fileName: finalName, localPath: localPath, fallbackExtension: - n._downloadResultOutputExt(result, filePath: localPath) ?? - safOutputExt, + result.outputExtension(path: localPath) ?? safOutputExt, ); final localExt = finalName.substring(finalName.lastIndexOf('.')); @@ -1206,8 +1194,10 @@ class _DownloadRun { final isAlreadyNativeFlac = codec == 'flac' && await FFmpegService.isNativeFlacFile(tempPath); final shouldAttemptConversion = - FFmpegService.isLosslessAudioCodec(codec) || - isInconclusiveAudioCodec(codec); + shouldAttemptLosslessContainerConversion( + forceConversion: true, + probedCodec: codec, + ); if (!shouldAttemptConversion) { _log.d( 'Preserving native container; audio codec is ${codec ?? 'unknown'}, ' @@ -1339,8 +1329,10 @@ class _DownloadRun { codec == 'flac' && await FFmpegService.isNativeFlacFile(currentFilePath); final shouldAttemptConversion = - FFmpegService.isLosslessAudioCodec(codec) || - isInconclusiveAudioCodec(codec); + shouldAttemptLosslessContainerConversion( + forceConversion: true, + probedCodec: codec, + ); if (!shouldAttemptConversion) { _log.d( 'Preserving native container; audio codec is ${codec ?? 'unknown'}, ' @@ -1831,13 +1823,10 @@ class _DownloadRun { return false; } - var errorMsg = result['error'] as String? ?? 'Download failed'; - final errorTypeStr = result['error_type'] as String? ?? 'unknown'; - final retryAfterSeconds = readPositiveInt(result['retry_after_seconds']); - if (retryAfterSeconds != null && retryAfterSeconds > 0) { - errorMsg = '$errorMsg retry-after: $retryAfterSeconds'; - } - if (errorTypeStr == 'cancelled') { + final failure = result.failure!; + final errorMsg = failure.retryMessage; + final errorTypeStr = failure.backendType; + if (failure.cancelled) { if (n._isPausePending(item.id)) { pausedDuringThisRun = true; n._requeueItemForPause(item.id); @@ -1849,7 +1838,7 @@ class _DownloadRun { return false; } - final backendErrorType = downloadErrorTypeFromBackend(errorTypeStr); + final backendErrorType = failure.type; final errorType = backendErrorType ?? n._downloadErrorTypeFromMessage(errorMsg); @@ -1857,7 +1846,7 @@ class _DownloadRun { await n._handleVerificationRequiredDownload( item, errorMsg, - result['service'] as String?, + failure.service, ); return false; } diff --git a/lib/services/download_container_finalizer.dart b/lib/services/download_container_finalizer.dart new file mode 100644 index 00000000..34bf716f --- /dev/null +++ b/lib/services/download_container_finalizer.dart @@ -0,0 +1,122 @@ +import 'package:spotiflac_android/models/download_result.dart'; +import 'package:spotiflac_android/services/download_saf_file_replacer.dart'; +import 'package:spotiflac_android/utils/audio_format_utils.dart'; + +/// Finalizes a requested lossless container using the measured audio stream. +/// Native FLAC bytes can be renamed; FLAC-in-MP4 still needs remuxing. +class DownloadContainerFinalizer { + const DownloadContainerFinalizer({ + required this.probeCodec, + required this.isNativeFlac, + required this.ensureFlacExtension, + required this.convertToFlac, + required this.replaceSafFile, + required this.debug, + }); + + final Future Function(String) probeCodec; + final Future Function(String) isNativeFlac; + final Future Function(String) ensureFlacExtension; + final Future Function(String) convertToFlac; + final ReplaceSafDownloadFile replaceSafFile; + final void Function(String) debug; + + Future finalize({ + required DownloadResult result, + required String filePath, + required String requestedExtension, + required bool forceConversion, + required bool useSaf, + String? treeUri, + String relativeDir = '', + String? fallbackFileName, + Future Function(String path)? embedMetadata, + }) async { + if (requestedExtension != '.flac') return filePath; + forceConversion = forceConversion || result.requiresContainerConversion; + final reportedCodec = normalizeAudioFormatValue(result.audioCodec); + if (!forceConversion && isLossyAudioFormat(reportedCodec)) { + debug( + 'Native-worker output is $reportedCodec; preserving native container.', + ); + return filePath; + } + final outputExtension = result.outputExtension(path: filePath); + final lowerPath = filePath.toLowerCase(); + final isSafSource = filePath.startsWith('content://'); + final pathLooksLikeM4a = + lowerPath.endsWith('.m4a') || + lowerPath.endsWith('.mp4') || + outputExtension == '.m4a' || + outputExtension == '.mp4'; + if (!forceConversion && !pathLooksLikeM4a && !isSafSource) return filePath; + final decryptionExtension = result.decryption?.normalizedOutputExtension; + if (!forceConversion && + decryptionExtension != null && + decryptionExtension != '.flac') { + debug( + 'Native-worker decrypted output requested $decryptionExtension; preserving native container.', + ); + return filePath; + } + bool shouldConvert(String? codec) { + if (shouldAttemptLosslessContainerConversion( + forceConversion: forceConversion, + probedCodec: codec, + )) { + return true; + } + debug( + 'Preserving native container; audio codec is ${codec ?? 'unknown'}, no FLAC container conversion needed.', + ); + return false; + } + + if (useSaf && isSafSource) { + if (treeUri == null || treeUri.isEmpty) return null; + var preserve = false; + final name = result.fileName ?? fallbackFileName ?? 'track'; + final flacName = '${name.replaceFirst(RegExp(r'\.[^.]+$'), '')}.flac'; + final uri = await replaceSafFile( + uri: filePath, + treeUri: treeUri, + relativeDir: relativeDir, + op: (tempPath, addCleanup) async { + final codec = await probeCodec(tempPath); + final nativeFlac = codec == 'flac' && await isNativeFlac(tempPath); + if (!shouldConvert(codec)) { + preserve = true; + return null; + } + if (nativeFlac) { + debug( + 'Native FLAC payload detected in temporary container; publishing as FLAC and embedding metadata.', + ); + await embedMetadata?.call(tempPath); + return (tempPath, flacName); + } + final output = await convertToFlac(tempPath); + if (output == null) return null; + addCleanup(output); + await embedMetadata?.call(output); + return (output, flacName); + }, + ); + if (preserve) return filePath; + if (uri == null) return null; + result.fileName = flacName; + result.markFlacContainer(); + return uri; + } + final codec = await probeCodec(filePath); + final nativeFlac = codec == 'flac' && await isNativeFlac(filePath); + if (!shouldConvert(codec)) return filePath; + final output = nativeFlac + ? await ensureFlacExtension(filePath) + : await convertToFlac(filePath); + if (output == null) return null; + await embedMetadata?.call(output); + result.markFlacContainer(); + return output; + } +} diff --git a/lib/services/download_file_finalizer.dart b/lib/services/download_file_finalizer.dart new file mode 100644 index 00000000..825896c0 --- /dev/null +++ b/lib/services/download_file_finalizer.dart @@ -0,0 +1,311 @@ +import 'dart:io'; + +import 'package:spotiflac_android/models/download_result.dart'; +import 'package:spotiflac_android/services/download_saf_file_replacer.dart'; +import 'package:spotiflac_android/services/ffmpeg_models.dart'; +import 'package:spotiflac_android/utils/audio_conversion_utils.dart'; +import 'package:spotiflac_android/utils/audio_format_utils.dart'; + +typedef DecryptDownloadFile = + Future Function({ + required String inputPath, + required DownloadDecryptionDescriptor descriptor, + required bool deleteOriginal, + Future Function(String)? prepareOutput, + }); + +typedef ConvertDownloadAudio = + Future Function({ + required String inputPath, + required String targetFormat, + required String bitrate, + required bool deleteOriginal, + }); + +enum DownloadDecryptionFailure { safAccess, decrypt, safWrite } + +class DownloadDecryptionOutcome { + const DownloadDecryptionOutcome(this.path, {this.fileName, this.failure}); + final String? path; + final String? fileName; + final DownloadDecryptionFailure? failure; +} + +class DownloadConversionOutcome { + const DownloadConversionOutcome({ + required this.filePath, + required this.fileName, + required this.quality, + this.converted = false, + }); + final String filePath; + final String? fileName; + final String quality; + final bool converted; +} + +/// Owns audio transforms and their optional-failure policy. Queue state, +/// settings, track enrichment and platform storage stay at the call boundary. +class DownloadFileFinalizer { + const DownloadFileFinalizer({ + required this.decryptFile, + required this.probeCodec, + required this.repairAc4, + required this.convertAudio, + required this.replaceSafFile, + required this.warn, + required this.info, + }); + + final DecryptDownloadFile decryptFile; + final Future Function(String) probeCodec; + final Future Function(String path, String encryptedPath) repairAc4; + final ConvertDownloadAudio convertAudio; + final ReplaceSafDownloadFile replaceSafFile; + final void Function(String) warn; + final void Function(String) info; + + Future _normalizeIsoBmffPath( + String path, + DownloadResult result, + ) async { + if (!_isMp4Container(path)) return path; + final codec = await probeCodec(path); + final extension = isoBmffAudioExtensionForCodec( + codec ?? result.reportedFormat, + ); + if (path.toLowerCase().endsWith(extension)) return path; + final targetPath = path.replaceFirst( + RegExp(r'\.(?:m4a|mp4)$', caseSensitive: false), + extension, + ); + if (targetPath == path) return path; + try { + if (await File(targetPath).exists()) { + warn( + 'Cannot normalize ISO-BMFF audio extension; target already exists: $targetPath', + ); + return path; + } + return (await File(path).rename(targetPath)).path; + } catch (e) { + warn('Failed to normalize ISO-BMFF audio extension for $path: $e'); + return path; + } + } + + Future _repair(String path, String encryptedPath) async { + if (!_isMp4Container(path)) return; + try { + await repairAc4(path, encryptedPath); + } catch (e) { + warn('AC-4 container repair skipped: $e'); + } + } + + Future decrypt({ + required DownloadResult result, + required String filePath, + required bool useSaf, + String? treeUri, + required String relativeDir, + required String baseName, + required String extensionFallback, + required bool repairContainer, + void Function(String strategy)? onStart, + }) async { + if (result.alreadyExists) return DownloadDecryptionOutcome(filePath); + final descriptor = result.decryption; + if (descriptor == null) { + return DownloadDecryptionOutcome(filePath); + } + onStart?.call(descriptor.normalizedStrategy); + if (useSaf && filePath.startsWith('content://')) { + if (treeUri == null || treeUri.isEmpty) { + return const DownloadDecryptionOutcome( + null, + failure: DownloadDecryptionFailure.safAccess, + ); + } + DownloadDecryptionFailure? failure; + var started = false; + String? fileName; + final uri = await replaceSafFile( + uri: filePath, + treeUri: treeUri, + relativeDir: relativeDir, + op: (tempPath, addCleanup) async { + started = true; + final decrypted = await decryptFile( + inputPath: tempPath, + descriptor: descriptor, + deleteOriginal: false, + ); + if (decrypted == null) { + failure = DownloadDecryptionFailure.decrypt; + return null; + } + addCleanup(decrypted); + final normalized = await _normalizeIsoBmffPath(decrypted, result); + if (normalized != decrypted) addCleanup(normalized); + if (repairContainer) await _repair(normalized, tempPath); + final dotIndex = normalized.lastIndexOf('.'); + final extension = dotIndex >= 0 + ? normalized.substring(dotIndex).toLowerCase() + : extensionFallback; + const allowed = {'.flac', '.m4a', '.mp4', '.mp3', '.opus'}; + fileName = + '$baseName${allowed.contains(extension) ? extension : extensionFallback}'; + return (normalized, fileName!); + }, + ); + return DownloadDecryptionOutcome( + uri, + fileName: uri == null ? null : fileName, + failure: uri == null + ? failure ?? + (started + ? DownloadDecryptionFailure.safWrite + : DownloadDecryptionFailure.safAccess) + : null, + ); + } + final decrypted = await decryptFile( + inputPath: filePath, + descriptor: descriptor, + deleteOriginal: true, + // Repair before the encrypted source is removed by atomic promotion. + prepareOutput: repairContainer ? (path) => _repair(path, filePath) : null, + ); + return DownloadDecryptionOutcome( + decrypted == null ? null : await _normalizeIsoBmffPath(decrypted, result), + failure: decrypted == null ? DownloadDecryptionFailure.decrypt : null, + ); + } + + Future autoConvert({ + required DownloadResult result, + required String filePath, + required String? fileName, + required String quality, + required bool enabled, + required String targetFormat, + required String targetBitrate, + required bool useSaf, + String? treeUri, + String relativeDir = '', + Future Function(String path, String format)? embedMetadata, + void Function()? onStart, + }) async { + final original = DownloadConversionOutcome( + filePath: filePath, + fileName: fileName, + quality: quality, + ); + if (!enabled) return original; + targetFormat = normalizeAutoConvertFormat(targetFormat); + targetBitrate = normalizeAutoConvertBitrate(targetBitrate); + final bitrateKbps = autoConvertBitrateKbps(targetBitrate); + if (autoConversionAlreadySatisfied( + filePath: filePath, + fileName: fileName, + targetFormat: targetFormat, + targetBitrate: targetBitrate, + quality: quality, + bitrateKbps: result.bitrateKbps, + )) { + return original; + } + final baseName = fileName?.trim().isNotEmpty == true + ? fileName!.trim() + : File(filePath).uri.pathSegments.last; + final convertedName = convertedOutputFileName( + originalFileName: baseName, + targetFormat: targetFormat, + ); + Future embed(String path) async { + try { + await embedMetadata?.call( + path, + metadataFormatForLossyFormat(targetFormat), + ); + } catch (e) { + warn('Automatic conversion metadata embed failed: $e'); + result['auto_conversion_metadata_warning'] = e.toString(); + } + } + + try { + onStart?.call(); + String? convertedPath; + String? publishedName = convertedName; + if (useSaf && filePath.startsWith('content://')) { + if (treeUri == null || treeUri.isEmpty) { + throw StateError('Missing SAF tree for automatic conversion'); + } + convertedPath = await replaceSafFile( + uri: filePath, + treeUri: treeUri, + relativeDir: relativeDir, + avoidOverwrite: convertedName.toLowerCase() != baseName.toLowerCase(), + onPublishedFileName: (value) => publishedName = value, + op: (tempPath, addCleanup) async { + final output = await convertAudio( + inputPath: tempPath, + targetFormat: targetFormat, + bitrate: targetBitrate, + deleteOriginal: false, + ); + if (output == null) return null; + addCleanup(output); + await embed(output); + return (output, convertedName); + }, + ); + } else { + convertedPath = await convertAudio( + inputPath: filePath, + targetFormat: targetFormat, + bitrate: targetBitrate, + deleteOriginal: true, + ); + if (convertedPath != null) { + // Deferred SAF outputs keep their logical name while in cache. + if (!useSaf) { + publishedName = File(convertedPath).uri.pathSegments.last; + } + await embed(convertedPath); + } + } + if (convertedPath == null || convertedPath.isEmpty) { + throw StateError('FFmpeg returned no automatic conversion output'); + } + result.markConvertedAudio( + path: convertedPath, + name: publishedName, + format: targetFormat, + bitrateKbps: bitrateKbps, + ); + info( + 'Automatic conversion completed: ${autoConvertFormatLabel(targetFormat)} @ $targetBitrate', + ); + return DownloadConversionOutcome( + filePath: convertedPath, + fileName: publishedName, + quality: + '${displayFormatForLossyFormat(targetFormat)} ${bitrateKbps}kbps', + converted: true, + ); + } catch (e) { + // FFmpeg removes the source only after atomic output promotion. + result['auto_conversion_warning'] = e.toString(); + warn('Automatic conversion failed; keeping downloaded source: $e'); + return original; + } + } +} + +bool _isMp4Container(String path) { + final lower = path.toLowerCase(); + return lower.endsWith('.m4a') || lower.endsWith('.mp4'); +} diff --git a/lib/services/download_saf_file_replacer.dart b/lib/services/download_saf_file_replacer.dart new file mode 100644 index 00000000..8c5fd775 --- /dev/null +++ b/lib/services/download_saf_file_replacer.dart @@ -0,0 +1,84 @@ +import 'dart:io'; + +typedef SafFileOperation = + Future<(String path, String fileName)?> Function( + String tempPath, + void Function(String path) addCleanup, + ); + +typedef ReplaceSafDownloadFile = + Future Function({ + required String uri, + required String treeUri, + required String relativeDir, + required SafFileOperation op, + bool avoidOverwrite, + void Function(String fileName)? onPublishedFileName, + }); + +typedef PublishSafDownloadFile = + Future<({String uri, String fileName})?> Function({ + required String treeUri, + required String relativeDir, + required String fileName, + required String srcPath, + required bool avoidOverwrite, + required String preservedSuffix, + String? collisionCleanFileName, + String? collisionVariantFileName, + }); + +/// Owns temporary files for a SAF transform. Publication must succeed before +/// removing the source URI, including transforms that change the filename. +class DownloadSafFileReplacer { + const DownloadSafFileReplacer({ + required this.copyToTemp, + required this.publish, + required this.deleteSource, + }); + + final Future Function(String uri) copyToTemp; + final PublishSafDownloadFile publish; + final Future Function(String uri) deleteSource; + + Future replace({ + required String uri, + required String treeUri, + required String relativeDir, + required SafFileOperation op, + bool avoidOverwrite = false, + String preservedSuffix = '', + String? collisionCleanFileName, + String? collisionVariantFileName, + void Function(String fileName)? onPublishedFileName, + }) async { + final tempPath = await copyToTemp(uri); + if (tempPath == null) return null; + final cleanup = {tempPath}; + try { + final produced = await op(tempPath, cleanup.add); + if (produced == null) return null; + cleanup.add(produced.$1); + final published = await publish( + treeUri: treeUri, + relativeDir: relativeDir, + fileName: produced.$2, + srcPath: produced.$1, + avoidOverwrite: avoidOverwrite, + preservedSuffix: preservedSuffix, + collisionCleanFileName: collisionCleanFileName, + collisionVariantFileName: collisionVariantFileName, + ); + if (published == null) return null; + onPublishedFileName?.call(published.fileName); + if (published.uri != uri) await deleteSource(uri); + return published.uri; + } finally { + for (final path in cleanup) { + try { + await File(path).delete(); + } catch (_) {} + } + } + } +} diff --git a/lib/services/ffmpeg_probe.dart b/lib/services/ffmpeg_probe.dart index 2fc68367..a0ad023f 100644 --- a/lib/services/ffmpeg_probe.dart +++ b/lib/services/ffmpeg_probe.dart @@ -2,6 +2,7 @@ import 'dart:io'; import 'package:ffmpeg_kit_flutter_new_audio/ffprobe_kit.dart'; import 'package:spotiflac_android/utils/logger.dart'; +import 'package:spotiflac_android/utils/audio_format_utils.dart' as audio; final _log = AppLogger('FFmpeg'); @@ -112,20 +113,8 @@ abstract final class FFmpegProbe { return const PrimaryAudioProperties(); } - static bool isLosslessAudioCodec(String? codec) { - final normalized = codec?.trim().toLowerCase().replaceAll('-', '_') ?? ''; - return normalized.startsWith('pcm_') || - const { - 'alac', - 'flac', - 'wavpack', - 'ape', - 'tta', - 'mlp', - 'truehd', - 'shorten', - }.contains(normalized); - } + static bool isLosslessAudioCodec(String? codec) => + audio.isLosslessAudioCodec(codec); static Future isNativeFlacFile(String path) async { try { diff --git a/lib/utils/audio_format_utils.dart b/lib/utils/audio_format_utils.dart index d3948946..5c6a5717 100644 --- a/lib/utils/audio_format_utils.dart +++ b/lib/utils/audio_format_utils.dart @@ -211,6 +211,30 @@ bool isInconclusiveAudioCodec(String? value) { return normalized == 'm4a' || normalized == 'mp4'; } +bool isLosslessAudioCodec(String? codec) { + final normalized = codec?.trim().toLowerCase().replaceAll('-', '_') ?? ''; + return normalized.startsWith('pcm_') || + const { + 'alac', + 'flac', + 'wavpack', + 'ape', + 'tta', + 'mlp', + 'truehd', + 'shorten', + }.contains(normalized); +} + +/// A generic container needs an explicit conversion request; known lossy +/// audio stays in its native container even when that request is present. +bool shouldAttemptLosslessContainerConversion({ + required bool forceConversion, + required String? probedCodec, +}) => + isLosslessAudioCodec(probedCodec) || + (forceConversion && isInconclusiveAudioCodec(probedCodec)); + /// Returns a provider-independent quality label suitable for a filename. /// /// Requested labels such as LOSSLESS and HI_RES are intentionally ignored: diff --git a/test/download_container_finalizer_test.dart b/test/download_container_finalizer_test.dart new file mode 100644 index 00000000..0265d51e --- /dev/null +++ b/test/download_container_finalizer_test.dart @@ -0,0 +1,241 @@ +import 'dart:io'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:spotiflac_android/models/download_result.dart'; +import 'package:spotiflac_android/services/download_container_finalizer.dart'; +import 'package:spotiflac_android/services/download_saf_file_replacer.dart'; +import 'package:spotiflac_android/services/ffmpeg_probe.dart'; + +void main() { + late Directory directory; + setUp(() async { + directory = await Directory.systemTemp.createTemp( + 'container-finalization-', + ); + }); + tearDown(() async => directory.delete(recursive: true)); + + DownloadContainerFinalizer finalizer({ + Future Function(String)? probe, + Future Function(String)? convert, + Future Function(String)? rename, + ReplaceSafDownloadFile? replaceSaf, + }) => DownloadContainerFinalizer( + probeCodec: probe ?? (_) async => throw StateError('Unexpected probe'), + isNativeFlac: FFmpegProbe.isNativeFlacFile, + ensureFlacExtension: + rename ?? (_) async => throw StateError('Unexpected rename'), + convertToFlac: + convert ?? (_) async => throw StateError('Unexpected conversion'), + replaceSafFile: + replaceSaf ?? + ({ + required uri, + required treeUri, + required relativeDir, + required op, + avoidOverwrite = false, + onPublishedFileName, + }) async => throw StateError('Unexpected SAF access'), + debug: (_) {}, + ); + + Future finalize( + DownloadContainerFinalizer stage, + DownloadResult result, { + String path = '/Song.m4a', + String extension = '.flac', + bool force = false, + bool saf = false, + Future Function(String)? embed, + }) => stage.finalize( + result: result, + filePath: path, + requestedExtension: extension, + forceConversion: force, + useSaf: saf, + treeUri: 'content://tree', + relativeDir: 'Artist/Album', + fallbackFileName: 'Song.m4a', + embedMetadata: embed, + ); + + test( + 'native output and explicit non-FLAC decryption bypass conversion', + () async { + for (final raw in >[ + {'audio_codec': 'aac'}, + {'decryption_key': 'abcd', 'output_extension': '.mp4'}, + ]) { + expect( + await finalize(finalizer(), DownloadResult.fromMap(raw)), + '/Song.m4a', + ); + } + expect( + await finalize( + finalizer(), + DownloadResult.fromMap({}), + extension: '.opus', + force: true, + ), + '/Song.m4a', + ); + }, + ); + + test( + 'actual stream decisions follow the shared native conversion cases', + () async { + final source = await File( + '${directory.path}/Song.m4a', + ).writeAsString('MP4'); + for (final row in File( + 'android/app/src/test/resources/finalization_container_cases.tsv', + ).readAsLinesSync()) { + if (row.startsWith('#') || row.trim().isEmpty) continue; + final fields = row.split('\t'); + var converted = false; + final result = DownloadResult.fromMap({'unknown': 'retained'}); + final stage = finalizer( + probe: (_) async => fields[1].isEmpty ? null : fields[1], + convert: (_) async { + converted = true; + return '${directory.path}/Song.flac'; + }, + ); + final outcome = await finalize( + stage, + result, + path: source.path, + force: fields[0] == 'true', + ); + expect(converted, fields[2] == 'true', reason: row); + expect( + outcome, + converted ? '${directory.path}/Song.flac' : source.path, + ); + expect(result.audioCodec, converted ? 'flac' : null); + expect(result['unknown'], 'retained'); + } + }, + ); + + test( + 'FLAC-in-MP4 is remuxed while actual native FLAC is renamed and tagged', + () async { + for (final native in [false, true]) { + final source = await File( + '${directory.path}/Song.m4a', + ).writeAsString(native ? 'fLaCaudio' : 'MP4audio'); + final events = []; + final stage = finalizer( + probe: (_) async => 'flac', + rename: (path) async { + events.add('rename'); + return (await File( + path, + ).rename('${directory.path}/Song.flac')).path; + }, + convert: (path) async { + events.add('remux'); + final output = await File( + '${directory.path}/Song.flac', + ).writeAsString('fLaCaudio'); + await File(path).delete(); + return output.path; + }, + ); + final result = DownloadResult.fromMap({'file_name': 'Song.m4a'}); + final path = await finalize( + stage, + result, + path: source.path, + embed: (path) async { + expect(await FFmpegProbe.isNativeFlacFile(path), isTrue); + events.add('tag'); + }, + ); + expect(path, '${directory.path}/Song.flac'); + expect(events, [native ? 'rename' : 'remux', 'tag']); + expect(result.outputExtension(), '.flac'); + await File(path!).delete(); + } + }, + ); + + test( + 'SAF publication adopts FLAC name only after tags and successful write', + () async { + for (final published in [false, true]) { + final temp = File('${directory.path}/temporary.m4a'); + var deleted = false; + final replacer = DownloadSafFileReplacer( + copyToTemp: (_) async => (await temp.writeAsString('fLaCaudio')).path, + publish: + ({ + required treeUri, + required relativeDir, + required fileName, + required srcPath, + required avoidOverwrite, + required preservedSuffix, + collisionCleanFileName, + collisionVariantFileName, + }) async { + expect(fileName, 'Song.flac'); + expect(relativeDir, 'Artist/Album'); + expect(await File(srcPath).readAsString(), 'fLaCtagged'); + return published + ? (uri: 'content://final', fileName: fileName) + : null; + }, + deleteSource: (_) async => deleted = true, + ); + final result = DownloadResult.fromMap({'file_name': 'Song.m4a'}); + final stage = finalizer( + probe: (_) async => 'flac', + replaceSaf: replacer.replace, + ); + final path = await finalize( + stage, + result, + path: 'content://original', + saf: true, + embed: (path) async => File(path).writeAsString('fLaCtagged'), + ); + expect(path, published ? 'content://final' : null); + expect(result.fileName, published ? 'Song.flac' : 'Song.m4a'); + expect(result.audioCodec, published ? 'flac' : null); + expect(deleted, published); + expect(await directory.list().length, 0); + } + }, + ); + + test( + 'failed conversion and required tag failure never mark output as FLAC', + () async { + final result = DownloadResult.fromMap({'audio_codec': 'alac'}); + final failed = finalizer( + probe: (_) async => 'alac', + convert: (_) async => null, + ); + expect(await finalize(failed, result), isNull); + expect(result.audioCodec, 'alac'); + final tagFailure = finalizer( + probe: (_) async => 'alac', + convert: (_) async => '/Song.flac', + ); + await expectLater( + finalize( + tagFailure, + result, + embed: (_) async => throw StateError('Tag failure'), + ), + throwsStateError, + ); + expect(result.audioCodec, 'alac'); + }, + ); +} diff --git a/test/download_container_policy_test.dart b/test/download_container_policy_test.dart new file mode 100644 index 00000000..8ef35c79 --- /dev/null +++ b/test/download_container_policy_test.dart @@ -0,0 +1,28 @@ +import 'dart:io'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:spotiflac_android/utils/audio_format_utils.dart'; + +void main() { + test('container conversion agrees with the native pipeline fixture', () { + final rows = File( + 'android/app/src/test/resources/finalization_container_cases.tsv', + ).readAsLinesSync(); + var cases = 0; + for (final row in rows) { + if (row.startsWith('#') || row.trim().isEmpty) continue; + final fields = row.split('\t'); + expect(fields, hasLength(3)); + expect( + shouldAttemptLosslessContainerConversion( + forceConversion: fields[0] == 'true', + probedCodec: fields[1].isEmpty ? null : fields[1], + ), + fields[2] == 'true', + reason: row, + ); + cases++; + } + expect(cases, greaterThan(0)); + }); +} diff --git a/test/download_file_finalizer_test.dart b/test/download_file_finalizer_test.dart new file mode 100644 index 00000000..fa055850 --- /dev/null +++ b/test/download_file_finalizer_test.dart @@ -0,0 +1,397 @@ +import 'dart:io'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:spotiflac_android/models/download_result.dart'; +import 'package:spotiflac_android/services/download_file_finalizer.dart'; +import 'package:spotiflac_android/services/download_saf_file_replacer.dart'; + +void main() { + late Directory directory; + late List warnings; + setUp(() async { + directory = await Directory.systemTemp.createTemp('download-finalization-'); + warnings = []; + }); + tearDown(() async => directory.delete(recursive: true)); + + DownloadFileFinalizer finalizer({ + DecryptDownloadFile? decrypt, + ConvertDownloadAudio? convert, + ReplaceSafDownloadFile? replaceSaf, + Future Function(String)? probe, + Future Function(String, String)? repair, + }) => DownloadFileFinalizer( + decryptFile: + decrypt ?? + ({ + required inputPath, + required descriptor, + required deleteOriginal, + prepareOutput, + }) async => throw StateError('Unexpected decrypt'), + convertAudio: + convert ?? + ({ + required inputPath, + required targetFormat, + required bitrate, + required deleteOriginal, + }) async => throw StateError('Unexpected conversion'), + replaceSafFile: + replaceSaf ?? + ({ + required uri, + required treeUri, + required relativeDir, + required op, + avoidOverwrite = false, + onPublishedFileName, + }) async => throw StateError('Unexpected SAF access'), + probeCodec: probe ?? (_) async => throw StateError('Unexpected probe'), + repairAc4: repair ?? (_, _) async => throw StateError('Unexpected repair'), + warn: warnings.add, + info: (_) {}, + ); + + DownloadResult encrypted({bool existing = false}) => DownloadResult.fromMap({ + 'already_exists': existing, + 'decryption_key': 'abcd', + 'extension_context': {'provider': 'example'}, + }); + + Future decrypt( + DownloadFileFinalizer stage, + DownloadResult result, + String path, { + bool saf = false, + String? treeUri = 'content://tree', + bool repair = false, + }) => stage.decrypt( + result: result, + filePath: path, + useSaf: saf, + treeUri: treeUri, + relativeDir: 'Artist/Album', + baseName: 'Song', + extensionFallback: '.flac', + repairContainer: repair, + ); + + test( + 'existing or unencrypted files never enter the transform stages', + () async { + for (final result in [ + encrypted(existing: true), + DownloadResult.fromMap({}), + ]) { + final outcome = await decrypt( + finalizer(), + result, + 'content://source', + saf: true, + ); + expect(outcome.path, 'content://source'); + expect(outcome.failure, isNull); + } + }, + ); + + test( + 'local AC-4 repair completes before source promotion and stays MP4', + () async { + final source = await File( + '${directory.path}/Song.mp4', + ).writeAsString('encrypted'); + final output = File('${directory.path}/work.mp4'); + final stage = finalizer( + decrypt: + ({ + required inputPath, + required descriptor, + required deleteOriginal, + prepareOutput, + }) async { + expect(deleteOriginal, isTrue); + await output.writeAsString('decrypted'); + await prepareOutput!(output.path); + await source.delete(); + return (await output.rename(source.path)).path; + }, + repair: (path, encryptedPath) async { + expect(await File(encryptedPath).readAsString(), 'encrypted'); + await File(path).writeAsString('repaired'); + }, + probe: (_) async => 'ac4', + ); + final outcome = await decrypt( + stage, + encrypted(), + source.path, + repair: true, + ); + expect(outcome.path, source.path); + expect(await source.readAsString(), 'repaired'); + expect(await directory.list().length, 1); + }, + ); + + test('normalizes audio-only MP4 without overwriting a sibling', () async { + final output = await File( + '${directory.path}/Song.mp4', + ).writeAsString('audio'); + final stage = finalizer( + decrypt: + ({ + required inputPath, + required descriptor, + required deleteOriginal, + prepareOutput, + }) async => output.path, + probe: (_) async => 'aac', + ); + final outcome = await decrypt(stage, encrypted(), output.path); + expect(outcome.path, '${directory.path}/Song.m4a'); + expect(await File(outcome.path!).readAsString(), 'audio'); + await output.writeAsString('second'); + final collision = await decrypt(stage, encrypted(), output.path); + expect(collision.path, output.path); + expect(await File(outcome.path!).readAsString(), 'audio'); + expect(warnings.single, contains('target already exists')); + }); + + test( + 'SAF access, decrypt and publish failures retain distinct outcomes', + () async { + for (final failure in DownloadDecryptionFailure.values) { + final temp = File('${directory.path}/encrypted.flac'); + final output = File('${directory.path}/decrypted.flac'); + final replacer = DownloadSafFileReplacer( + copyToTemp: (_) async => + failure == DownloadDecryptionFailure.safAccess + ? null + : (await temp.writeAsString('encrypted')).path, + publish: + ({ + required treeUri, + required relativeDir, + required fileName, + required srcPath, + required avoidOverwrite, + required preservedSuffix, + collisionCleanFileName, + collisionVariantFileName, + }) async => null, + deleteSource: (_) async => fail('Source was deleted after failure'), + ); + final stage = finalizer( + replaceSaf: replacer.replace, + decrypt: + ({ + required inputPath, + required descriptor, + required deleteOriginal, + prepareOutput, + }) async { + expect(deleteOriginal, isFalse); + return failure == DownloadDecryptionFailure.decrypt + ? null + : (await output.writeAsString('decrypted')).path; + }, + ); + final outcome = await decrypt( + stage, + encrypted(), + 'content://source', + saf: true, + ); + expect(outcome.path, isNull); + expect(outcome.failure, failure); + expect(await directory.list().length, 0); + } + final missingTree = await decrypt( + finalizer(), + encrypted(), + 'content://source', + saf: true, + treeUri: '', + ); + expect(missingTree.failure, DownloadDecryptionFailure.safAccess); + }, + ); + + Future convert( + DownloadFileFinalizer stage, + DownloadResult result, { + String path = '/source.flac', + String? name, + bool saf = false, + bool enabled = true, + Future Function(String, String)? embed, + void Function()? start, + }) => stage.autoConvert( + result: result, + filePath: path, + fileName: name, + quality: '24-bit/96kHz', + enabled: enabled, + targetFormat: 'opus', + targetBitrate: '192k', + useSaf: saf, + treeUri: 'content://tree', + embedMetadata: embed, + onStart: start, + ); + + test( + 'disabled and already satisfied conversions leave the file alone', + () async { + final result = DownloadResult.fromMap({'bitrate': 192}); + for (final enabled in [false, true]) { + final outcome = await convert( + finalizer(), + result, + path: '/Song.opus', + enabled: enabled, + start: () => fail('Unexpected finalizing progress'), + ); + expect(outcome.converted, isFalse); + expect(outcome.filePath, '/Song.opus'); + } + }, + ); + + test( + 'optional conversion failure preserves source, quality and extension data', + () async { + final result = encrypted(); + final stage = finalizer( + convert: + ({ + required inputPath, + required targetFormat, + required bitrate, + required deleteOriginal, + }) async => null, + ); + final outcome = await convert(stage, result, name: 'Song.flac'); + expect(outcome.converted, isFalse); + expect(outcome.filePath, '/source.flac'); + expect(outcome.fileName, 'Song.flac'); + expect(outcome.quality, '24-bit/96kHz'); + expect(result['extension_context'], {'provider': 'example'}); + expect( + result['auto_conversion_warning'], + contains('no automatic conversion output'), + ); + expect(result.filePath, isNull); + }, + ); + + test( + 'deferred SAF conversion retains its logical filename despite tag failure', + () async { + final result = DownloadResult.fromMap({ + 'actual_bit_depth': 24, + 'actual_sample_rate': 96000, + 'unknown': 'kept', + }); + final stage = finalizer( + convert: + ({ + required inputPath, + required targetFormat, + required bitrate, + required deleteOriginal, + }) async { + expect(deleteOriginal, isTrue); + expect(targetFormat, 'opus'); + expect(bitrate, '192k'); + return '/cache/native_saf_work_123.opus'; + }, + ); + final outcome = await convert( + stage, + result, + path: '/cache/native_saf_work_123.flac', + name: 'Artist - Song.flac', + saf: true, + embed: (_, _) async => throw StateError('Tags failed'), + ); + expect(outcome.converted, isTrue); + expect(outcome.fileName, 'Artist - Song.opus'); + expect(outcome.quality, 'OPUS 192kbps'); + expect(result.fileName, outcome.fileName); + expect(result.actualBitDepth, isNull); + expect(result.actualSampleRate, isNull); + expect(result.audioCodec, 'opus'); + expect(result['unknown'], 'kept'); + expect( + result['auto_conversion_metadata_warning'], + contains('Tags failed'), + ); + }, + ); + + test( + 'SAF conversion adopts the collision-safe published name and cleans temps', + () async { + final temp = File('${directory.path}/cache.flac'); + final output = File('${directory.path}/cache.opus'); + final events = []; + final replacer = DownloadSafFileReplacer( + copyToTemp: (_) async => (await temp.writeAsString('source')).path, + publish: + ({ + required treeUri, + required relativeDir, + required fileName, + required srcPath, + required avoidOverwrite, + required preservedSuffix, + collisionCleanFileName, + collisionVariantFileName, + }) async { + expect(avoidOverwrite, isTrue); + expect(fileName, 'Song.opus'); + expect( + await File(srcPath).readAsString(), + 'converted and tagged', + ); + events.add('published'); + return (uri: 'content://converted', fileName: 'Song (2).opus'); + }, + deleteSource: (_) async => events.add('deleted'), + ); + final stage = finalizer( + replaceSaf: replacer.replace, + convert: + ({ + required inputPath, + required targetFormat, + required bitrate, + required deleteOriginal, + }) async { + expect(deleteOriginal, isFalse); + return (await output.writeAsString('converted')).path; + }, + ); + final result = encrypted(); + final outcome = await convert( + stage, + result, + path: 'content://source', + name: 'Song.flac', + saf: true, + embed: (path, format) async { + expect(format, 'opus'); + await File(path).writeAsString('converted and tagged'); + }, + ); + expect(outcome.filePath, 'content://converted'); + expect(outcome.fileName, 'Song (2).opus'); + expect(result.fileName, 'Song (2).opus'); + expect(events, ['published', 'deleted']); + expect(await directory.list().length, 0); + }, + ); +} diff --git a/test/download_result_test.dart b/test/download_result_test.dart new file mode 100644 index 00000000..9cd0c748 --- /dev/null +++ b/test/download_result_test.dart @@ -0,0 +1,108 @@ +import 'dart:convert'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:spotiflac_android/models/download_item.dart'; +import 'package:spotiflac_android/models/download_result.dart'; + +void main() { + test('typed updates retain unknown extension fields in the original map', () { + final original = { + 'success': true, + 'file_path': '/source.flac', + 'actual_bit_depth': 24, + 'actual_sample_rate': 96000, + 'extension_context': {'version': 3, 'opaque': 'kept'}, + }; + final result = DownloadResult.fromMap(original); + result.markConvertedAudio( + path: '/converted.opus', + name: 'Song.opus', + format: 'opus', + bitrateKbps: 192, + ); + expect(original['file_path'], '/converted.opus'); + expect(result.actualBitDepth, isNull); + expect(result.actualSampleRate, isNull); + expect(result.bitrateKbps, 192); + expect(jsonDecode(jsonEncode(result)), original); + expect(result['extension_context'], {'version': 3, 'opaque': 'kept'}); + expect(identical(DownloadResult.fromMap(result), result), isTrue); + }); + + test( + 'output extension honors explicit fields, logical names, then codec', + () { + final cases = <(Map, String)>[ + ({'actual_extension': ' MP4 ', 'output_extension': '.flac'}, '.mp4'), + ({'actual_container': 'invalid', 'container': 'ogg'}, '.ogg'), + ({'file_name': 'Song.OPUS', 'file_path': '/cache.flac'}, '.opus'), + ( + {'file_path': 'content://tree/123', 'actual_audio_codec': 'mp4a'}, + '.m4a', + ), + ({'file_path': 'content://tree/123', 'audio_codec': 'flac'}, '.flac'), + ]; + for (final (raw, expected) in cases) { + expect(DownloadResult.fromMap(raw).outputExtension(), expected); + } + expect( + DownloadResult.fromMap({ + 'file_path': '/source.flac', + }).outputExtension(path: '/final.mp3'), + '.mp3', + ); + expect(DownloadResult.fromMap({}).outputExtension(), isNull); + }, + ); + + test( + 'conversion and decryption accept current and legacy extension keys', + () { + for (final key in [ + 'requires_container_conversion', + 'requiresContainerConversion', + ]) { + expect( + DownloadResult.fromMap({key: true}).requiresContainerConversion, + isTrue, + ); + } + for (final raw in >[ + { + 'decryption': {'strategy': 'ffmpeg_mov_key', 'key': 'abcd'}, + 'output_extension': '.mp4', + }, + {'decryption_key': ' abcd ', 'output_extension': '.mp4'}, + ]) { + final descriptor = DownloadResult.fromMap(raw).decryption!; + expect(descriptor.normalizedStrategy, 'ffmpeg.mov_key'); + expect(descriptor.key, 'abcd'); + expect(descriptor.normalizedOutputExtension, '.mp4'); + } + }, + ); + + test('typed failures retain authentication and legacy error semantics', () { + final authentication = DownloadResult.fromMap({ + 'error': 'Provider challenge', + 'error_type': 'provider_reauth_required', + 'service': 'ext:example', + }).failure!; + expect(authentication.type, DownloadErrorType.unknown); + expect(authentication.retryMessage, 'Provider challenge'); + expect(authentication.service, 'ext:example'); + final rateLimit = DownloadResult.fromMap({ + 'error': 'Busy', + 'error_type': 'rate_limit', + 'retry_after_seconds': '30', + }).failure!; + expect(rateLimit.type, DownloadErrorType.rateLimit); + expect(rateLimit.retryMessage, 'Busy retry-after: 30'); + expect( + DownloadResult.fromMap({'error_type': 'cancelled'}).failure!.cancelled, + isTrue, + ); + expect(DownloadResult.fromMap({}).failure!.type, isNull); + expect(DownloadResult.fromMap({'success': true}).failure, isNull); + }); +} diff --git a/test/download_saf_file_replacer_test.dart b/test/download_saf_file_replacer_test.dart new file mode 100644 index 00000000..cec3a85a --- /dev/null +++ b/test/download_saf_file_replacer_test.dart @@ -0,0 +1,147 @@ +import 'dart:io'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:spotiflac_android/services/download_saf_file_replacer.dart'; + +void main() { + late Directory directory; + setUp(() async { + directory = await Directory.systemTemp.createTemp('saf-replacement-'); + }); + tearDown(() async => directory.delete(recursive: true)); + + test( + 'failed transforms and publications preserve the source and clean every temp', + () async { + for (final failure in [ + 'transform-null', + 'transform-throw', + 'publish-null', + 'publish-throw', + ]) { + final temp = File('${directory.path}/input.flac'); + final output = File('${directory.path}/output.flac'); + final replacer = DownloadSafFileReplacer( + copyToTemp: (_) async => (await temp.writeAsString('source')).path, + publish: + ({ + required treeUri, + required relativeDir, + required fileName, + required srcPath, + required avoidOverwrite, + required preservedSuffix, + collisionCleanFileName, + collisionVariantFileName, + }) async { + expect(failure, startsWith('publish')); + if (failure == 'publish-throw') { + throw StateError('Publishing failed'); + } + return null; + }, + deleteSource: (_) async => fail('Deleted source before publication'), + ); + final operation = replacer.replace( + uri: 'content://original', + treeUri: 'content://tree', + relativeDir: 'Album', + op: (path, addCleanup) async { + expect(await File(path).readAsString(), 'source'); + await output.writeAsString('transformed'); + addCleanup(output.path); + if (failure == 'transform-null') return null; + if (failure == 'transform-throw') { + throw StateError('Transform failed'); + } + return (output.path, 'Song.flac'); + }, + ); + if (failure.endsWith('throw')) { + await expectLater(operation, throwsStateError); + } else { + expect(await operation, isNull); + } + expect(await directory.list().length, 0, reason: failure); + } + }, + ); + + test( + 'unregistered output is cleaned and same-URI publication keeps the source', + () async { + final temp = File('${directory.path}/input.flac'); + final output = File('${directory.path}/output.flac'); + String? publishedName; + final replacer = DownloadSafFileReplacer( + copyToTemp: (_) async => (await temp.writeAsString('source')).path, + publish: + ({ + required treeUri, + required relativeDir, + required fileName, + required srcPath, + required avoidOverwrite, + required preservedSuffix, + collisionCleanFileName, + collisionVariantFileName, + }) async { + expect(treeUri, 'content://tree'); + expect(relativeDir, 'Artist/Album'); + expect(await File(srcPath).readAsString(), 'transformed'); + expect(collisionCleanFileName, 'Song.flac'); + expect(collisionVariantFileName, 'Song - 24bit-96kHz.flac'); + expect(preservedSuffix, '24bit-96kHz'); + return (uri: 'content://original', fileName: 'Song (2).flac'); + }, + deleteSource: (_) async => fail('Deleted the published URI'), + ); + expect( + await replacer.replace( + uri: 'content://original', + treeUri: 'content://tree', + relativeDir: 'Artist/Album', + preservedSuffix: '24bit-96kHz', + collisionCleanFileName: 'Song.flac', + collisionVariantFileName: 'Song - 24bit-96kHz.flac', + onPublishedFileName: (name) => publishedName = name, + op: (_, _) async => + ((await output.writeAsString('transformed')).path, 'Song.flac'), + ), + 'content://original', + ); + expect(publishedName, 'Song (2).flac'); + expect(await directory.list().length, 0); + }, + ); + + test( + 'inaccessible source never starts a transformation or publication', + () async { + final replacer = DownloadSafFileReplacer( + copyToTemp: (_) async => null, + publish: + ({ + required treeUri, + required relativeDir, + required fileName, + required srcPath, + required avoidOverwrite, + required preservedSuffix, + collisionCleanFileName, + collisionVariantFileName, + }) async => throw StateError('Unexpected publication'), + deleteSource: (_) async => fail('Unexpected source deletion'), + ); + expect( + await replacer.replace( + uri: 'content://original', + treeUri: 'content://tree', + relativeDir: '', + op: (_, _) async => throw StateError('Unexpected transform'), + ), + isNull, + ); + }, + ); +}