Files
SpotiFLAC-Mobile/lib/providers/download_queue_provider_single_item.dart
T

1736 lines
60 KiB
Dart

// ignore_for_file: invalid_use_of_protected_member, invalid_use_of_visible_for_testing_member
part of 'download_queue_provider.dart';
/// The per-item download pipeline: metadata enrichment, output resolution,
/// the native download call, decrypt/convert/embed finalization, and the
/// history hand-off. Queue scheduling stays in the main file.
extension _SingleItemDownload on DownloadQueueNotifier {
bool _isStorageWriteFailure(Map<String, dynamic> result) {
return isStorageWriteFailure(
errorType: result['error_type']?.toString(),
errorMessage: (result['error'] ?? result['message'])?.toString(),
);
}
Future<String?> _runPostProcessingHooks(String filePath, Track track) async {
try {
final settings = ref.read(settingsProvider);
final extensionState = ref.read(extensionProvider);
final resolvedAlbumArtist = _resolveAlbumArtistForMetadata(
track,
settings,
);
if (!settings.useExtensionProviders) return null;
final hasPostProcessing = extensionState.extensions.any(
(e) => e.enabled && e.hasPostProcessing,
);
if (!hasPostProcessing) return null;
_log.d('Running post-processing hooks on: $filePath');
final metadata = <String, dynamic>{
'title': track.name,
'artist': track.artistName,
'album': track.albumName,
'track_number': track.trackNumber ?? 0,
'disc_number': track.discNumber ?? 0,
'isrc': track.isrc ?? '',
'release_date': track.releaseDate ?? '',
'duration_ms': track.duration * 1000,
'cover_url': track.coverUrl ?? '',
};
if (resolvedAlbumArtist != null) {
metadata['album_artist'] = resolvedAlbumArtist;
}
final result = await PlatformBridge.runPostProcessingV2(
filePath,
metadata: metadata,
);
if (result['success'] == true) {
final hooksRun = result['hooks_run'] as int? ?? 0;
final newPath = result['file_path'] as String?;
_log.i('Post-processing completed: $hooksRun hook(s) executed');
if (newPath != null && newPath != filePath) {
_log.d('File path changed by post-processing: $newPath');
return newPath;
}
return filePath;
} else {
final error = result['error'] as String? ?? 'Unknown error';
_log.w('Post-processing failed: $error');
}
} catch (e) {
_log.w('Post-processing error: $e');
}
return null;
}
Future<void> _downloadSingleItem(DownloadItem item) =>
_DownloadRun(this, item)._run();
}
/// One download attempt for a single queue item.
///
/// What used to be the ~35 locals of a 1,700-line method live here as fields
/// so the pipeline reads as stage methods: enrich -> resolve output ->
/// resolve identifiers -> download (with SAF fallback) -> decrypt / convert /
/// embed -> publish to history. `n` is the owning notifier; queue state and
/// shared helpers stay there.
class _DownloadRun {
_DownloadRun(this.n, this.item);
final DownloadQueueNotifier n;
DownloadItem item;
bool pausedDuringThisRun = false;
late AppSettings settings;
late bool metadataEmbeddingEnabled;
late Track trackToDownload;
String? resolvedAlbumArtist;
late String quality;
late String safOutputExt;
late String effectiveFilenameFormat;
bool effectiveSafMode = false;
String effectiveOutputDir = '';
String? appOutputDir;
String? safFileName;
bool qualityVariantCollisionOnly = false;
String? safBaseName;
String? finalSafFileName;
late ExtensionState extensionState;
late bool selectedExtensionDownloadProvider;
late bool shouldSkipExtensionSongLinkPrelookup;
late bool useExtensions;
String? deezerTrackId;
String? genre;
String? label;
String? copyright;
late Map<String, dynamic> result;
// Success-path state shared between finalization stages.
String? filePath;
bool wasExisting = false;
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.
Map<String, dynamic>? probedFinalMetadata;
Future<void> _run() async {
final normalizedService = n._normalizeQueuedService(item.service);
if (normalizedService != item.service) {
item = item.copyWith(service: normalizedService);
n.state = n.state.copyWith(
items: [
for (final existing in n.state.items)
if (existing.id == item.id) item else existing,
],
currentDownload: n.state.currentDownload?.id == item.id
? item
: n.state.currentDownload,
);
n._saveQueueToStorage();
}
if (!n._hasActiveDownloadProvider(item.service)) {
n.updateItemStatus(
item.id,
DownloadStatus.failed,
error: 'Download provider is no longer available',
errorType: DownloadErrorType.notFound,
);
return;
}
_log.d('Processing: ${item.track.name} by ${item.track.artistName}');
_log.d('Cover URL: ${item.track.coverUrl}');
final currentItem = n._findItemById(item.id) ?? item;
if (n._isLocallyCancelled(item.id, item: currentItem)) {
_log.i('Download was cancelled before start, skipping');
return;
}
if (n._isPausePending(item.id)) {
pausedDuringThisRun = true;
n._requeueItemForPause(item.id);
_log.i('Download is pause-pending before start, skipping');
return;
}
n.state = n.state.copyWith(currentDownload: item);
n.updateItemStatus(item.id, DownloadStatus.downloading);
try {
settings = n.ref.read(settingsProvider);
metadataEmbeddingEnabled = settings.embedMetadata;
trackToDownload = item.track;
if (!await _enrichDeezerTrackIfNeeded()) return;
resolvedAlbumArtist = n._resolveAlbumArtistForMetadata(
trackToDownload,
settings,
);
await _resolveOutputTarget();
_resolveExtensionFlags();
if (!await _resolveTrackIdentifiers()) return;
// Genre/label/copyright are only consumed at embed time (the payload
// copy is just echoed back in the response), so this lookup overlaps
// with the download instead of delaying its start; it is awaited right
// after the download returns.
final extendedMetadataFuture = n
._loadExtendedMetadataForDeezerId(deezerTrackId)
.catchError((Object e) {
_log.w('Extended metadata lookup failed: $e');
return null;
});
if (await _shouldAbort('before native download start')) {
return;
}
if (!await _downloadAndMaybeFallback()) return;
_log.d('Result: $result');
final extendedMetadata = await extendedMetadataFuture;
if (extendedMetadata != null) {
genre = extendedMetadata.genre;
label = extendedMetadata.label;
copyright = extendedMetadata.copyright;
}
final resultFilePath = result['file_path'] as String?;
final resultFileToCleanup =
(resultFilePath != null && result['success'] == true)
? resultFilePath
: null;
if (await _shouldAbort(
'after result',
deleteFileOnAbort: resultFileToCleanup,
)) {
return;
}
if (result['success'] == true) {
if (!await _handleDownloadSuccess()) return;
} else {
if (!await _handleBackendFailure()) return;
}
n._downloadCount++;
if (n._downloadCount % DownloadQueueNotifier._cleanupInterval == 0) {
_log.d(
'Cleaning up idle connections (after ${n._downloadCount} downloads)...',
);
try {
await PlatformBridge.cleanupConnections();
} catch (e) {
_log.e('Connection cleanup failed: $e');
}
}
} catch (e, stackTrace) {
await _handleRunException(e, stackTrace);
} finally {
if (pausedDuringThisRun) {
n._pausePendingItemIds.remove(item.id);
}
}
}
/// Shared guard for every checkpoint in the pipeline: an item that vanished
/// from the queue or was flagged locally-cancelled aborts immediately; a
/// pause-pending item re-queues instead. [deleteFileOnAbort], when given,
/// is removed before returning (used once a file has already been written).
Future<bool> _shouldAbort(String stage, {String? deleteFileOnAbort}) async {
final current = n._findItemById(item.id);
if (current == null || n._isLocallyCancelled(item.id, item: current)) {
_log.i('Download was cancelled $stage, skipping');
if (deleteFileOnAbort != null) {
await deleteFile(deleteFileOnAbort);
_log.d('Deleted cancelled download file: $deleteFileOnAbort');
}
return true;
}
if (n._isPausePending(item.id)) {
pausedDuringThisRun = true;
if (deleteFileOnAbort != null) {
await deleteFile(deleteFileOnAbort);
_log.d('Deleted paused download file: $deleteFileOnAbort');
}
n._requeueItemForPause(item.id);
_log.i('Download pause requested $stage, re-queueing');
return true;
}
return false;
}
Future<bool> _enrichDeezerTrackIfNeeded() async {
final needsEnrichment =
trackToDownload.id.startsWith('deezer:') &&
(trackToDownload.isrc == null ||
trackToDownload.isrc!.isEmpty ||
trackToDownload.trackNumber == null ||
trackToDownload.trackNumber == 0 ||
trackToDownload.totalTracks == null ||
trackToDownload.totalTracks == 0 ||
(trackToDownload.composer == null ||
trackToDownload.composer!.isEmpty));
if (needsEnrichment) {
try {
_log.d(
'Enriching incomplete metadata for Deezer track: ${trackToDownload.name}',
);
_log.d(
'Current ISRC: ${trackToDownload.isrc}, TrackNumber: ${trackToDownload.trackNumber}',
);
final rawId = trackToDownload.id.split(':')[1];
_log.d('Fetching full metadata for Deezer ID: $rawId');
final fullData = await PlatformBridge.getProviderMetadata(
'deezer',
'track',
rawId,
);
_log.d('Got response keys: ${fullData.keys.toList()}');
if (fullData.containsKey('track')) {
final trackData = fullData['track'];
_log.d('Track data type: ${trackData.runtimeType}');
if (trackData is Map<String, dynamic>) {
final data = trackData;
_log.d('Track data keys: ${data.keys.toList()}');
_log.d('ISRC from API: ${data['isrc']}');
_log.d('album_type from API: ${data['album_type']}');
final enrichedTotalTracks = readPositiveInt(data['total_tracks']);
final enrichedTotalDiscs = readPositiveInt(data['total_discs']);
final enrichedComposer = normalizeOptionalString(
data['composer']?.toString(),
);
trackToDownload = Track(
id: (data['spotify_id'] as String?) ?? trackToDownload.id,
name: (data['name'] as String?) ?? trackToDownload.name,
artistName:
(data['artists'] as String?) ?? trackToDownload.artistName,
albumName:
(data['album_name'] as String?) ?? trackToDownload.albumName,
albumArtist: data['album_artist'] as String?,
artistId:
(data['artist_id'] ?? data['artistId'])?.toString() ??
trackToDownload.artistId,
albumId: data['album_id']?.toString() ?? trackToDownload.albumId,
coverUrl: data['images'] as String?,
duration:
((data['duration_ms'] as int?) ??
(trackToDownload.duration * 1000)) ~/
1000,
isrc: (data['isrc'] as String?) ?? trackToDownload.isrc,
trackNumber: data['track_number'] as int?,
discNumber: data['disc_number'] as int?,
totalDiscs: enrichedTotalDiscs ?? trackToDownload.totalDiscs,
releaseDate: data['release_date'] as String?,
deezerId: rawId,
availability: trackToDownload.availability,
albumType:
(data['album_type'] as String?) ?? trackToDownload.albumType,
totalTracks: enrichedTotalTracks ?? trackToDownload.totalTracks,
composer: enrichedComposer ?? trackToDownload.composer,
source: trackToDownload.source,
);
_log.d(
'Metadata enriched: Track ${trackToDownload.trackNumber}, Disc ${trackToDownload.discNumber}, ISRC ${trackToDownload.isrc}, AlbumType ${trackToDownload.albumType}',
);
} else {
_log.w('Unexpected track data type: ${trackData.runtimeType}');
}
} else {
_log.w('Response does not contain track key');
}
} catch (e, stack) {
_log.w('Failed to enrich metadata: $e');
_log.w('Stack trace: $stack');
}
if (await _shouldAbort('during metadata enrichment')) {
return false;
}
}
_log.d('Track coverUrl after enrichment: ${trackToDownload.coverUrl}');
return true;
}
Future<void> _resolveOutputTarget() async {
quality = item.qualityOverride ?? n.state.audioQuality;
if (quality == 'DEFAULT') quality = n.state.audioQuality;
final isSafMode = n._isSafMode(settings);
final relativeOutputDir = isSafMode
? await n._buildRelativeOutputDir(
trackToDownload,
settings.folderOrganization,
separateSingles: settings.separateSingles,
albumFolderStructure: settings.albumFolderStructure,
createPlaylistFolder: settings.createPlaylistFolder,
useAlbumArtistForFolders: settings.useAlbumArtistForFolders,
usePrimaryArtistOnly: settings.usePrimaryArtistOnly,
filterContributingArtistsInAlbumArtist:
settings.filterContributingArtistsInAlbumArtist,
playlistName: item.playlistName,
)
: '';
final initialOutputDir = isSafMode
? relativeOutputDir
: await n._buildOutputDir(
trackToDownload,
settings.folderOrganization,
separateSingles: settings.separateSingles,
albumFolderStructure: settings.albumFolderStructure,
createPlaylistFolder: settings.createPlaylistFolder,
useAlbumArtistForFolders: settings.useAlbumArtistForFolders,
usePrimaryArtistOnly: settings.usePrimaryArtistOnly,
filterContributingArtistsInAlbumArtist:
settings.filterContributingArtistsInAlbumArtist,
playlistName: item.playlistName,
);
effectiveOutputDir = isSafMode
? n._sanitizeSafRelativeDir(initialOutputDir)
: initialOutputDir;
effectiveSafMode = isSafMode;
safOutputExt = n._determineOutputExt(quality, item.service);
final baseFilenameFormat = n._shouldTreatAsSingleRelease(trackToDownload)
? n.state.singleFilenameFormat
: n.state.filenameFormat;
effectiveFilenameFormat = n._filenameFormatForItem(
item,
baseFilenameFormat,
);
qualityVariantCollisionOnly =
item.preserveQualityVariant &&
!_explicitQualityFilenameTokenPattern.hasMatch(baseFilenameFormat);
if (isSafMode) {
final builtSafFileName = await n._buildSafFileNameForItem(
item,
trackToDownload,
filenameFormat: effectiveFilenameFormat,
quality: quality,
outputExt: safOutputExt,
);
safFileName = builtSafFileName;
safBaseName = builtSafFileName.replaceFirst(RegExp(r'\.[^.]+$'), '');
}
finalSafFileName = safFileName;
}
void _resolveExtensionFlags() {
extensionState = n.ref.read(extensionProvider);
selectedExtensionDownloadProvider =
settings.useExtensionProviders &&
extensionState.extensions.any(
(e) =>
e.enabled &&
e.hasDownloadProvider &&
e.id.toLowerCase() == item.service.toLowerCase(),
);
final trackSource = (trackToDownload.source ?? '').trim().toLowerCase();
shouldSkipExtensionSongLinkPrelookup =
trackSource.isNotEmpty &&
extensionState.extensions.any(
(e) =>
e.enabled &&
e.hasMetadataProvider &&
e.id.toLowerCase() == trackSource,
);
final hasActiveExtensions = extensionState.extensions.any((e) => e.enabled);
useExtensions = settings.useExtensionProviders && hasActiveExtensions;
}
Future<bool> _resolveTrackIdentifiers() async {
deezerTrackId = await n._resolveDeezerIdFromKnownOrIsrc(
trackToDownload,
item.id,
lookupContext: 'ISRC',
);
if (await _shouldAbort('during Deezer ISRC lookup')) {
return false;
}
// For tidal:/qobuz: tracks without ISRC, resolve ISRC from provider
// API directly (faster than SongLink and avoids rate limits).
final providerResolved = await n._resolveDeezerIdViaProviderIfNeeded(
trackToDownload,
deezerTrackId,
item.id,
);
trackToDownload = providerResolved.track;
deezerTrackId = providerResolved.deezerTrackId;
if (await _shouldAbort('during provider ISRC resolution')) {
return false;
}
if (!selectedExtensionDownloadProvider &&
deezerTrackId == null &&
!shouldSkipExtensionSongLinkPrelookup &&
trackToDownload.id.isNotEmpty &&
!trackToDownload.id.startsWith('deezer:') &&
!trackToDownload.id.startsWith('extension:') &&
!trackToDownload.id.startsWith('tidal:') &&
!trackToDownload.id.startsWith('qobuz:')) {
final spotifyLookup = await n._resolveSpotifyTrackViaDeezer(
trackToDownload,
);
trackToDownload = spotifyLookup.track;
deezerTrackId ??= spotifyLookup.deezerTrackId;
if (await _shouldAbort('during SongLink availability lookup')) {
return false;
}
} else if (selectedExtensionDownloadProvider && deezerTrackId == null) {
_log.d(
'Skipping Flutter SongLink Deezer prelookup for extension provider: ${item.service}',
);
} else if (shouldSkipExtensionSongLinkPrelookup && deezerTrackId == null) {
_log.d(
'Skipping Flutter SongLink Deezer prelookup for extension-sourced track; backend metadata enrichment will resolve identifiers first',
);
}
return true;
}
Future<Map<String, dynamic>> _invokeDownload({
required bool useSaf,
required String outputDir,
}) async {
final outputExt = safOutputExt;
final shouldUseExtensions = useExtensions;
final shouldUseFallback = n.state.autoFallback;
if (shouldUseExtensions) {
_log.d('Using extension providers for download');
_log.d(
'Quality: $quality${item.qualityOverride != null ? ' (override)' : ''}',
);
} else if (shouldUseFallback) {
_log.d('Using auto-fallback mode');
_log.d(
'Quality: $quality${item.qualityOverride != null ? ' (override)' : ''}',
);
}
if (!useSaf) {
await n._ensureDirExists(outputDir, label: 'Output folder');
}
_log.d('Output dir: $outputDir');
final payload = n._buildDownloadRequestPayload(
track: trackToDownload,
item: item,
settings: settings,
extensionState: extensionState,
quality: quality,
filenameFormat: effectiveFilenameFormat,
outputDir: outputDir,
outputExt: outputExt,
useSaf: useSaf,
safFileName: safFileName,
deezerTrackId: deezerTrackId,
genre: genre,
label: label,
copyright: copyright,
qualityVariantCollisionOnly: qualityVariantCollisionOnly,
);
return PlatformBridge.downloadByStrategy(
payload: payload,
useExtensions: shouldUseExtensions,
useFallback: shouldUseFallback,
);
}
Future<bool> _downloadAndMaybeFallback() async {
result = await _invokeDownload(
useSaf: effectiveSafMode,
outputDir: effectiveOutputDir,
);
if (result['success'] != true && n._isStorageWriteFailure(result)) {
if (n._isLocallyCancelled(item.id)) {
_log.i('Download was cancelled before storage fallback, skipping');
return false;
}
_log.w('Storage write failed, retrying with a writable app folder');
try {
await n._activateAppFolderStorageFallback(
failedOutputDir: effectiveSafMode ? null : effectiveOutputDir,
);
final fallbackDir =
appOutputDir ??
await n._buildOutputDir(
trackToDownload,
settings.folderOrganization,
separateSingles: settings.separateSingles,
albumFolderStructure: settings.albumFolderStructure,
createPlaylistFolder: settings.createPlaylistFolder,
useAlbumArtistForFolders: settings.useAlbumArtistForFolders,
usePrimaryArtistOnly: settings.usePrimaryArtistOnly,
filterContributingArtistsInAlbumArtist:
settings.filterContributingArtistsInAlbumArtist,
playlistName: item.playlistName,
);
appOutputDir = fallbackDir;
final fallbackResult = await _invokeDownload(
useSaf: false,
outputDir: fallbackDir,
);
result = fallbackResult;
if (fallbackResult['success'] == true) {
effectiveSafMode = false;
effectiveOutputDir = fallbackDir;
finalSafFileName = null;
}
} catch (e) {
_log.e('App-folder storage fallback failed: $e');
}
}
return true;
}
Future<bool> _handleDownloadSuccess() async {
filePath = result['file_path'] as String?;
final reportedFileName = result['file_name'] as String?;
if (effectiveSafMode &&
reportedFileName != null &&
reportedFileName.isNotEmpty) {
finalSafFileName = reportedFileName;
}
wasExisting = result['already_exists'] == true;
if (wasExisting) {
_log.i('File already exists in library: $filePath');
}
_log.i('Download success, file: $filePath');
final actualBitDepth = result['actual_bit_depth'] as int?;
final actualSampleRate = result['actual_sample_rate'] as int?;
actualQuality = quality;
if (actualBitDepth != null && actualBitDepth > 0) {
final sampleRateKHz = actualSampleRate != null && actualSampleRate > 0
? (actualSampleRate / 1000).toStringAsFixed(
actualSampleRate % 1000 == 0 ? 0 : 1,
)
: '?';
actualQuality = '$actualBitDepth-bit/${sampleRateKHz}kHz';
_log.i('Actual quality: $actualQuality');
}
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(),
);
final resultIsLossyAudio = isLossyAudioFormat(resultAudioFormat);
final requiresContainerConversion =
result['requires_container_conversion'] == true ||
result['requiresContainerConversion'] == true ||
(!resultIsLossyAudio &&
n._shouldRequestContainerConversion(actualService, safOutputExt));
final preferredOutputExt = n._extensionPreferredOutputExt(actualService);
shouldPreserveNativeM4a =
!requiresContainerConversion &&
(resultOutputExt == '.m4a' ||
resultOutputExt == '.mp4' ||
preferredOutputExt == '.m4a' ||
preferredOutputExt == '.mp4' ||
n._extensionPreservesNativeOutputExt(actualService, '.m4a') ||
n._extensionPreservesNativeOutputExt(actualService, '.mp4'));
decryptionDescriptor = DownloadDecryptionDescriptor.fromDownloadResult(
result,
);
trackToDownload = n._buildTrackForMetadataEmbedding(
trackToDownload,
result,
resolvedAlbumArtist,
);
_log.d('Track coverUrl after download result: ${trackToDownload.coverUrl}');
if (!await _decryptIfNeeded()) {
return false;
}
await _applyFormatHandling(actualService);
if (await _shouldAbort(
'during finalization',
deleteFileOnAbort: filePath,
)) {
return false;
}
await _recoverSafUriIfNeeded();
final hookInput = filePath;
if (hookInput != null) {
final postProcessedPath = await n._runPostProcessingHooks(
hookInput,
trackToDownload,
);
if (postProcessedPath != null && postProcessedPath.isNotEmpty) {
filePath = postProcessedPath;
result['file_path'] = postProcessedPath;
}
}
final variantInput = filePath;
if (variantInput != null && item.preserveQualityVariant) {
final variantOutcome = await n._finalizeQualityVariantFilename(
item: item,
result: result,
filePath: variantInput,
storageMode: effectiveSafMode ? 'saf' : 'app',
downloadTreeUri: settings.downloadTreeUri,
safRelativeDir: effectiveOutputDir,
fileName: finalSafFileName ?? safFileName,
collisionOnly: qualityVariantCollisionOnly,
);
filePath = variantOutcome.filePath;
if (variantOutcome.fileName != null) {
finalSafFileName = variantOutcome.fileName;
}
if (variantOutcome.metadata != null) {
probedFinalMetadata = variantOutcome.metadata;
}
}
if (normalizeOptionalString(filePath) == null) {
throw StateError(
'Download backend reported success without a final file path',
);
}
final lrcTarget = filePath;
if (effectiveSafMode && lrcTarget != null && isContentUri(lrcTarget)) {
await n._saveExternalLrc(
result: result,
settings: settings,
extensionState: extensionState,
track: trackToDownload,
service: item.service,
filePath: lrcTarget,
storageMode: 'saf',
downloadTreeUri: settings.downloadTreeUri,
safRelativeDir: effectiveOutputDir,
resolveBaseName: () async {
final currentFinalName = finalSafFileName;
return currentFinalName != null
? currentFinalName.replaceFirst(RegExp(r'\.[^.]+$'), '')
: safBaseName ??
await PlatformBridge.sanitizeFilename(
'${trackToDownload.artistName} - ${trackToDownload.name}',
);
},
onFetchError: (e) =>
_log.w('Failed to fetch lyrics for external LRC: $e'),
);
}
final rgPath = filePath;
// Album ReplayGain: update the accumulator path to the final file
// location. For SAF downloads the metadata was embedded on a temp
// copy, so the stored path still points there. Replace it with the
// actual output path (SAF content URI or local path) so the later
// album-gain writer targets the correct file.
if (rgPath != null) {
n._updateAlbumRgFilePath(trackToDownload, rgPath);
}
// Album ReplayGain: check if all album tracks are now complete and,
// if so, compute and write album gain/peak to every track file.
try {
await n._checkAndWriteAlbumReplayGain(trackToDownload);
} catch (e) {
_log.w('Album ReplayGain check failed: $e');
}
await _persistCompletionAndNotify();
return true;
}
Future<bool> _decryptIfNeeded() async {
final path = filePath;
final descriptor = decryptionDescriptor;
if (wasExisting || descriptor == null || path == null) {
return true;
}
_log.i(
'Encrypted stream detected, decrypting via ${descriptor.normalizedStrategy}...',
);
n.updateItemStatus(item.id, DownloadStatus.finalizing, progress: 0.9);
final isSafSource = effectiveSafMode && isContentUri(path);
final decryptOutcome = await n._finalizeDecryption(
result: result,
filePath: path,
storageMode: effectiveSafMode ? 'saf' : 'app',
downloadTreeUri: settings.downloadTreeUri,
safRelativeDir: effectiveOutputDir,
baseName: safBaseName ?? 'track',
extFallback: '.flac',
repairAc4: true,
);
if (decryptOutcome.path == null) {
final String errorMsg;
switch (decryptOutcome.failStage) {
case DownloadQueueNotifier._decryptStageSafAccess:
_log.e('Failed to copy encrypted SAF file to temp for decrypt');
errorMsg = 'Failed to access encrypted SAF file';
break;
case DownloadQueueNotifier._decryptStageSafWrite:
_log.e('Failed to write decrypted stream back to SAF');
errorMsg = 'Failed to write decrypted file to storage';
break;
default:
_log.e(
isSafSource
? 'FFmpeg decrypt failed for SAF file'
: 'FFmpeg decrypt failed for local file',
);
errorMsg = 'Failed to decrypt encrypted stream';
break;
}
n.updateItemStatus(
item.id,
DownloadStatus.failed,
error: errorMsg,
errorType: DownloadErrorType.unknown,
);
return false;
}
filePath = decryptOutcome.path;
if (decryptOutcome.newFileName != null) {
finalSafFileName = decryptOutcome.newFileName;
}
_log.i(
isSafSource ? 'SAF decryption completed' : 'Local decryption completed',
);
return true;
}
Future<void> _applyFormatHandling(String actualService) async {
final path = filePath;
if (path == null) return;
final isContentUriPath = isContentUri(path);
final mimeType = isContentUriPath ? await n._getSafMimeType(path) : null;
final isM4aFile =
(path.endsWith('.m4a') ||
path.endsWith('.mp4') ||
resultOutputExt == '.m4a' ||
resultOutputExt == '.mp4' ||
(mimeType != null && mimeType.contains('mp4')));
final isFlacFile =
(path.endsWith('.flac') ||
resultOutputExt == '.flac' ||
(mimeType != null && mimeType.contains('flac')));
final shouldForceDashSafM4aHandling =
!wasExisting &&
isContentUriPath &&
effectiveSafMode &&
n._downloadProviderReplacesLegacyProvider(actualService, 'tidal') &&
path.endsWith('.flac') &&
(mimeType == null || mimeType.contains('flac'));
if (shouldForceDashSafM4aHandling) {
_log.w(
'SAF file is labeled FLAC but backend returned DASH/M4A stream; converting it back to FLAC.',
);
}
if (isM4aFile || shouldForceDashSafM4aHandling) {
if (isContentUriPath && effectiveSafMode) {
if (quality == 'HIGH') {
await _convertSafM4aToLossy(path);
} else if (shouldPreserveNativeM4a) {
await _preserveSafNativeM4a(path);
} else {
await _convertSafM4aToFlac(path);
}
} else {
if (quality == 'HIGH') {
await _convertLocalM4aToLossy(path);
} else if (shouldPreserveNativeM4a) {
await _preserveLocalNativeM4a(path);
} else {
await _convertLocalM4aToFlac(path);
}
}
} else if (metadataEmbeddingEnabled &&
isContentUriPath &&
effectiveSafMode &&
!isM4aFile &&
!wasExisting) {
await _embedSafNonM4a(path);
} else if (metadataEmbeddingEnabled &&
!isContentUriPath &&
!effectiveSafMode &&
isFlacFile &&
!wasExisting &&
decryptionDescriptor != null) {
await _embedLocalFlacAfterDecrypt(path);
}
}
Future<void> _convertSafM4aToLossy(String currentFilePath) async {
final tidalHighFormat = settings.tidalHighFormat;
_log.i(
'Lossy 320kbps quality (SAF), converting M4A to $tidalHighFormat...',
);
final format = lossyFormatForSetting(tidalHighFormat);
final displayFormat = displayFormatForLossyFormat(format);
final newExt = lossyExtensionForFormat(format);
final newFileName = '${safBaseName ?? 'track'}$newExt';
var opStarted = false;
var convertFailed = false;
try {
final newUri = await n._replaceSafFileVia(
uri: currentFilePath,
treeUri: settings.downloadTreeUri,
relativeDir: effectiveOutputDir,
op: (tempPath, addCleanup) async {
opStarted = true;
n.updateItemStatus(
item.id,
DownloadStatus.finalizing,
progress: 0.95,
);
final convertedPath = await FFmpegService.convertM4aToLossy(
tempPath,
format: format,
bitrate: tidalHighFormat,
deleteOriginal: false,
);
if (convertedPath == null) {
convertFailed = true;
return null;
}
addCleanup(convertedPath);
_log.i(
'Successfully converted M4A to $format (temp): $convertedPath',
);
_log.i('Embedding metadata to $format...');
n.updateItemStatus(
item.id,
DownloadStatus.finalizing,
progress: 0.99,
);
await _embedFinalMetadata(
convertedPath,
format: metadataFormatForLossyFormat(format),
rebuildTrack: false,
);
return (convertedPath, newFileName);
},
);
if (newUri != null) {
filePath = newUri;
finalSafFileName = newFileName;
final bitrateDisplay = tidalHighFormat.contains('_')
? '${tidalHighFormat.split('_').last}kbps'
: '320kbps';
actualQuality = '$displayFormat $bitrateDisplay';
} else if (convertFailed) {
_log.w('M4A to $format conversion failed, keeping M4A file');
actualQuality = 'AAC 320kbps';
} else if (opStarted) {
_log.w('Failed to write converted $format to SAF, keeping M4A');
actualQuality = 'AAC 320kbps';
}
} catch (e) {
_log.w('SAF M4A conversion failed: $e');
actualQuality = 'AAC 320kbps';
}
}
Future<void> _preserveSafNativeM4a(String currentFilePath) async {
// Decrypted streams are already in their final format.
// Converting e.g. eac3 M4A to FLAC would produce fake upscaled output.
_log.d('M4A/MP4 file detected (SAF), preserving native container...');
final preserveExt = currentFilePath.toLowerCase().endsWith('.mp4')
? '.mp4'
: '.m4a';
final newFileName = '${safBaseName ?? 'track'}$preserveExt';
var opStarted = false;
try {
final newUri = await n._replaceSafFileVia(
uri: currentFilePath,
treeUri: settings.downloadTreeUri,
relativeDir: effectiveOutputDir,
op: (tempPath, _) async {
opStarted = true;
if (metadataEmbeddingEnabled) {
n.updateItemStatus(
item.id,
DownloadStatus.finalizing,
progress: 0.99,
);
await _embedFinalMetadata(
tempPath,
format: 'm4a',
writeExternalLrc: false,
);
}
return (tempPath, newFileName);
},
);
if (newUri != null) {
filePath = newUri;
finalSafFileName = newFileName;
} else if (opStarted) {
_log.w('Failed to write M4A to SAF, keeping original');
}
} catch (e) {
_log.w('SAF native M4A handling failed: $e');
}
}
Future<void> _convertSafM4aToFlac(String currentFilePath) async {
_log.d('M4A file detected (SAF), converting to FLAC...');
String? branch;
String? producedFileName;
try {
final newUri = await n._replaceSafFileVia(
uri: currentFilePath,
treeUri: settings.downloadTreeUri,
relativeDir: effectiveOutputDir,
op: (tempPath, addCleanup) async {
final length = await File(tempPath).length();
if (length < 1024) {
_log.w('Temp M4A is too small (<1KB), skipping conversion');
branch = 'skip';
return null;
}
final codec = await FFmpegService.probePrimaryAudioCodec(tempPath);
final isAlreadyNativeFlac =
codec == 'flac' && await FFmpegService.isNativeFlacFile(tempPath);
if (!FFmpegService.isLosslessAudioCodec(codec)) {
_log.d(
'Preserving native container; audio codec is ${codec ?? 'unknown'}, '
'no FLAC container conversion needed.',
);
branch = 'preserve';
final preserveExt = resultOutputExt == '.mp4' ? '.mp4' : '.m4a';
final newFileName = '${safBaseName ?? 'track'}$preserveExt';
producedFileName = newFileName;
return (tempPath, newFileName);
} else if (isAlreadyNativeFlac) {
_log.d(
'Native FLAC payload detected in SAF temp file; '
'publishing as FLAC and embedding metadata.',
);
branch = 'nativeFlac';
await _embedFinalMetadata(
tempPath,
format: 'flac',
writeExternalLrc: false,
);
final newFileName = '${safBaseName ?? 'track'}.flac';
producedFileName = newFileName;
return (tempPath, newFileName);
} else {
n.updateItemStatus(
item.id,
DownloadStatus.finalizing,
progress: 0.95,
);
final flacPath = await FFmpegService.convertM4aToFlac(tempPath);
if (flacPath == null) {
_log.w('FFmpeg conversion returned null, keeping M4A file');
branch = 'convertFailed';
return null;
}
addCleanup(flacPath);
_log.d('Converted to FLAC (temp): $flacPath');
_log.d('Embedding metadata and cover to converted FLAC...');
await _embedFinalMetadata(
flacPath,
format: 'flac',
writeExternalLrc: false,
);
final newFileName = '${safBaseName ?? 'track'}.flac';
branch = 'convert';
producedFileName = newFileName;
return (flacPath, newFileName);
}
},
);
if (newUri != null) {
filePath = newUri;
finalSafFileName = producedFileName;
} else if (branch == 'nativeFlac') {
_log.w('Failed to write native FLAC to SAF');
} else if (branch == 'convert') {
_log.w('Failed to write FLAC to SAF, keeping M4A');
}
} catch (e) {
_log.w('SAF M4A->FLAC conversion failed: $e');
}
}
Future<void> _convertLocalM4aToLossy(String currentFilePath) async {
final tidalHighFormat = settings.tidalHighFormat;
_log.i(
'Lossy 320kbps quality download, converting M4A to $tidalHighFormat...',
);
try {
n.updateItemStatus(item.id, DownloadStatus.finalizing, progress: 0.95);
final format = lossyFormatForSetting(tidalHighFormat);
final displayFormat = displayFormatForLossyFormat(format);
final convertedPath = await FFmpegService.convertM4aToLossy(
currentFilePath,
format: format,
bitrate: tidalHighFormat,
deleteOriginal: true,
);
if (convertedPath != null) {
filePath = convertedPath;
final bitrateDisplay = tidalHighFormat.contains('_')
? '${tidalHighFormat.split('_').last}kbps'
: '320kbps';
actualQuality = '$displayFormat $bitrateDisplay';
_log.i('Successfully converted M4A to $format: $convertedPath');
_log.i('Embedding metadata to $format...');
n.updateItemStatus(item.id, DownloadStatus.finalizing, progress: 0.99);
await _embedFinalMetadata(
convertedPath,
format: metadataFormatForLossyFormat(format),
rebuildTrack: false,
);
_log.d('Metadata embedded successfully');
} else {
_log.w('M4A to $format conversion failed, keeping M4A file');
actualQuality = 'AAC 320kbps';
}
} catch (e) {
_log.w('M4A conversion process failed: $e, keeping M4A file');
actualQuality = 'AAC 320kbps';
}
}
Future<void> _preserveLocalNativeM4a(String currentFilePath) async {
_log.d('M4A/MP4 file detected, preserving native container...');
try {
var targetPath = currentFilePath;
final file = File(targetPath);
if (!await file.exists()) {
_log.e('File does not exist at path: $filePath');
} else {
if (!(targetPath.toLowerCase().endsWith('.m4a') ||
targetPath.toLowerCase().endsWith('.mp4'))) {
final renamedPath = targetPath.replaceAll(
RegExp(r'\.[^.]+$'),
'.m4a',
);
final finalRenamedPath = renamedPath == targetPath
? '$targetPath.m4a'
: renamedPath;
await file.rename(finalRenamedPath);
targetPath = finalRenamedPath;
filePath = finalRenamedPath;
} else {
filePath = targetPath;
}
if (metadataEmbeddingEnabled) {
n.updateItemStatus(
item.id,
DownloadStatus.finalizing,
progress: 0.99,
);
await _embedFinalMetadata(targetPath, format: 'm4a');
}
}
} catch (e) {
_log.w('Native M4A handling failed: $e');
}
}
Future<void> _convertLocalM4aToFlac(String currentFilePath) async {
_log.d(
'M4A file detected (Hi-Res DASH stream), attempting conversion to FLAC...',
);
try {
final file = File(currentFilePath);
if (!await file.exists()) {
_log.e('File does not exist at path: $filePath');
} else {
final length = await file.length();
_log.i('File size before conversion: ${length / 1024} KB');
if (length < 1024) {
_log.w(
'File is too small (<1KB), skipping conversion. Download might be corrupt.',
);
} else {
final codec = await FFmpegService.probePrimaryAudioCodec(
currentFilePath,
);
final isAlreadyNativeFlac =
codec == 'flac' &&
await FFmpegService.isNativeFlacFile(currentFilePath);
if (!FFmpegService.isLosslessAudioCodec(codec)) {
_log.d(
'Preserving native container; audio codec is ${codec ?? 'unknown'}, '
'no FLAC container conversion needed.',
);
} else if (isAlreadyNativeFlac) {
_log.d(
'Native FLAC payload detected; ensuring .flac '
'extension and embedding metadata.',
);
var flacPath = currentFilePath;
if (!currentFilePath.toLowerCase().endsWith('.flac')) {
final renamedPath = currentFilePath.replaceAll(
RegExp(r'\.[^.]+$'),
'.flac',
);
final targetPath = renamedPath == currentFilePath
? '$currentFilePath.flac'
: renamedPath;
await File(currentFilePath).rename(targetPath);
flacPath = targetPath;
filePath = targetPath;
}
await _embedFinalMetadata(flacPath, format: 'flac');
} else {
n.updateItemStatus(
item.id,
DownloadStatus.finalizing,
progress: 0.95,
);
final flacPath = await FFmpegService.convertM4aToFlac(
currentFilePath,
);
if (flacPath != null) {
filePath = flacPath;
_log.d('Converted to FLAC: $flacPath');
_log.d('Embedding metadata and cover to converted FLAC...');
try {
final backendGenre = result['genre'] as String?;
final backendLabel = result['label'] as String?;
final backendCopyright = result['copyright'] as String?;
if (backendGenre != null ||
backendLabel != null ||
backendCopyright != null) {
_log.d(
'Extended metadata from backend - Genre: $backendGenre, Label: $backendLabel, Copyright: $backendCopyright',
);
}
await _embedFinalMetadata(flacPath, format: 'flac');
_log.d('Metadata and cover embedded successfully');
} catch (e) {
_log.w('Warning: Failed to embed metadata/cover: $e');
}
} else {
_log.w('FFmpeg conversion returned null, keeping M4A file');
}
}
}
}
} catch (e) {
_log.w('FFmpeg conversion process failed: $e, keeping M4A file');
}
}
Future<void> _embedSafNonM4a(String currentFilePath) async {
final isOpusFile =
currentFilePath.endsWith('.opus') ||
currentFilePath.endsWith('.ogg') ||
resultOutputExt == '.opus' ||
resultOutputExt == '.ogg';
final isMp3File =
currentFilePath.endsWith('.mp3') || resultOutputExt == '.mp3';
final ext = isOpusFile
? (resultOutputExt == '.ogg' ? '.ogg' : '.opus')
: isMp3File
? '.mp3'
: '.flac';
final formatName = isOpusFile
? 'Opus'
: isMp3File
? 'MP3'
: 'FLAC';
_log.d(
'SAF $formatName detected, embedding metadata and cover via temp file...',
);
final newFileName = '${safBaseName ?? 'track'}$ext';
var opStarted = false;
try {
final newUri = await n._replaceSafFileVia(
uri: currentFilePath,
treeUri: settings.downloadTreeUri,
relativeDir: effectiveOutputDir,
op: (tempPath, _) async {
opStarted = true;
n.updateItemStatus(
item.id,
DownloadStatus.finalizing,
progress: 0.99,
);
// writeExternalLrc: false — tempPath lives in the cache dir,
// so a sidecar .lrc written next to it would be orphaned;
// the SAF .lrc is written by _saveExternalLrc after publish,
// reusing the LRC fetched here via result['lyrics_lrc'].
final embedFormat = isMp3File
? 'mp3'
: isOpusFile
? 'opus'
: 'flac';
final fetchedLrc = await _embedFinalMetadata(
tempPath,
format: embedFormat,
writeExternalLrc: false,
);
final existingLrc = result['lyrics_lrc'] as String?;
if ((existingLrc == null || existingLrc.isEmpty) &&
fetchedLrc != null &&
fetchedLrc.isNotEmpty) {
result['lyrics_lrc'] = fetchedLrc;
}
// Probe quality from the local temp now: the same probe on
// the published content:// URI would copy the whole file
// out of SAF again just to read a few header bytes.
try {
probedFinalMetadata = await PlatformBridge.readFileMetadata(
tempPath,
);
} catch (_) {}
return (tempPath, newFileName);
},
);
if (newUri != null) {
filePath = newUri;
finalSafFileName = newFileName;
_log.d('SAF $formatName metadata embedding completed');
} else if (opStarted) {
_log.w('Failed to write metadata-updated $formatName back to SAF');
}
} catch (e) {
_log.w('SAF $formatName metadata embedding failed: $e');
}
}
Future<void> _embedLocalFlacAfterDecrypt(String currentFilePath) async {
_log.d(
'Local FLAC after decrypt detected, embedding metadata and cover...',
);
try {
n.updateItemStatus(item.id, DownloadStatus.finalizing, progress: 0.99);
await _embedFinalMetadata(currentFilePath, format: 'flac');
_log.d('Local FLAC metadata embedding completed');
} catch (e) {
_log.w('Local FLAC metadata embedding failed: $e');
}
}
/// Final metadata embed shared by every publish branch. Backend-provided
/// genre/label/copyright win over the Deezer extended-metadata lookup.
/// [rebuildTrack] is false only for the lossy-HIGH branches, which embed
/// the track as-is instead of re-merging the download result into it.
Future<String?> _embedFinalMetadata(
String path, {
required String format,
bool writeExternalLrc = true,
bool rebuildTrack = true,
}) {
final track = rebuildTrack
? n._buildTrackForMetadataEmbedding(
trackToDownload,
result,
resolvedAlbumArtist,
)
: trackToDownload;
return n._embedMetadataToFile(
path,
track,
format: format,
genre: (result['genre'] as String?) ?? genre,
label: (result['label'] as String?) ?? label,
copyright: result['copyright'] as String?,
downloadService: item.service,
writeExternalLrc: writeExternalLrc,
);
}
Future<void> _recoverSafUriIfNeeded() async {
final path = filePath;
if (!effectiveSafMode ||
path == null ||
path.isEmpty ||
isContentUri(path) ||
settings.downloadTreeUri.isEmpty) {
return;
}
final fallbackName = (finalSafFileName ?? safFileName ?? '').trim();
if (fallbackName.isNotEmpty) {
try {
final resolved = await PlatformBridge.resolveSafFile(
treeUri: settings.downloadTreeUri,
relativeDir: effectiveOutputDir,
fileName: fallbackName,
);
final resolvedUri = (resolved['uri'] as String? ?? '').trim();
final resolvedRelativeDir = (resolved['relative_dir'] as String? ?? '')
.trim();
if (resolvedUri.isNotEmpty && isContentUri(resolvedUri)) {
_log.w('Recovered SAF URI from transient path: $path');
filePath = resolvedUri;
finalSafFileName = fallbackName;
if (resolvedRelativeDir.isNotEmpty) {
effectiveOutputDir = resolvedRelativeDir;
}
} else {
_log.w(
'Failed to recover SAF URI (fileName=$fallbackName, dir=$effectiveOutputDir)',
);
}
} catch (e) {
_log.w('SAF URI recovery failed: $e');
}
} else {
_log.w(
'SAF download returned non-URI path without filename metadata: $filePath',
);
}
}
Future<void> _persistCompletionAndNotify() async {
final path = filePath;
final historyNotifier = n.ref.read(downloadHistoryProvider.notifier);
final existingInHistory = path == null
? null
: await historyNotifier.getByFilePathAsync(path);
if (wasExisting && existingInHistory != null) {
_log.i(
'Track file already exists in library; refreshing its history metadata',
);
}
if (path != null) {
final historyFilePath = path;
final backendBitDepth = result['actual_bit_depth'] as int?;
final backendSampleRate = result['actual_sample_rate'] as int?;
final backendFormat =
normalizeAudioFormatValue(
result['audio_codec']?.toString() ?? result['format']?.toString(),
) ??
normalizeAudioFormatValue(audioFormatForPath(path));
final backendBitrateKbps = readPositiveBitrateKbps(
result['bitrate'] ?? result['actual_bitrate'],
);
final backendGenre = result['genre'] as String?;
final backendLabel = result['label'] as String?;
final backendCopyright = result['copyright'] as String?;
final effectiveGenre =
normalizeOptionalString(backendGenre) ??
normalizeOptionalString(genre) ??
normalizeOptionalString(existingInHistory?.genre);
final effectiveLabel =
normalizeOptionalString(backendLabel) ??
normalizeOptionalString(label) ??
normalizeOptionalString(existingInHistory?.label);
final effectiveCopyright =
normalizeOptionalString(backendCopyright) ??
normalizeOptionalString(copyright) ??
normalizeOptionalString(existingInHistory?.copyright);
int? finalBitDepth = backendBitDepth;
int? finalSampleRate = backendSampleRate;
String? finalFormat = backendFormat;
int? finalBitrateKbps = backendBitrateKbps;
final lowerFilePath = path.toLowerCase();
final canProbeFinalMetadata =
path.startsWith('content://') ||
lowerFilePath.endsWith('.flac') ||
lowerFilePath.endsWith('.m4a') ||
lowerFilePath.endsWith('.mp4') ||
lowerFilePath.endsWith('.aac') ||
lowerFilePath.endsWith('.mp3') ||
lowerFilePath.endsWith('.opus') ||
lowerFilePath.endsWith('.ogg');
if (canProbeFinalMetadata) {
try {
final probed = probedFinalMetadata;
final metadata = (probed != null && probed['error'] == null)
? probed
: await PlatformBridge.readFileMetadata(path);
if (metadata['error'] == null) {
final probedBitDepth = metadata['bit_depth'] is num
? (metadata['bit_depth'] as num).toInt()
: int.tryParse(metadata['bit_depth']?.toString() ?? '');
final probedSampleRate = metadata['sample_rate'] is num
? (metadata['sample_rate'] as num).toInt()
: int.tryParse(metadata['sample_rate']?.toString() ?? '');
if (probedBitDepth != null && probedBitDepth > 0) {
finalBitDepth = probedBitDepth;
}
if (probedSampleRate != null && probedSampleRate > 0) {
finalSampleRate = probedSampleRate;
}
final probedFormat = normalizeAudioFormatValue(
metadata['audio_codec']?.toString() ??
metadata['format']?.toString(),
);
if (probedFormat != null) {
finalFormat = probedFormat;
}
final probedBitrateKbps = readPositiveBitrateKbps(
metadata['bitrate'] ?? metadata['bit_rate'],
);
if (probedBitrateKbps != null) {
finalBitrateKbps = probedBitrateKbps;
}
final resolvedQuality = resolveDisplayQuality(
filePath: path,
fileName: finalSafFileName,
detectedFormat: finalFormat,
bitDepth: finalBitDepth,
sampleRate: finalSampleRate,
bitrateKbps: finalBitrateKbps,
storedQuality: actualQuality,
);
if (resolvedQuality != null) {
actualQuality = resolvedQuality;
}
}
} catch (e) {
_log.d('Final audio metadata probe failed for $path: $e');
}
}
_log.d('Saving to history - coverUrl: ${trackToDownload.coverUrl}');
final isLossyOutput =
isLossyAudioFormat(finalFormat) ||
lowerFilePath.endsWith('.mp3') ||
lowerFilePath.endsWith('.opus') ||
lowerFilePath.endsWith('.ogg');
final historyBitDepth = isLossyOutput ? null : finalBitDepth;
final historySampleRate = isLossyOutput ? null : finalSampleRate;
final historyBitrate = finalBitrateKbps;
await persistBeforePublishingDownloadCompletion(
persist: () async {
if (!settings.saveDownloadHistory) return;
await n.ref
.read(downloadHistoryProvider.notifier)
.addToHistory(
n._historyItemFromResult(
item: item,
trackToDownload: trackToDownload,
result: result,
filePath: historyFilePath,
quality: actualQuality,
useSaf: effectiveSafMode,
downloadTreeUri: settings.downloadTreeUri,
safRelativeDir: effectiveOutputDir,
safFileName: finalSafFileName ?? safFileName,
bitDepth: historyBitDepth,
sampleRate: historySampleRate,
bitrate: historyBitrate,
format: finalFormat,
genre: effectiveGenre,
label: effectiveLabel,
copyright: effectiveCopyright,
),
preserveTrackVariant: item.preserveQualityVariant,
);
},
publish: () {
n._completedInSession++;
n.updateItemStatus(
item.id,
DownloadStatus.completed,
progress: 1.0,
filePath: path,
);
},
);
await n._notificationService.showDownloadComplete(
trackName: item.track.name,
artistName: item.track.artistName,
completedCount: n._completedInSession,
totalCount: n._totalQueuedAtStart,
alreadyInLibrary: wasExisting,
);
n.removeItem(item.id);
}
}
Future<bool> _handleBackendFailure() async {
if (await _shouldAbort('after backend failure')) {
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') {
if (n._isPausePending(item.id)) {
pausedDuringThisRun = true;
n._requeueItemForPause(item.id);
_log.i('Download was paused by backend cancellation, re-queueing');
} else {
_log.i('Download was cancelled by backend, skipping error handling');
n.updateItemStatus(item.id, DownloadStatus.skipped);
}
return false;
}
final backendErrorType = n._downloadErrorTypeFromBackend(errorTypeStr);
final errorType = backendErrorType == DownloadErrorType.unknown
? n._downloadErrorTypeFromMessage(errorMsg)
: backendErrorType;
if (errorType == DownloadErrorType.verificationRequired) {
await n._handleVerificationRequiredDownload(
item,
errorMsg,
result['service'] as String?,
);
return false;
}
if (errorType == DownloadErrorType.rateLimit &&
await n._handleRateLimitedDownload(item, errorMsg)) {
return false;
}
_log.e('Download failed: $errorMsg (type: $errorTypeStr)');
n.updateItemStatus(
item.id,
DownloadStatus.failed,
error: errorMsg,
errorType: errorType,
);
n._failedInSession++;
try {
await PlatformBridge.cleanupConnections();
} catch (e) {
_log.e('Post-failure connection cleanup failed: $e');
}
return true;
}
Future<void> _handleRunException(Object e, StackTrace stackTrace) async {
if (await _shouldAbort('after exception')) {
return;
}
_log.e('Exception: $e', e, stackTrace);
String errorMsg = e.toString();
DownloadErrorType errorType = DownloadErrorType.unknown;
if (isStorageWriteFailure(errorMessage: errorMsg)) {
try {
await n._activateAppFolderStorageFallback(
failedOutputDir: n._isSafMode(settings)
? null
: (effectiveOutputDir.isNotEmpty
? effectiveOutputDir
: n.state.outputDir),
);
n.updateItemStatus(item.id, DownloadStatus.queued, progress: 0.0);
_log.w(
'Storage exception recovered; re-queued ${item.track.name} in the app folder',
);
return;
} catch (fallbackError) {
_log.e(
'Could not recover storage exception with app-folder fallback: '
'$fallbackError',
);
}
}
if (errorMsg.contains('could not find Deezer equivalent') ||
errorMsg.contains('track not found on Deezer')) {
errorMsg = 'Track not found on Deezer (Metadata Unavailable)';
errorType = DownloadErrorType.notFound;
} else {
errorType = n._downloadErrorTypeFromMessage(errorMsg);
}
if (errorType == DownloadErrorType.verificationRequired) {
await n._handleVerificationRequiredDownload(item, errorMsg, item.service);
return;
}
if (errorType == DownloadErrorType.rateLimit &&
await n._handleRateLimitedDownload(item, errorMsg)) {
return;
}
n.updateItemStatus(
item.id,
DownloadStatus.failed,
error: errorMsg,
errorType: errorType,
);
n._failedInSession++;
try {
await PlatformBridge.cleanupConnections();
} catch (cleanupErr) {
_log.e('Post-exception connection cleanup failed: $cleanupErr');
}
}
}