refactor(downloads): isolate typed results and audio finalization

This commit is contained in:
zarzet committed 2026-10-06 13:09:26 +07:00
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
1 # force_conversion codec expected
2 false false
3 true true
4 true null true
5 false m4a false
6 true m4a true
7 true MP4 true
8 false flac true
9 false ALAC true
10 false pcm_s24le true
11 false wavpack true
12 true aac false
13 true mp4a false
14 true mp3 false
15 true ogg false
16 true ac-4 false
17 true unknown false
+135
View File
@@ -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';
}
+4 -4
View File
@@ -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;
}
}
+311
View File
@@ -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 (_) {}
}
}
}
}
+3 -14
View File
@@ -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 {
+24
View File
@@ -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:
+241
View File
@@ -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');
},
);
}
+28
View File
@@ -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));
});
}
+397
View File
@@ -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);
},
);
}
+108
View File
@@ -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);
});
}
+147
View File
@@ -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,
);
},
);
}