mirror of
https://github.com/zarzet/SpotiFLAC-Mobile.git
synced 2026-10-20 06:28:27 +02:00
refactor(downloads): isolate typed results and audio finalization
This commit is contained in:
1 parent
7493c37698
commit
b8787168d1
19 files changed
+1875
-770
No files matched your search
@@ -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(
|
||||
|
||||
@@ -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
|
||||
|
@@ -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<String, dynamic> {
|
||||
factory DownloadResult.fromMap(Map<String, dynamic> values) =>
|
||||
values is DownloadResult ? values : DownloadResult._(values);
|
||||
|
||||
DownloadResult._(this._values);
|
||||
final Map<String, dynamic> _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<String> 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';
|
||||
}
|
||||
@@ -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<DownloadQueueState> {
|
||||
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
|
||||
|
||||
@@ -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<String> _normalizeDecryptedIsoBmffAudioPath(
|
||||
String path,
|
||||
Map<String, dynamic> 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<void> _saveDownloadedMotionArtwork(
|
||||
Ref ref,
|
||||
DownloadItem item,
|
||||
@@ -124,7 +85,7 @@ extension _DownloadQueueFinalization on DownloadQueueNotifier {
|
||||
}
|
||||
}
|
||||
|
||||
Future<_AutoConversionOutcome> _autoConvertDownloadedFile({
|
||||
Future<DownloadConversionOutcome> _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<void> 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<String?> _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>{};
|
||||
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<String, dynamic> 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 = <String>{'.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<String?> _finalizeNativeWorkerContainerConversion({
|
||||
required _NativeWorkerRequestContext context,
|
||||
required Map<String, dynamic> 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<void> 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
|
||||
|
||||
@@ -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<String, dynamic>? 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 = <String, dynamic>{};
|
||||
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;
|
||||
|
||||
@@ -1247,12 +1247,13 @@ extension _DownloadQueueNativeWorker on DownloadQueueNotifier {
|
||||
|
||||
Future<void> _completeAndroidNativeWorkerItem(
|
||||
_NativeWorkerRequestContext context,
|
||||
Map<String, dynamic> result,
|
||||
Map<String, dynamic> 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;
|
||||
|
||||
@@ -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<String, dynamic> 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 <String?>[
|
||||
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<String?> _getSafMimeType(String uri) async {
|
||||
try {
|
||||
final stat = await PlatformBridge.safStat(uri);
|
||||
|
||||
@@ -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<String, dynamic> 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<String, dynamic>.from(prepared['track'] as Map),
|
||||
);
|
||||
result = Map<String, dynamic>.from(prepared['result'] as Map);
|
||||
result = DownloadResult.fromMap(
|
||||
Map<String, dynamic>.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<Map<String, dynamic>> _invokeDownload({
|
||||
Future<DownloadResult> _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<bool> _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;
|
||||
}
|
||||
|
||||
@@ -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<String?> Function(String) probeCodec;
|
||||
final Future<bool> Function(String) isNativeFlac;
|
||||
final Future<String> Function(String) ensureFlacExtension;
|
||||
final Future<String?> Function(String) convertToFlac;
|
||||
final ReplaceSafDownloadFile replaceSafFile;
|
||||
final void Function(String) debug;
|
||||
|
||||
Future<String?> finalize({
|
||||
required DownloadResult result,
|
||||
required String filePath,
|
||||
required String requestedExtension,
|
||||
required bool forceConversion,
|
||||
required bool useSaf,
|
||||
String? treeUri,
|
||||
String relativeDir = '',
|
||||
String? fallbackFileName,
|
||||
Future<void> 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;
|
||||
}
|
||||
}
|
||||
@@ -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<String?> Function({
|
||||
required String inputPath,
|
||||
required DownloadDecryptionDescriptor descriptor,
|
||||
required bool deleteOriginal,
|
||||
Future<void> Function(String)? prepareOutput,
|
||||
});
|
||||
|
||||
typedef ConvertDownloadAudio =
|
||||
Future<String?> 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<String?> Function(String) probeCodec;
|
||||
final Future<void> Function(String path, String encryptedPath) repairAc4;
|
||||
final ConvertDownloadAudio convertAudio;
|
||||
final ReplaceSafDownloadFile replaceSafFile;
|
||||
final void Function(String) warn;
|
||||
final void Function(String) info;
|
||||
|
||||
Future<String> _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<void> _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<DownloadDecryptionOutcome> 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<DownloadConversionOutcome> 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<void> 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<void> 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');
|
||||
}
|
||||
@@ -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<String?> 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<String?> Function(String uri) copyToTemp;
|
||||
final PublishSafDownloadFile publish;
|
||||
final Future<void> Function(String uri) deleteSource;
|
||||
|
||||
Future<String?> 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 = <String>{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 (_) {}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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<bool> isNativeFlacFile(String path) async {
|
||||
try {
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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<String?> Function(String)? probe,
|
||||
Future<String?> Function(String)? convert,
|
||||
Future<String> 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<String?> finalize(
|
||||
DownloadContainerFinalizer stage,
|
||||
DownloadResult result, {
|
||||
String path = '/Song.m4a',
|
||||
String extension = '.flac',
|
||||
bool force = false,
|
||||
bool saf = false,
|
||||
Future<void> 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 <Map<String, dynamic>>[
|
||||
{'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 = <String>[];
|
||||
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');
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -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));
|
||||
});
|
||||
}
|
||||
@@ -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<String> 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<String?> Function(String)? probe,
|
||||
Future<void> 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<DownloadDecryptionOutcome> 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<DownloadConversionOutcome> convert(
|
||||
DownloadFileFinalizer stage,
|
||||
DownloadResult result, {
|
||||
String path = '/source.flac',
|
||||
String? name,
|
||||
bool saf = false,
|
||||
bool enabled = true,
|
||||
Future<void> 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 = <String>[];
|
||||
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);
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -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 = <String, dynamic>{
|
||||
'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, dynamic>, 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 <Map<String, dynamic>>[
|
||||
{
|
||||
'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);
|
||||
});
|
||||
}
|
||||
@@ -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,
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user