From 2d52f3251e57908fafcddb3a41bfe1cf610a2488 Mon Sep 17 00:00:00 2001 From: zarzet Date: Sun, 12 Jul 2026 21:31:38 +0700 Subject: [PATCH] refactor(queue): split download_queue_provider into concern part files Pure move: paths, native worker, finalization, ReplayGain, and metadata embedding move to part files as private extensions on DownloadQueueNotifier; queue orchestration, state, and public API stay in the main file (7598 -> 4022 lines). --- lib/providers/download_queue_provider.dart | 3594 +---------------- .../download_queue_provider_embedding.dart | 912 +++++ .../download_queue_provider_finalization.dart | 695 ++++ ...download_queue_provider_native_worker.dart | 1139 ++++++ .../download_queue_provider_paths.dart | 564 +++ .../download_queue_provider_replaygain.dart | 307 ++ 6 files changed, 3626 insertions(+), 3585 deletions(-) create mode 100644 lib/providers/download_queue_provider_embedding.dart create mode 100644 lib/providers/download_queue_provider_finalization.dart create mode 100644 lib/providers/download_queue_provider_native_worker.dart create mode 100644 lib/providers/download_queue_provider_paths.dart create mode 100644 lib/providers/download_queue_provider_replaygain.dart diff --git a/lib/providers/download_queue_provider.dart b/lib/providers/download_queue_provider.dart index 6c4a7e6d..75679ffd 100644 --- a/lib/providers/download_queue_provider.dart +++ b/lib/providers/download_queue_provider.dart @@ -33,6 +33,12 @@ export 'package:spotiflac_android/providers/download_history_provider.dart'; export 'package:spotiflac_android/services/history_database.dart' show HistoryLookupRequest, HistoryBatchLookupRequest; +part 'download_queue_provider_paths.dart'; +part 'download_queue_provider_native_worker.dart'; +part 'download_queue_provider_finalization.dart'; +part 'download_queue_provider_replaygain.dart'; +part 'download_queue_provider_embedding.dart'; + final _log = AppLogger('DownloadQueue'); /// Set on queued items when the persisted Android SAF grant fails validation. @@ -145,41 +151,6 @@ class _ProgressUpdate { }); } -class _NativeWorkerRequestContext { - final DownloadItem item; - final String requestJson; - final String outputDir; - final String quality; - final String storageMode; - final String outputExt; - final String? downloadTreeUri; - final String? safRelativeDir; - final String? safFileName; - - const _NativeWorkerRequestContext({ - required this.item, - required this.requestJson, - required this.outputDir, - required this.quality, - required this.storageMode, - required this.outputExt, - this.downloadTreeUri, - this.safRelativeDir, - this.safFileName, - }); -} - -/// 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 DownloadQueueNotifier extends Notifier { Timer? _queuePersistDebounce; StreamSubscription>? _connectivitySub; @@ -194,6 +165,9 @@ class DownloadQueueNotifier extends Notifier { 'download_queue_native_worker_run_id'; static const _bytesUiStep = 104857; // ~0.1 MiB, matches one-decimal MB UI. static const _serviceProgressStepPercent = 2; + static const _decryptStageSafAccess = 'safAccess'; + static const _decryptStageDecrypt = 'decrypt'; + static const _decryptStageSafWrite = 'safWrite'; final NotificationService _notificationService = NotificationService(); final AppStateDatabase _appStateDb = AppStateDatabase.instance; late final ProgressStreamPoller> _progressPoller = @@ -968,246 +942,10 @@ class DownloadQueueNotifier extends Notifier { _lastNotifQueueCount = -1; } - Directory _defaultDocumentsOutputDir(String documentsPath) { - return Directory('$documentsPath/$_defaultOutputFolderName'); - } - - Directory _defaultAndroidMusicOutputDir(String storageRootPath) { - return Directory('$storageRootPath/$_defaultAndroidMusicSubpath'); - } - - Future _ensureDefaultDocumentsOutputDir() async { - final dir = await getApplicationDocumentsDirectory(); - final musicDir = _defaultDocumentsOutputDir(dir.path); - if (!await musicDir.exists()) { - await musicDir.create(recursive: true); - } - return musicDir; - } - - Future _ensureDefaultAndroidMusicOutputDir() async { - final dir = await getExternalStorageDirectory(); - if (dir == null) return null; - - final musicDir = _defaultAndroidMusicOutputDir( - dir.parent.parent.parent.parent.path, - ); - if (!await musicDir.exists()) { - await musicDir.create(recursive: true); - } - return musicDir; - } - - Future _initOutputDir() async { - if (state.outputDir.isEmpty) { - try { - if (Platform.isIOS) { - final musicDir = await _ensureDefaultDocumentsOutputDir(); - state = state.copyWith(outputDir: musicDir.path); - } else { - final musicDir = - await _ensureDefaultAndroidMusicOutputDir() ?? - await _ensureDefaultDocumentsOutputDir(); - state = state.copyWith(outputDir: musicDir.path); - } - } catch (e) { - final musicDir = await _ensureDefaultDocumentsOutputDir(); - state = state.copyWith(outputDir: musicDir.path); - } - } - } - - Future _ensureDirExists(String path, {String? label}) async { - if (_ensuredDirs.contains(path)) return; - final dir = Directory(path); - if (!await dir.exists()) { - await dir.create(recursive: true); - if (label != null) { - _log.d('Created $label: $path'); - } else { - _log.d('Created folder: $path'); - } - } - _ensuredDirs.add(path); - } - void setOutputDir(String dir) { state = state.copyWith(outputDir: dir); } - bool _shouldTreatAsSingleRelease(Track track) { - if (track.isSingle) { - return true; - } - - final normalizedAlbumType = normalizeOptionalString( - track.albumType, - )?.toLowerCase(); - if (normalizedAlbumType != null && normalizedAlbumType.isNotEmpty) { - return false; - } - - final totalTracks = track.totalTracks; - if (totalTracks == 1) { - return true; - } - - final normalizedAlbumName = normalizeOptionalString( - track.albumName, - )?.toLowerCase(); - if (normalizedAlbumName == 'single' || normalizedAlbumName == 'singles') { - return totalTracks == null || totalTracks <= 2; - } - - return false; - } - - Future _buildOutputDir( - Track track, - String folderOrganization, { - bool separateSingles = false, - String albumFolderStructure = 'artist_album', - bool createPlaylistFolder = false, - bool useAlbumArtistForFolders = true, - bool usePrimaryArtistOnly = false, - bool filterContributingArtistsInAlbumArtist = false, - String? playlistName, - }) async { - final baseDir = state.outputDir; - if (createPlaylistFolder && - folderOrganization != 'playlist' && - playlistName != null && - playlistName.isNotEmpty) { - final playlistFolder = _sanitizeFolderName(playlistName); - if (playlistFolder.isNotEmpty) { - await _ensureDirExists( - '$baseDir${Platform.pathSeparator}$playlistFolder', - label: 'Playlist folder', - ); - } - } - - final relativeDir = await _buildRelativeOutputDir( - track, - folderOrganization, - separateSingles: separateSingles, - albumFolderStructure: albumFolderStructure, - createPlaylistFolder: createPlaylistFolder, - useAlbumArtistForFolders: useAlbumArtistForFolders, - usePrimaryArtistOnly: usePrimaryArtistOnly, - filterContributingArtistsInAlbumArtist: - filterContributingArtistsInAlbumArtist, - playlistName: playlistName, - ); - if (relativeDir.isEmpty) { - return baseDir; - } - - final fullPath = - '$baseDir${Platform.pathSeparator}' - '${relativeDir.replaceAll('/', Platform.pathSeparator)}'; - await _ensureDirExists(fullPath); - return fullPath; - } - - String _sanitizeFolderName(String name) { - final buffer = StringBuffer(); - for (final rune in name.runes) { - if (rune < 0x20 || rune == 0x7f) { - continue; - } - final char = String.fromCharCode(rune); - if (_invalidFolderChars.hasMatch(char)) { - buffer.write(' '); - continue; - } - buffer.write(char); - } - - var sanitized = buffer.toString().trim(); - sanitized = sanitized.replaceAll(_trimDotsAndSpacesRegex, ''); - sanitized = sanitized.replaceAll(_multiWhitespaceRegex, ' '); - sanitized = sanitized.replaceAll(_multiUnderscoreRegex, '_'); - sanitized = sanitized.replaceAll(_trimUnderscoresAndSpacesRegex, ''); - - if (sanitized.isEmpty) { - return 'Unknown'; - } - return sanitized; - } - - String _truncateUtf8Bytes(String value, int maxBytes) { - if (maxBytes <= 0 || utf8.encode(value).length <= maxBytes) { - return value; - } - - final buffer = StringBuffer(); - var usedBytes = 0; - for (final rune in value.runes) { - final char = String.fromCharCode(rune); - final charBytes = utf8.encode(char).length; - if (usedBytes + charBytes > maxBytes) break; - buffer.write(char); - usedBytes += charBytes; - } - return buffer.toString(); - } - - String _trimSafeName(String value) { - var trimmed = value.trim(); - trimmed = trimmed.replaceAll(_trimDotsAndSpacesRegex, ''); - trimmed = trimmed.replaceAll(_trimUnderscoresAndSpacesRegex, ''); - return trimmed.isEmpty ? 'Unknown' : trimmed; - } - - String _sanitizeSafRelativeDir(String relativeDir) { - if (relativeDir.trim().isEmpty) return ''; - final parts = relativeDir - .split('/') - .map(_sanitizeFolderName) - .map((part) { - final truncated = _truncateUtf8Bytes( - part, - _maxSafDirSegmentUtf8Bytes, - ); - return _trimSafeName(truncated); - }) - .where((part) => part.isNotEmpty && part != '.' && part != '..') - .toList(growable: false); - return parts.join('/'); - } - - Future _buildSafFileName(String baseName, String outputExt) async { - final sanitized = await PlatformBridge.sanitizeFilename(baseName); - final extBytes = utf8.encode(outputExt).length; - final maxBaseBytes = max(1, _maxSafFilenameUtf8Bytes - extBytes); - final truncated = _truncateUtf8Bytes(sanitized, maxBaseBytes); - return '${_trimSafeName(truncated)}$outputExt'; - } - - static final _featuredArtistPattern = RegExp( - r'\s*[,;]\s*|\s+(?:feat\.?|ft\.?|featuring|with|x)\s+', - caseSensitive: false, - ); - - String _extractPrimaryArtist(String artist) { - final match = _featuredArtistPattern.firstMatch(artist); - if (match != null && match.start > 0) { - return artist.substring(0, match.start).trim(); - } - return artist; - } - - String? _resolveAlbumArtistForMetadata(Track track, AppSettings settings) { - var albumArtist = normalizeOptionalString(track.albumArtist); - if (settings.filterContributingArtistsInAlbumArtist) { - albumArtist = albumArtist == null - ? null - : normalizeOptionalString(_extractPrimaryArtist(albumArtist)); - } - return albumArtist; - } - bool _isSafMode(AppSettings settings) { return Platform.isAndroid && settings.storageMode == 'saf' && @@ -1225,206 +963,6 @@ class DownloadQueueNotifier extends Notifier { error.contains('documentfile'); } - Future _buildRelativeOutputDir( - Track track, - String folderOrganization, { - bool separateSingles = false, - String albumFolderStructure = 'artist_album', - bool createPlaylistFolder = false, - bool useAlbumArtistForFolders = true, - bool usePrimaryArtistOnly = false, - bool filterContributingArtistsInAlbumArtist = false, - String? playlistName, - }) async { - final playlistPrefix = - createPlaylistFolder && - folderOrganization != 'playlist' && - playlistName != null && - playlistName.isNotEmpty - ? _sanitizeFolderName(playlistName) - : ''; - final normalizedAlbumArtist = normalizeOptionalString(track.albumArtist); - var folderArtist = useAlbumArtistForFolders - ? normalizedAlbumArtist ?? track.artistName - : track.artistName; - if (useAlbumArtistForFolders && - filterContributingArtistsInAlbumArtist && - normalizedAlbumArtist != null) { - folderArtist = _extractPrimaryArtist(folderArtist); - } - if (usePrimaryArtistOnly) { - folderArtist = _extractPrimaryArtist(folderArtist); - } - - if (separateSingles) { - final isSingle = _shouldTreatAsSingleRelease(track); - final artistName = _sanitizeFolderName(folderArtist); - - if (albumFolderStructure == 'artist_album_singles') { - if (isSingle) { - return _joinRelativePath(playlistPrefix, '$artistName/Singles'); - } - final albumName = _sanitizeFolderName(track.albumName); - return _joinRelativePath(playlistPrefix, '$artistName/$albumName'); - } - - if (albumFolderStructure == 'artist_album_flat') { - if (isSingle) { - return _joinRelativePath(playlistPrefix, artistName); - } - final albumName = _sanitizeFolderName(track.albumName); - return _joinRelativePath(playlistPrefix, '$artistName/$albumName'); - } - - if (isSingle) { - return _joinRelativePath(playlistPrefix, 'Singles'); - } - - final albumName = _sanitizeFolderName(track.albumName); - final year = _extractYear(track.releaseDate); - switch (albumFolderStructure) { - case 'album_only': - return _joinRelativePath(playlistPrefix, 'Albums/$albumName'); - case 'artist_year_album': - final yearAlbum = year != null ? '[$year] $albumName' : albumName; - return _joinRelativePath( - playlistPrefix, - 'Albums/$artistName/$yearAlbum', - ); - case 'year_album': - final yearAlbum = year != null ? '[$year] $albumName' : albumName; - return _joinRelativePath(playlistPrefix, 'Albums/$yearAlbum'); - default: - return _joinRelativePath( - playlistPrefix, - 'Albums/$artistName/$albumName', - ); - } - } - - if (folderOrganization == 'none') { - return playlistPrefix; - } - - switch (folderOrganization) { - case 'playlist': - if (playlistName != null && playlistName.isNotEmpty) { - return _sanitizeFolderName(playlistName); - } - return ''; - case 'artist': - return _joinRelativePath( - playlistPrefix, - _sanitizeFolderName(folderArtist), - ); - case 'album': - return _joinRelativePath( - playlistPrefix, - _sanitizeFolderName(track.albumName), - ); - case 'artist_album': - final artistName = _sanitizeFolderName(folderArtist); - final albumName = _sanitizeFolderName(track.albumName); - return _joinRelativePath(playlistPrefix, '$artistName/$albumName'); - default: - return playlistPrefix; - } - } - - String _joinRelativePath(String prefix, String suffix) { - if (prefix.isEmpty) return suffix; - if (suffix.isEmpty) return prefix; - return '$prefix/$suffix'; - } - - String? _extensionPreferredOutputExt(String service) { - final normalizedService = service.trim().toLowerCase(); - if (normalizedService.isEmpty) return null; - - final extensionState = ref.read(extensionProvider); - for (final ext in extensionState.extensions) { - if (!ext.enabled || !ext.hasDownloadProvider) continue; - if (ext.id.toLowerCase() != normalizedService) continue; - - final preferred = ext.preferredDownloadOutputExtension; - if (preferred == null) return null; - - final normalized = preferred.startsWith('.') - ? preferred.toLowerCase() - : '.${preferred.toLowerCase()}'; - if (normalized == '.mp4') { - return '.m4a'; - } - const allowed = {'.flac', '.m4a', '.mp3', '.opus'}; - if (allowed.contains(normalized)) { - return normalized; - } - return null; - } - - return null; - } - - bool _extensionPreservesNativeOutputExt(String service, String ext) { - final normalizedService = service.trim().toLowerCase(); - final normalizedExt = ext.trim().toLowerCase(); - if (normalizedService.isEmpty || normalizedExt.isEmpty) return false; - - final extensionState = ref.read(extensionProvider); - return extensionState.extensions.any( - (ext) => - ext.enabled && - ext.hasDownloadProvider && - ext.id.toLowerCase() == normalizedService && - ext.preservedNativeOutputExtensions.contains(normalizedExt), - ); - } - - bool _extensionRequiresNativeContainerConversion(String service) { - final normalizedService = service.trim().toLowerCase(); - if (normalizedService.isEmpty) return false; - - final extensionState = ref.read(extensionProvider); - return extensionState.extensions.any( - (ext) => - ext.enabled && - ext.hasDownloadProvider && - (ext.id.toLowerCase() == normalizedService || - ext.replacesBuiltInProviders.contains(normalizedService)) && - ext.requiresNativeContainerConversion, - ); - } - - bool _shouldRequestContainerConversion(String service, String outputExt) { - return outputExt.trim().toLowerCase() == '.flac' && - _extensionRequiresNativeContainerConversion(service); - } - - String _determineOutputExt(String quality, String service) { - final extensionPreferred = _extensionPreferredOutputExt(service); - if (extensionPreferred != null) { - return extensionPreferred; - } - if (_downloadProviderReplacesLegacyProvider(service, 'tidal') && - quality == 'HIGH') { - return '.m4a'; - } - final q = quality.toLowerCase(); - if (q == 'alac' || q.startsWith('aac')) return '.m4a'; - if (q.startsWith('opus')) return '.opus'; - if (q.startsWith('mp3')) return '.mp3'; - return '.flac'; - } - - bool _downloadProviderReplacesLegacyProvider( - String service, - String legacyProviderId, - ) { - return ref - .read(extensionProvider.notifier) - .downloadProviderReplacesLegacyProvider(service, legacyProviderId); - } - String _normalizeQueuedService(String service) { final normalized = service.trim(); if (normalized.isEmpty) { @@ -1456,494 +994,6 @@ class DownloadQueueNotifier extends Notifier { ); } - String _mimeTypeForExt(String ext) { - switch (ext.toLowerCase()) { - case '.m4a': - case '.mp4': - return 'audio/mp4'; - case '.mp3': - return 'audio/mpeg'; - case '.opus': - return 'audio/ogg'; - case '.flac': - return 'audio/flac'; - case '.lrc': - return 'application/octet-stream'; - default: - return 'application/octet-stream'; - } - } - - String? _normalizeAudioExt(Object? value) { - final raw = value?.toString().trim().toLowerCase(); - if (raw == null || raw.isEmpty) return null; - final normalized = raw.startsWith('.') ? raw : '.$raw'; - const allowed = {'.flac', '.m4a', '.mp4', '.mp3', '.opus', '.ogg', '.aac'}; - return allowed.contains(normalized) ? normalized : null; - } - - String? _downloadResultOutputExt( - Map result, { - String? filePath, - }) { - final explicit = - _normalizeAudioExt(result['actual_extension']) ?? - _normalizeAudioExt(result['output_extension']) ?? - _normalizeAudioExt(result['actual_container']) ?? - _normalizeAudioExt(result['container']); - if (explicit != null) return explicit; - - for (final candidate in [ - result['file_name'] as String?, - filePath, - result['file_path'] as String?, - ]) { - if (candidate == null) continue; - final lower = candidate.trim().toLowerCase(); - for (final ext in const [ - '.flac', - '.m4a', - '.mp4', - '.mp3', - '.opus', - '.ogg', - '.aac', - ]) { - if (lower.endsWith(ext)) return ext; - } - } - - // Generic safety net: when neither an explicit extension field nor a - // recognizable path suffix is available (e.g. SAF content URIs that drop - // the suffix), fall back to the actual audio codec reported by the backend - // probe. This keeps any extension that returns a non-FLAC container (Opus, - // MP3, AAC) from being mislabeled as FLAC. - final codec = normalizeAudioFormatValue( - result['audio_codec']?.toString() ?? - result['actual_audio_codec']?.toString() ?? - result['format']?.toString(), - ); - switch (codec) { - case 'opus': - return '.opus'; - case 'mp3': - return '.mp3'; - case 'aac': - case 'alac': - case 'm4a': - return '.m4a'; - case 'flac': - return '.flac'; - } - return null; - } - - Future _getSafMimeType(String uri) async { - try { - final stat = await PlatformBridge.safStat(uri); - return stat['mime_type'] as String?; - } catch (_) { - return null; - } - } - - String? _extractYear(String? releaseDate) { - if (releaseDate == null || releaseDate.isEmpty) return null; - final match = _yearRegex.firstMatch(releaseDate); - return match?.group(1); - } - - static final _isrcRegex = RegExp(r'^[A-Z]{2}[A-Z0-9]{3}\d{2}\d{5}$'); - - bool _isValidISRC(String value) { - return _isrcRegex.hasMatch(value.toUpperCase()); - } - - /// Returns true if any enabled extension matching [source] or [service] - /// declares `skipLyrics: true` in its manifest. - bool _shouldSkipLyrics( - ExtensionState extensionState, - String? source, - String? service, - ) { - final candidates = {}; - if (source != null && source.isNotEmpty) { - candidates.add(source.trim().toLowerCase()); - } - if (service != null && service.isNotEmpty) { - candidates.add(service.trim().toLowerCase()); - } - if (candidates.isEmpty) return false; - return extensionState.extensions.any( - (e) => - e.enabled && e.skipLyrics && candidates.contains(e.id.toLowerCase()), - ); - } - - String? _extractKnownDeezerTrackId(Track track) { - final deezerId = track.deezerId?.trim(); - if (deezerId != null && deezerId.isNotEmpty) { - return deezerId; - } - - if (track.id.startsWith('deezer:')) { - final rawId = track.id.substring('deezer:'.length).trim(); - if (rawId.isNotEmpty) { - return rawId; - } - } - - final availabilityDeezerId = track.availability?.deezerId?.trim(); - if (availabilityDeezerId != null && availabilityDeezerId.isNotEmpty) { - return availabilityDeezerId; - } - - return null; - } - - Future _searchDeezerTrackIdByIsrc( - String? isrc, { - required String lookupContext, - String? itemId, - }) async { - final normalizedIsrc = normalizeOptionalString(isrc); - if (normalizedIsrc == null || !_isValidISRC(normalizedIsrc)) { - return null; - } - - try { - _log.d('No Deezer ID, searching by $lookupContext: $normalizedIsrc'); - final deezerResult = await PlatformBridge.searchDeezerByISRC( - normalizedIsrc, - itemId: itemId, - ); - if (deezerResult['success'] == true && deezerResult['track_id'] != null) { - final deezerTrackId = deezerResult['track_id'].toString(); - _log.d('Found Deezer track ID via $lookupContext: $deezerTrackId'); - return deezerTrackId; - } - } catch (e) { - _log.w('Failed to search Deezer by $lookupContext: $e'); - } - - return null; - } - - Track _copyTrackWithResolvedMetadata( - Track track, { - String? resolvedIsrc, - int? trackNumber, - int? totalTracks, - int? discNumber, - int? totalDiscs, - String? releaseDate, - String? deezerId, - String? composer, - }) { - final normalizedIsrc = normalizeOptionalString(resolvedIsrc); - final normalizedComposer = normalizeOptionalString(composer); - - return Track( - id: track.id, - name: track.name, - artistName: track.artistName, - albumName: track.albumName, - albumArtist: track.albumArtist, - artistId: track.artistId, - albumId: track.albumId, - coverUrl: normalizeCoverReference(track.coverUrl), - duration: track.duration, - isrc: (normalizedIsrc != null && _isValidISRC(normalizedIsrc)) - ? normalizedIsrc - : track.isrc, - trackNumber: (track.trackNumber != null && track.trackNumber! > 0) - ? track.trackNumber - : trackNumber, - discNumber: (track.discNumber != null && track.discNumber! > 0) - ? track.discNumber - : discNumber, - totalDiscs: (track.totalDiscs != null && track.totalDiscs! > 0) - ? track.totalDiscs - : totalDiscs, - releaseDate: track.releaseDate ?? normalizeOptionalString(releaseDate), - deezerId: deezerId ?? track.deezerId, - availability: track.availability, - source: track.source, - albumType: track.albumType, - totalTracks: (track.totalTracks != null && track.totalTracks! > 0) - ? track.totalTracks - : totalTracks, - composer: (track.composer != null && track.composer!.isNotEmpty) - ? track.composer - : normalizedComposer, - itemType: track.itemType, - ); - } - - Future<_DeezerLookupPreparation> _resolveProviderTrackForDeezerLookup( - Track track, - String itemId, - ) async { - try { - final colonIdx = track.id.indexOf(':'); - final provider = track.id.substring(0, colonIdx); - final effectiveProvider = resolveEffectiveMetadataProvider( - provider, - ref.read(extensionProvider), - ); - final providerTrackId = track.id.substring(colonIdx + 1); - - _log.d( - 'No ISRC, fetching from ${effectiveProvider.isEmpty ? provider : effectiveProvider} API: $providerTrackId', - ); - final providerData = await PlatformBridge.getProviderMetadata( - effectiveProvider.isEmpty ? provider : effectiveProvider, - 'track', - providerTrackId, - ); - - final trackData = providerData['track'] as Map?; - if (trackData == null) { - return _DeezerLookupPreparation( - track: track, - deezerTrackId: _extractKnownDeezerTrackId(track), - ); - } - - final resolvedIsrc = normalizeOptionalString( - trackData['isrc'] as String?, - ); - if (resolvedIsrc == null || !_isValidISRC(resolvedIsrc)) { - return _DeezerLookupPreparation( - track: track, - deezerTrackId: _extractKnownDeezerTrackId(track), - ); - } - - _log.d( - 'Resolved ISRC from ${effectiveProvider.isEmpty ? provider : effectiveProvider}: $resolvedIsrc', - ); - - final updatedTrack = _copyTrackWithResolvedMetadata( - track, - resolvedIsrc: resolvedIsrc, - releaseDate: trackData['release_date'] as String?, - trackNumber: trackData['track_number'] as int?, - totalTracks: trackData['total_tracks'] as int?, - discNumber: trackData['disc_number'] as int?, - totalDiscs: trackData['total_discs'] as int?, - composer: trackData['composer'] as String?, - ); - final deezerTrackId = await _searchDeezerTrackIdByIsrc( - resolvedIsrc, - lookupContext: - '${effectiveProvider.isEmpty ? provider : effectiveProvider} ISRC', - itemId: itemId, - ); - - return _DeezerLookupPreparation( - track: deezerTrackId == null - ? updatedTrack - : _copyTrackWithResolvedMetadata( - updatedTrack, - deezerId: deezerTrackId, - ), - deezerTrackId: - deezerTrackId ?? _extractKnownDeezerTrackId(updatedTrack), - ); - } catch (e) { - _log.w('Failed to resolve ISRC from provider: $e'); - return _DeezerLookupPreparation( - track: track, - deezerTrackId: _extractKnownDeezerTrackId(track), - ); - } - } - - Future<_DeezerLookupPreparation> _resolveSpotifyTrackViaDeezer( - Track track, - ) async { - try { - var spotifyId = track.id; - if (spotifyId.startsWith('spotify:track:')) { - spotifyId = spotifyId.split(':').last; - } - _log.d('No Deezer ID, converting from Spotify via SongLink: $spotifyId'); - - final deezerData = await PlatformBridge.convertSpotifyToDeezer( - 'track', - spotifyId, - ); - final trackData = deezerData['track']; - - String? deezerTrackId; - if (trackData is Map) { - final rawId = trackData['spotify_id'] as String?; - if (rawId != null && rawId.startsWith('deezer:')) { - deezerTrackId = rawId.split(':')[1]; - _log.d('Found Deezer track ID via SongLink: $deezerTrackId'); - } else if (deezerData['id'] != null) { - deezerTrackId = deezerData['id'].toString(); - _log.d('Found Deezer track ID via SongLink (legacy): $deezerTrackId'); - } - - final deezerIsrc = normalizeOptionalString( - trackData['isrc'] as String?, - ); - final needsEnrich = - (track.releaseDate == null && - normalizeOptionalString(trackData['release_date'] as String?) != - null) || - (track.isrc == null && deezerIsrc != null) || - (!_isValidISRC(track.isrc ?? '') && deezerIsrc != null) || - ((track.trackNumber == null || track.trackNumber! <= 0) && - (trackData['track_number'] as int?) != null && - (trackData['track_number'] as int?)! > 0) || - ((track.totalTracks == null || track.totalTracks! <= 0) && - (trackData['total_tracks'] as int?) != null && - (trackData['total_tracks'] as int?)! > 0) || - ((track.discNumber == null || track.discNumber! <= 0) && - (trackData['disc_number'] as int?) != null && - (trackData['disc_number'] as int?)! > 0) || - ((track.totalDiscs == null || track.totalDiscs! <= 0) && - (trackData['total_discs'] as int?) != null && - (trackData['total_discs'] as int?)! > 0) || - ((track.composer == null || track.composer!.isEmpty) && - normalizeOptionalString(trackData['composer'] as String?) != - null) || - deezerTrackId != null; - - final updatedTrack = needsEnrich - ? _copyTrackWithResolvedMetadata( - track, - resolvedIsrc: deezerIsrc, - releaseDate: trackData['release_date'] as String?, - trackNumber: trackData['track_number'] as int?, - totalTracks: trackData['total_tracks'] as int?, - discNumber: trackData['disc_number'] as int?, - totalDiscs: trackData['total_discs'] as int?, - composer: trackData['composer'] as String?, - deezerId: deezerTrackId, - ) - : track; - - if (needsEnrich) { - _log.d( - 'Enriched track from Deezer - date: ${updatedTrack.releaseDate}, ISRC: ${updatedTrack.isrc}, track: ${updatedTrack.trackNumber}, disc: ${updatedTrack.discNumber}', - ); - } - - return _DeezerLookupPreparation( - track: updatedTrack, - deezerTrackId: - deezerTrackId ?? _extractKnownDeezerTrackId(updatedTrack), - ); - } - - if (deezerData['id'] != null) { - deezerTrackId = deezerData['id'].toString(); - _log.d('Found Deezer track ID via SongLink (flat): $deezerTrackId'); - return _DeezerLookupPreparation( - track: _copyTrackWithResolvedMetadata(track, deezerId: deezerTrackId), - deezerTrackId: deezerTrackId, - ); - } - } catch (e) { - _log.w('Failed to convert Spotify to Deezer via SongLink: $e'); - } - - return _DeezerLookupPreparation( - track: track, - deezerTrackId: _extractKnownDeezerTrackId(track), - ); - } - - Future<_DeezerExtendedMetadataFields> _loadDeezerExtendedMetadata( - String deezerTrackId, - ) async { - try { - final extendedMetadata = await PlatformBridge.getDeezerExtendedMetadata( - deezerTrackId, - ); - if (extendedMetadata == null) { - return const _DeezerExtendedMetadataFields(); - } - - final metadata = _DeezerExtendedMetadataFields( - genre: normalizeOptionalString(extendedMetadata['genre']), - label: normalizeOptionalString(extendedMetadata['label']), - copyright: normalizeOptionalString(extendedMetadata['copyright']), - ); - if (metadata.hasAnyValue) { - _log.d( - 'Extended metadata - Genre: ${metadata.genre}, Label: ${metadata.label}, Copyright: ${metadata.copyright}', - ); - } - return metadata; - } catch (e) { - _log.w('Failed to fetch extended metadata from Deezer: $e'); - return const _DeezerExtendedMetadataFields(); - } - } - - /// Returns a known Deezer track ID for [track], or resolves one via ISRC - /// search. Does not attempt provider-based resolution (see - /// [_resolveDeezerIdViaProviderIfNeeded]). - Future _resolveDeezerIdFromKnownOrIsrc( - Track track, - String itemId, { - required String lookupContext, - }) async { - final known = _extractKnownDeezerTrackId(track); - if (known != null) return known; - if (track.isrc != null && - track.isrc!.isNotEmpty && - _isValidISRC(track.isrc!)) { - return _searchDeezerTrackIdByIsrc( - track.isrc, - lookupContext: lookupContext, - itemId: itemId, - ); - } - return null; - } - - /// For tidal:/qobuz: tracks without a usable ISRC, resolves the ISRC (and - /// Deezer ID) from the provider API directly. Returns [track] and - /// [deezerTrackId] unchanged when resolution isn't needed or fails. - Future<_DeezerLookupPreparation> _resolveDeezerIdViaProviderIfNeeded( - Track track, - String? deezerTrackId, - String itemId, - ) async { - if (deezerTrackId == null && - (track.isrc == null || - track.isrc!.isEmpty || - !_isValidISRC(track.isrc!)) && - (track.id.startsWith('tidal:') || track.id.startsWith('qobuz:'))) { - final providerLookup = await _resolveProviderTrackForDeezerLookup( - track, - itemId, - ); - return _DeezerLookupPreparation( - track: providerLookup.track, - deezerTrackId: deezerTrackId ?? providerLookup.deezerTrackId, - ); - } - return _DeezerLookupPreparation(track: track, deezerTrackId: deezerTrackId); - } - - /// Loads extended Deezer metadata (genre/label/copyright) for - /// [deezerTrackId], or returns null when no ID is available. - Future<_DeezerExtendedMetadataFields?> _loadExtendedMetadataForDeezerId( - String? deezerTrackId, - ) { - if (deezerTrackId == null || deezerTrackId.isEmpty) { - return Future.value(null); - } - return _loadDeezerExtendedMetadata(deezerTrackId); - } - /// Builds the backend [DownloadRequestPayload] shared by the native-worker /// request builder and the inline single-item download path. Fields whose /// source value legitimately differs between the two callers (SAF staging, @@ -2187,43 +1237,6 @@ class DownloadQueueNotifier extends Notifier { } } - int _validPlaylistPosition(DownloadItem item) { - final position = item.playlistPosition; - if (position == null || position <= 0) return 0; - return position; - } - - String _filenameFormatForItem(DownloadItem item, String baseFormat) { - if (!item.fromBatch) { - return baseFormat; - } - final trimmed = baseFormat.trim(); - if (trimmed.isEmpty) { - return baseFormat; - } - if (_batchUniqueFilenameTokenPattern.hasMatch(trimmed)) { - return baseFormat; - } - return '$trimmed - {track:02} - {title}'; - } - - Map _filenameMetadataForTrack( - Track track, { - int playlistPosition = 0, - }) { - return { - 'title': track.name, - 'artist': track.artistName, - 'album': track.albumName, - 'track': track.trackNumber ?? 0, - 'disc': track.discNumber ?? 0, - 'year': _extractYear(track.releaseDate) ?? '', - 'date': track.releaseDate ?? '', - 'playlist_position': playlistPosition, - 'playlistPosition': playlistPosition, - }; - } - void updateItemStatus( String id, DownloadStatus status, { @@ -2784,999 +1797,6 @@ class DownloadQueueNotifier extends Notifier { return null; } - String _albumRgKey(Track track) { - if (track.albumId != null && track.albumId!.isNotEmpty) { - return 'id:${track.albumId}'; - } - return 'name:${track.albumName}|${track.albumArtist ?? ''}'; - } - - /// Purge a track's stale ReplayGain accumulator entry, dropping the whole - /// album accumulator once it becomes empty. - void _purgeAlbumRgEntry(Track track) { - final key = _albumRgKey(track); - final accumulator = _albumRgData[key]; - if (accumulator == null) return; - accumulator.entries.removeWhere((e) => e.trackId == track.id); - if (accumulator.entries.isEmpty) { - _albumRgData.remove(key); - } - } - - /// Store a track's ReplayGain scan result for later album gain computation. - void _storeTrackReplayGainForAlbum( - Track track, - String filePath, - ReplayGainResult rg, - ) { - final key = _albumRgKey(track); - _albumRgData.putIfAbsent(key, () => _AlbumRgAccumulator()); - // Remove any stale entry for this track (e.g. from a previous failed - // attempt that was retried). Without this, the same track can accumulate - // multiple entries and bias the album loudness calculation. - _albumRgData[key]!.entries.removeWhere((e) => e.trackId == track.id); - _albumRgData[key]!.entries.add( - _AlbumRgTrackEntry( - filePath: filePath, - trackId: track.id, - integratedLufs: rg.integratedLufs, - truePeakLinear: rg.truePeakLinear, - durationSecs: track.duration.toDouble(), - ), - ); - } - - /// Replace the temp path stored in the accumulator with the final output - /// path. For SAF downloads the embed happens on a temp file which is later - /// deleted — this ensures the album-gain writer targets the real file. - void _updateAlbumRgFilePath(Track track, String finalPath) { - final key = _albumRgKey(track); - final accumulator = _albumRgData[key]; - if (accumulator == null) return; - for (final entry in accumulator.entries) { - if (entry.trackId == track.id) { - entry.filePath = finalPath; - break; - } - } - } - - /// After a track completes, check whether all tracks from the same album - /// in the current queue are done. If so, compute album gain and write it - /// to every track's file. - Future _checkAndWriteAlbumReplayGain(Track track) async { - final settings = ref.read(settingsProvider); - if (!settings.embedReplayGain) return; - - final key = _albumRgKey(track); - final accumulator = _albumRgData[key]; - if (accumulator == null || accumulator.entries.isEmpty) return; - - // Find queue items for this album that are STILL in the queue. - // Completed tracks may have already been removed by removeItem(), so - // their absence means they finished successfully (not that they're - // still pending). - final albumItemsInQueue = state.items - .where((item) => _albumRgKey(item.track) == key) - .toList(); - - final pending = albumItemsInQueue.where( - (item) => - item.status == DownloadStatus.queued || - item.status == DownloadStatus.downloading || - item.status == DownloadStatus.finalizing, - ); - if (pending.isNotEmpty) return; - - // If any item is failed/skipped, the user might retry it later. - // Don't finalize album RG with partial data — wait until all album - // tracks are either completed (and possibly removed) or retried. - final retryable = albumItemsInQueue.where( - (item) => - item.status == DownloadStatus.failed || - item.status == DownloadStatus.skipped, - ); - if (retryable.isNotEmpty) return; - - // The accumulator entries represent successfully scanned tracks. Entries - // are only added after a successful ReplayGain scan, removed on retry or - // when a non-completed item is removed from the queue, so every entry - // here corresponds to a track that completed (or is about to complete) - // its download. - final validEntries = accumulator.entries.toList(); - - // Single-track albums: album gain == track gain, no extra write needed. - if (validEntries.length <= 1) { - _albumRgData.remove(key); - return; - } - - // Compute album gain using duration-weighted power-mean of LUFS values. - // album_loudness = 10 * log10( Σ(10^(Li/10) * di) / Σ(di) ) - // This weights longer tracks more, matching "whole program" loudness. - double sumWeightedPower = 0; - double sumDuration = 0; - double maxPeak = 0; - for (final entry in validEntries) { - final weight = entry.durationSecs > 0 ? entry.durationSecs : 1.0; - sumWeightedPower += pow(10, entry.integratedLufs / 10.0) * weight; - sumDuration += weight; - if (entry.truePeakLinear > maxPeak) { - maxPeak = entry.truePeakLinear; - } - } - final albumLufs = 10.0 * _log10(sumWeightedPower / sumDuration); - const replayGainReferenceLufs = -18.0; - final albumGainDb = replayGainReferenceLufs - albumLufs; - - final albumGain = - '${albumGainDb >= 0 ? "+" : ""}${albumGainDb.toStringAsFixed(2)} dB'; - final albumPeak = maxPeak.toStringAsFixed(6); - - _log.i( - 'Album ReplayGain for "$key": gain=$albumGain, peak=$albumPeak (${validEntries.length} tracks, album LUFS=${albumLufs.toStringAsFixed(1)})', - ); - - for (final entry in validEntries) { - try { - await _writeAlbumReplayGain(entry.filePath, albumGain, albumPeak); - } catch (e) { - _log.w('Failed to write album ReplayGain to ${entry.filePath}: $e'); - } - } - - _albumRgData.remove(key); - } - - /// Write album ReplayGain tags to a single file. - Future _writeAlbumReplayGain( - String filePath, - String albumGain, - String albumPeak, - ) async { - final lower = filePath.toLowerCase(); - if (lower.endsWith('.flac') || - lower.endsWith('.ape') || - lower.endsWith('.wv') || - lower.endsWith('.mpc')) { - // Native writer — only touches the provided fields, preserves the rest. - await PlatformBridge.editFileMetadata(filePath, { - 'replaygain_album_gain': albumGain, - 'replaygain_album_peak': albumPeak, - }); - } else if (isContentUri(filePath)) { - // SAF content:// URI — FFmpeg can read it but can't write back directly. - // Get the temp output from FFmpeg, then copy it to the SAF URI. - String? tempPath; - final ok = await FFmpegService.writeAlbumReplayGainTags( - filePath, - albumGain, - albumPeak, - returnTempPath: true, - onTempReady: (path) => tempPath = path, - ); - if (ok && tempPath != null) { - try { - final safOk = await PlatformBridge.writeTempToSaf( - tempPath!, - filePath, - ); - if (!safOk) { - _log.w('SAF write-back failed for album RG: $filePath'); - } - } finally { - try { - final tmp = File(tempPath!); - if (await tmp.exists()) await tmp.delete(); - } catch (_) {} - } - } else { - _log.w('FFmpeg album ReplayGain write failed for SAF: $filePath'); - } - } else { - // Local MP3 / Opus — use FFmpeg copy-with-metadata approach. - final ok = await FFmpegService.writeAlbumReplayGainTags( - filePath, - albumGain, - albumPeak, - ); - if (!ok) { - _log.w('FFmpeg album ReplayGain write failed for: $filePath'); - } - } - } - - /// Re-check album ReplayGain for all albums that still have accumulator data. - /// Called after removing/dismissing a failed or skipped item, which may - /// unblock an album that was waiting for retryable items to be resolved. - void _retriggerAlbumRgChecks() { - if (_albumRgData.isEmpty) return; - final settings = ref.read(settingsProvider); - if (!settings.embedReplayGain) return; - - // Snapshot the keys — _checkAndWriteAlbumReplayGain may mutate the map. - final keys = _albumRgData.keys.toList(); - for (final key in keys) { - final acc = _albumRgData[key]; - if (acc == null || acc.entries.isEmpty) continue; - // Use the first entry's trackId to find a representative track. - // _checkAndWriteAlbumReplayGain only needs it for _albumRgKey(), so any - // track from the album works. - final albumItems = state.items - .where((item) => _albumRgKey(item.track) == key) - .toList(); - // If there are no items left in queue for this album but we have - // accumulator data, all items were completed and removed. Use a - // synthetic call — we need a Track to call the check, but the items - // are gone. For this case, directly check conditions inline. - if (albumItems.isEmpty) { - // All items removed → no pending/retryable. Trigger computation. - if (acc.entries.length > 1) { - _computeAndWriteAlbumRg(key, acc); - } - continue; - } - final representative = albumItems.first; - _checkAndWriteAlbumReplayGain(representative.track); - } - } - - /// Compute album RG and write it — extracted from _checkAndWriteAlbumReplayGain - /// for use when no queue items remain (all completed and removed). - Future _computeAndWriteAlbumRg( - String key, - _AlbumRgAccumulator accumulator, - ) async { - final validEntries = accumulator.entries.toList(); - if (validEntries.length <= 1) { - _albumRgData.remove(key); - return; - } - - double sumWeightedPower = 0; - double sumDuration = 0; - double maxPeak = 0; - for (final entry in validEntries) { - final weight = entry.durationSecs > 0 ? entry.durationSecs : 1.0; - sumWeightedPower += pow(10, entry.integratedLufs / 10.0) * weight; - sumDuration += weight; - if (entry.truePeakLinear > maxPeak) { - maxPeak = entry.truePeakLinear; - } - } - final albumLufs = 10.0 * _log10(sumWeightedPower / sumDuration); - const replayGainReferenceLufs = -18.0; - final albumGainDb = replayGainReferenceLufs - albumLufs; - - final albumGain = - '${albumGainDb >= 0 ? "+" : ""}${albumGainDb.toStringAsFixed(2)} dB'; - final albumPeak = maxPeak.toStringAsFixed(6); - - _log.i( - 'Album ReplayGain for "$key": gain=$albumGain, peak=$albumPeak (${validEntries.length} tracks, album LUFS=${albumLufs.toStringAsFixed(1)})', - ); - - for (final entry in validEntries) { - try { - await _writeAlbumReplayGain(entry.filePath, albumGain, albumPeak); - } catch (e) { - _log.w('Failed to write album ReplayGain to ${entry.filePath}: $e'); - } - } - - _albumRgData.remove(key); - } - - /// Deezer CDN cover size pattern: /WxH-0-0-0-0.jpg - static final _deezerSizeRegex = RegExp(r'/(\d+)x(\d+)-\d+-\d+-\d+-\d+\.jpg$'); - - String _upgradeToMaxQualityCover(String coverUrl) { - const spotifySize300 = 'ab67616d00001e02'; - const spotifySize640 = 'ab67616d0000b273'; - const spotifySizeMax = 'ab67616d000082c1'; - - var result = coverUrl; - if (result.contains(spotifySize300)) { - result = result.replaceFirst(spotifySize300, spotifySize640); - } - if (result.contains(spotifySize640)) { - result = result.replaceFirst(spotifySize640, spotifySizeMax); - } - - if (result.contains('cdn-images.dzcdn.net')) { - final upgraded = result.replaceFirst( - _deezerSizeRegex, - '/1800x1800-000000-80-0-0.jpg', - ); - if (upgraded != result) { - _log.d('Cover URL upgraded (Deezer): 1800x1800'); - result = upgraded; - } - } - - // Tidal CDN upgrade (1280x1280 → origin) - if (result.contains('resources.tidal.com') && - result.contains('/1280x1280.jpg')) { - result = result.replaceFirst('/1280x1280.jpg', '/origin.jpg'); - _log.d('Cover URL upgraded (Tidal): origin'); - } - - return result; - } - - bool _isUsableIndex(int? number, int? total) { - if (number == null || number <= 0) return false; - return total == null || total <= 0 || number <= total; - } - - int? _resolvePositiveMetadataInt(int? sourceValue, int? backendValue) { - if (sourceValue != null && sourceValue > 0) return sourceValue; - return backendValue; - } - - int? _resolveMetadataIndex({ - required int? sourceValue, - required int? backendValue, - required int? total, - }) { - if (_isUsableIndex(sourceValue, total)) return sourceValue; - if (_isUsableIndex(backendValue, total)) return backendValue; - return sourceValue != null && sourceValue > 0 ? sourceValue : backendValue; - } - - String? _resolveMetadataText(String? sourceValue, String? backendValue) { - return normalizeOptionalString(sourceValue) ?? - normalizeOptionalString(backendValue); - } - - Track _buildTrackForMetadataEmbedding( - Track baseTrack, - Map backendResult, - String? resolvedAlbumArtist, - ) { - final backendTrackNum = readPositiveInt(backendResult['track_number']); - final backendDiscNum = readPositiveInt(backendResult['disc_number']); - final backendTotalTracks = readPositiveInt(backendResult['total_tracks']); - final backendTotalDiscs = readPositiveInt(backendResult['total_discs']); - final backendYear = normalizeOptionalString( - backendResult['release_date'] as String?, - ); - final backendAlbum = normalizeOptionalString( - backendResult['album'] as String?, - ); - final backendIsrc = normalizeOptionalString( - backendResult['isrc'] as String?, - ); - final backendCoverUrl = normalizeCoverReference( - backendResult['cover_url']?.toString(), - ); - final baseCoverUrl = normalizeCoverReference(baseTrack.coverUrl); - final resolvedCoverUrl = baseCoverUrl ?? backendCoverUrl; - final backendAlbumArtist = normalizeOptionalString( - backendResult['album_artist'] as String?, - ); - final backendComposer = normalizeOptionalString( - backendResult['composer']?.toString(), - ); - final sourceAlbumName = normalizeOptionalString(baseTrack.albumName); - final sourceAlbumArtist = normalizeOptionalString(baseTrack.albumArtist); - final sourceIsrc = normalizeOptionalString(baseTrack.isrc); - final sourceReleaseDate = normalizeOptionalString(baseTrack.releaseDate); - final sourceComposer = normalizeOptionalString(baseTrack.composer); - final resolvedTotalTracks = _resolvePositiveMetadataInt( - baseTrack.totalTracks, - backendTotalTracks, - ); - final resolvedTotalDiscs = _resolvePositiveMetadataInt( - baseTrack.totalDiscs, - backendTotalDiscs, - ); - final resolvedTrackNumber = _resolveMetadataIndex( - sourceValue: baseTrack.trackNumber, - backendValue: backendTrackNum, - total: resolvedTotalTracks, - ); - final resolvedDiscNumber = _resolveMetadataIndex( - sourceValue: baseTrack.discNumber, - backendValue: backendDiscNum, - total: resolvedTotalDiscs, - ); - - final hasOverrides = - resolvedTrackNumber != baseTrack.trackNumber || - resolvedDiscNumber != baseTrack.discNumber || - resolvedTotalTracks != baseTrack.totalTracks || - resolvedTotalDiscs != baseTrack.totalDiscs || - resolvedAlbumArtist != sourceAlbumArtist || - (sourceReleaseDate == null && backendYear != null) || - (sourceAlbumName == null && backendAlbum != null) || - (sourceIsrc == null && backendIsrc != null) || - (baseCoverUrl == null && backendCoverUrl != null) || - (sourceAlbumArtist == null && - resolvedAlbumArtist == null && - backendAlbumArtist != null) || - (sourceComposer == null && backendComposer != null); - - if (!hasOverrides) { - return baseTrack; - } - - return Track( - id: baseTrack.id, - name: baseTrack.name, - artistName: baseTrack.artistName, - albumName: sourceAlbumName ?? backendAlbum ?? baseTrack.albumName, - albumArtist: - resolvedAlbumArtist ?? sourceAlbumArtist ?? backendAlbumArtist, - artistId: baseTrack.artistId, - albumId: baseTrack.albumId, - coverUrl: resolvedCoverUrl, - duration: baseTrack.duration, - isrc: sourceIsrc ?? backendIsrc, - trackNumber: resolvedTrackNumber, - discNumber: resolvedDiscNumber, - totalDiscs: resolvedTotalDiscs, - releaseDate: sourceReleaseDate ?? backendYear, - deezerId: baseTrack.deezerId, - availability: baseTrack.availability, - albumType: baseTrack.albumType, - totalTracks: resolvedTotalTracks, - composer: sourceComposer ?? backendComposer, - source: baseTrack.source, - ); - } - - /// Builds the [DownloadHistoryItem] shared by the native-worker and inline - /// completion paths. Fields whose source/derivation legitimately differs - /// between the two callers (SAF location, probed vs. raw audio metadata, - /// genre/label/copyright fallback chain) are left as parameters. - DownloadHistoryItem _historyItemFromResult({ - required DownloadItem item, - required Track trackToDownload, - required Map result, - required String filePath, - required String quality, - required bool useSaf, - String? downloadTreeUri, - String? safRelativeDir, - String? safFileName, - int? bitDepth, - int? sampleRate, - int? bitrate, - String? format, - String? genre, - String? label, - String? copyright, - }) { - final backendTitle = result['title'] as String?; - final backendArtist = result['artist'] as String?; - final backendAlbum = result['album'] as String?; - final backendYear = result['release_date'] as String?; - final backendTrackNum = readPositiveInt(result['track_number']); - final backendDiscNum = readPositiveInt(result['disc_number']); - final backendTotalTracks = readPositiveInt(result['total_tracks']); - final backendTotalDiscs = readPositiveInt(result['total_discs']); - final backendISRC = result['isrc'] as String?; - final backendComposer = result['composer'] as String?; - - final historyTotalTracks = _resolvePositiveMetadataInt( - trackToDownload.totalTracks, - backendTotalTracks, - ); - final historyTotalDiscs = _resolvePositiveMetadataInt( - trackToDownload.totalDiscs, - backendTotalDiscs, - ); - final historyTrackNumber = _resolveMetadataIndex( - sourceValue: trackToDownload.trackNumber, - backendValue: backendTrackNum, - total: historyTotalTracks, - ); - final historyDiscNumber = _resolveMetadataIndex( - sourceValue: trackToDownload.discNumber, - backendValue: backendDiscNum, - total: historyTotalDiscs, - ); - final historyTitle = - _resolveMetadataText(trackToDownload.name, backendTitle) ?? - item.track.name; - final historyArtist = - _resolveMetadataText(trackToDownload.artistName, backendArtist) ?? - item.track.artistName; - final historyAlbum = - _resolveMetadataText(trackToDownload.albumName, backendAlbum) ?? - item.track.albumName; - final historyIsrc = _resolveMetadataText(trackToDownload.isrc, backendISRC); - final historyReleaseDate = _resolveMetadataText( - trackToDownload.releaseDate, - backendYear, - ); - final historyComposer = _resolveMetadataText( - trackToDownload.composer, - backendComposer, - ); - - return DownloadHistoryItem( - id: item.id, - trackName: historyTitle, - artistName: historyArtist, - albumName: historyAlbum, - albumArtist: normalizeOptionalString(trackToDownload.albumArtist), - coverUrl: normalizeCoverReference(trackToDownload.coverUrl), - filePath: filePath, - storageMode: useSaf ? 'saf' : 'app', - downloadTreeUri: useSaf ? downloadTreeUri : null, - safRelativeDir: useSaf ? safRelativeDir : null, - safFileName: useSaf ? safFileName : null, - safRepaired: false, - service: result['service'] as String? ?? item.service, - downloadedAt: DateTime.now(), - isrc: historyIsrc, - spotifyId: trackToDownload.id, - trackNumber: historyTrackNumber, - totalTracks: historyTotalTracks, - discNumber: historyDiscNumber, - totalDiscs: historyTotalDiscs, - duration: trackToDownload.duration, - releaseDate: historyReleaseDate, - quality: quality, - bitDepth: bitDepth, - sampleRate: sampleRate, - bitrate: bitrate, - format: format, - genre: genre, - composer: historyComposer, - label: label, - copyright: copyright, - ); - } - - /// Unified metadata, cover, lyrics, and ReplayGain embedding for all formats. - /// - /// [format] must be one of `'flac'`, `'m4a'`, `'mp3'`, or `'opus'`. - /// [writeExternalLrc] only applies to FLAC and M4A (non-SAF paths handle LRC separately). - Future _embedMetadataToFile( - String filePath, - Track track, { - required String format, - String? genre, - String? label, - String? copyright, - String? downloadService, - bool writeExternalLrc = true, - }) async { - final settings = ref.read(settingsProvider); - if (!settings.embedMetadata) { - _log.d( - 'Metadata embedding disabled, skipping $format metadata/cover embed', - ); - return; - } - - final isFlac = format == 'flac'; - final isM4a = format == 'm4a'; - final isMp3 = format == 'mp3'; - - String? coverPath; - var coverUrl = normalizeRemoteHttpUrl(track.coverUrl); - if (coverUrl != null && coverUrl.isNotEmpty) { - try { - if (settings.maxQualityCover) { - coverUrl = _upgradeToMaxQualityCover(coverUrl); - _log.d('Cover URL upgraded to max quality for $format: $coverUrl'); - } - - final tempDir = await getTemporaryDirectory(); - final uniqueId = - '${DateTime.now().millisecondsSinceEpoch}_${Random().nextInt(10000)}'; - coverPath = '${tempDir.path}/cover_${format}_$uniqueId.jpg'; - - final httpClient = HttpClient(); - final request = await httpClient.getUrl(Uri.parse(coverUrl)); - final response = await request.close(); - if (response.statusCode == 200) { - final file = File(coverPath); - final sink = file.openWrite(); - await response.pipe(sink); - await sink.close(); - _log.d('Cover downloaded for $format: $coverPath'); - } else { - _log.w( - 'Failed to download cover for $format: HTTP ${response.statusCode}', - ); - coverPath = null; - } - httpClient.close(); - } catch (e) { - _log.e('Failed to download cover for $format: $e'); - coverPath = null; - } - } - - try { - final metadata = { - 'TITLE': track.name, - 'ARTIST': track.artistName, - 'ALBUM': track.albumName, - }; - String formatIndexTag(int number, int? total) { - if (total != null && total > 0) { - return '$number/$total'; - } - return number.toString(); - } - - final albumArtist = _resolveAlbumArtistForMetadata(track, settings); - if (albumArtist != null) { - metadata['ALBUMARTIST'] = albumArtist; - } - - if (track.trackNumber != null && track.trackNumber! > 0) { - final trackTag = formatIndexTag(track.trackNumber!, track.totalTracks); - metadata['TRACKNUMBER'] = trackTag; - if (isFlac || isMp3) metadata['TRACK'] = trackTag; - } - if (track.discNumber != null && track.discNumber! > 0) { - final discTag = formatIndexTag(track.discNumber!, track.totalDiscs); - metadata['DISCNUMBER'] = discTag; - if (isFlac || isMp3) metadata['DISC'] = discTag; - } - if (track.releaseDate != null) { - metadata['DATE'] = track.releaseDate!; - if (isFlac || isMp3) { - metadata['YEAR'] = track.releaseDate!.split('-').first; - } - } - if (track.isrc != null) metadata['ISRC'] = track.isrc!; - if (genre != null && genre.isNotEmpty) metadata['GENRE'] = genre; - if (label != null && label.isNotEmpty) metadata['ORGANIZATION'] = label; - if (copyright != null && copyright.isNotEmpty) { - metadata['COPYRIGHT'] = copyright; - } - if (track.composer != null && track.composer!.isNotEmpty) { - metadata['COMPOSER'] = track.composer!; - } - - final lyricsMode = settings.lyricsMode; - final extensionState = ref.read(extensionProvider); - final skipLyrics = _shouldSkipLyrics( - extensionState, - track.source, - downloadService, - ); - final shouldEmbedLyrics = - settings.embedLyrics && - !skipLyrics && - (lyricsMode == 'embed' || lyricsMode == 'both'); - final shouldSaveExternalLyrics = - settings.embedLyrics && - !skipLyrics && - (lyricsMode == 'external' || lyricsMode == 'both'); - String? lrcContent; - - if (shouldEmbedLyrics || shouldSaveExternalLyrics) { - try { - final fetchedLrc = await PlatformBridge.getLyricsLRC( - track.id, - track.name, - track.artistName, - filePath: '', - durationMs: track.duration * 1000, - ); - if (fetchedLrc.isNotEmpty && fetchedLrc != '[instrumental:true]') { - lrcContent = fetchedLrc; - _log.d('Lyrics fetched for $format (${fetchedLrc.length} chars)'); - } else if (fetchedLrc == '[instrumental:true]') { - _log.d('Track is instrumental, skipping lyrics handling'); - } - } catch (e) { - _log.w('Failed to fetch lyrics for $format: $e'); - } - } - - if (shouldEmbedLyrics && lrcContent != null) { - metadata['LYRICS'] = lrcContent; - if (isFlac || isMp3) metadata['UNSYNCEDLYRICS'] = lrcContent; - } else if ((isFlac || isM4a) && !shouldEmbedLyrics) { - metadata['LYRICS'] = ''; - if (isFlac) { - metadata['UNSYNCEDLYRICS'] = ''; - } - } - - if (writeExternalLrc && shouldSaveExternalLyrics && lrcContent != null) { - try { - final lrcPath = filePath.replaceAll(RegExp(r'\.[^.]+$'), '.lrc'); - final safeLrcPath = lrcPath == filePath ? '$filePath.lrc' : lrcPath; - await File(safeLrcPath).writeAsString(lrcContent); - _log.d('External LRC file saved: $safeLrcPath'); - } catch (e) { - _log.w('Failed to save external LRC file for $format: $e'); - } - } - - ReplayGainResult? scannedReplayGain; - - if (settings.embedReplayGain && !isFlac) { - try { - final rgResult = await FFmpegService.scanReplayGain(filePath); - if (rgResult != null) { - scannedReplayGain = rgResult; - metadata['REPLAYGAIN_TRACK_GAIN'] = rgResult.trackGain; - metadata['REPLAYGAIN_TRACK_PEAK'] = rgResult.trackPeak; - if (format == 'opus') { - final r128 = FFmpegService.replayGainDbToR128(rgResult.trackGain); - if (r128 != null) metadata['R128_TRACK_GAIN'] = r128; - } - _log.d( - 'ReplayGain for $format: gain=${rgResult.trackGain}, peak=${rgResult.trackPeak}', - ); - _storeTrackReplayGainForAlbum(track, filePath, rgResult); - } - } catch (e) { - _log.w('Failed to scan ReplayGain for $format: $e'); - } - } - - final validCover = coverPath != null && await File(coverPath).exists() - ? coverPath - : null; - - // AC-4 is passthrough-only: the FFmpeg mov muxer would re-wrap it as - // QuickTime and break the ISO MP4 from decryption. writeAC4Metadata is a - // no-op for non-AC-4 files, so other m4a downloads fall through to FFmpeg. - if (isM4a) { - try { - final ac4Meta = { - 'title': track.name, - 'artist': track.artistName, - 'album': track.albumName, - 'albumArtist': ?albumArtist, - if (track.releaseDate != null) 'date': track.releaseDate!, - if (genre != null && genre.isNotEmpty) 'genre': genre, - if (track.composer != null && track.composer!.isNotEmpty) - 'composer': track.composer!, - if (track.trackNumber != null && track.trackNumber! > 0) - 'trackNumber': track.trackNumber!.toString(), - if (track.totalTracks != null && track.totalTracks! > 0) - 'totalTracks': track.totalTracks!.toString(), - if (track.discNumber != null && track.discNumber! > 0) - 'discNumber': track.discNumber!.toString(), - if (track.totalDiscs != null && track.totalDiscs! > 0) - 'totalDiscs': track.totalDiscs!.toString(), - if (track.isrc != null) 'isrc': track.isrc!, - if (label != null && label.isNotEmpty) 'label': label, - if (copyright != null && copyright.isNotEmpty) - 'copyright': copyright, - if (shouldEmbedLyrics) 'lyrics': ?lrcContent, - }; - final ac4Result = await PlatformBridge.writeAC4Metadata( - filePath, - ac4Meta, - validCover ?? '', - ); - if (ac4Result['handled'] == true) { - _log.d('AC-4 metadata embedded natively for $format'); - return; - } - } catch (e) { - _log.w('AC-4 metadata path failed, falling back to FFmpeg: $e'); - } - } - - String? ffmpegResult; - if (isFlac) { - ffmpegResult = await FFmpegService.embedMetadata( - flacPath: filePath, - coverPath: validCover, - metadata: metadata, - artistTagMode: settings.artistTagMode, - ); - } else if (isM4a) { - ffmpegResult = await FFmpegService.embedMetadataToM4a( - m4aPath: filePath, - coverPath: validCover, - metadata: metadata, - ); - } else if (isMp3) { - ffmpegResult = await FFmpegService.embedMetadataToMp3( - mp3Path: filePath, - coverPath: validCover, - metadata: metadata, - ); - } else { - ffmpegResult = await FFmpegService.embedMetadataToOpus( - opusPath: filePath, - coverPath: validCover, - metadata: metadata, - artistTagMode: settings.artistTagMode, - ); - } - - if (ffmpegResult != null) { - _log.d('Metadata embedded to $format via FFmpeg'); - } else { - _log.w('FFmpeg $format metadata embed failed'); - } - - if (isM4a && settings.embedReplayGain && scannedReplayGain != null) { - try { - await PlatformBridge.editFileMetadata(filePath, { - 'replaygain_track_gain': scannedReplayGain.trackGain, - 'replaygain_track_peak': scannedReplayGain.trackPeak, - }); - _log.d( - 'ReplayGain compatibility tags written for $format: gain=${scannedReplayGain.trackGain}, peak=${scannedReplayGain.trackPeak}', - ); - } catch (e) { - _log.w('Failed to write native ReplayGain tags for $format: $e'); - } - } - - if (isFlac) { - if (settings.artistTagMode == artistTagModeSplitVorbis) { - try { - await PlatformBridge.rewriteSplitArtistTags( - filePath, - track.artistName, - albumArtist ?? '', - ); - _log.d('Split artist tags rewritten via native FLAC writer'); - } catch (e) { - _log.w('Failed to rewrite split artist tags: $e'); - } - } - - if (settings.embedReplayGain) { - try { - final rgResult = await FFmpegService.scanReplayGain(filePath); - if (rgResult != null) { - await PlatformBridge.editFileMetadata(filePath, { - 'replaygain_track_gain': rgResult.trackGain, - 'replaygain_track_peak': rgResult.trackPeak, - }); - _log.d( - 'ReplayGain for $format: gain=${rgResult.trackGain}, peak=${rgResult.trackPeak}', - ); - _storeTrackReplayGainForAlbum(track, filePath, rgResult); - } - } catch (e) { - _log.w('Failed to embed ReplayGain via native writer: $e'); - } - } - } - } catch (e) { - _log.e('Failed to embed metadata to $format: $e'); - } finally { - if (coverPath != null) { - try { - final coverFile = File(coverPath); - if (await coverFile.exists()) await coverFile.delete(); - } catch (e) { - _log.w('Failed to cleanup $format cover file: $e'); - } - } - } - } - - Future _copySafToTemp(String uri) async { - try { - return await PlatformBridge.copyContentUriToTemp(uri); - } catch (e) { - _log.w('Failed to copy SAF uri to temp: $e'); - return null; - } - } - - Future _writeTempToSaf({ - required String treeUri, - required String relativeDir, - required String fileName, - required String mimeType, - required String srcPath, - }) async { - try { - return await PlatformBridge.createSafFileFromPath( - treeUri: treeUri, - relativeDir: relativeDir, - fileName: fileName, - mimeType: mimeType, - srcPath: srcPath, - ); - } catch (e) { - _log.w('Failed to write temp file to SAF: $e'); - return null; - } - } - - Future _writeLrcToSaf({ - required String treeUri, - required String relativeDir, - required String baseName, - required String lrcContent, - }) async { - try { - if (lrcContent.isEmpty) return; - final tempDir = await getTemporaryDirectory(); - final tempPath = '${tempDir.path}/$baseName.lrc'; - await File(tempPath).writeAsString(lrcContent); - final lrcName = '$baseName.lrc'; - final uri = await _writeTempToSaf( - treeUri: treeUri, - relativeDir: relativeDir, - fileName: lrcName, - mimeType: _mimeTypeForExt('.lrc'), - srcPath: tempPath, - ); - if (uri != null) { - _log.d('External LRC saved to SAF: $lrcName'); - } else { - _log.w('Failed to write external LRC to SAF'); - } - try { - await File(tempPath).delete(); - } catch (_) {} - } catch (e) { - _log.w('Failed to create external LRC in SAF: $e'); - } - } - - Future _deleteSafFile(String uri) async { - try { - await PlatformBridge.safDelete(uri); - } catch (e) { - _log.w('Failed to delete SAF file: $e'); - } - } - - /// Shared "SAF roundtrip" used by every finalize step that needs to - /// transform a SAF file: copies [uri] to a local temp file, lets [op] - /// transform it (returning the local path to publish plus the file name - /// to publish it under, or null to abort), writes that file back into the - /// SAF tree, deletes the original SAF file if its URI changed, and always - /// cleans up the local temp file(s). Returns the new content:// URI, or - /// null if the temp copy, [op], or the SAF write failed. - Future _replaceSafFileVia({ - required String uri, - required String treeUri, - required String relativeDir, - required Future<(String path, String fileName)?> Function(String tempPath) - op, - }) async { - final tempPath = await _copySafToTemp(uri); - if (tempPath == null) return null; - String? outPath; - try { - final produced = await op(tempPath); - if (produced == null) return null; - outPath = produced.$1; - final fileName = produced.$2; - final dotIndex = fileName.lastIndexOf('.'); - final ext = dotIndex >= 0 ? fileName.substring(dotIndex) : ''; - final newUri = await _writeTempToSaf( - treeUri: treeUri, - relativeDir: relativeDir, - fileName: fileName, - mimeType: _mimeTypeForExt(ext), - srcPath: outPath, - ); - if (newUri == null) return null; - if (newUri != uri) { - await _deleteSafFile(uri); - } - return newUri; - } finally { - try { - await File(tempPath).delete(); - } catch (_) {} - if (outPath != null && outPath != tempPath) { - try { - await File(outPath).delete(); - } catch (_) {} - } - } - } - bool _hasWifiConnection(List results) { return results.contains(ConnectivityResult.wifi); } @@ -3838,1539 +1858,6 @@ class DownloadQueueNotifier extends Notifier { } } - bool _canUseAndroidNativeWorker(AppSettings settings) { - if (!Platform.isAndroid || !settings.nativeDownloadWorkerEnabled) { - return false; - } - if (settings.concurrentDownloads > 1) { - // The experimental native worker downloads strictly sequentially, so - // prefer the Dart queue when the user enabled concurrent downloads. - _log.i('Concurrent downloads enabled; skipping native worker'); - return false; - } - if (!settings.useExtensionProviders) { - return false; - } - if (_isSafMode(settings)) { - if (settings.downloadTreeUri.isEmpty) { - return false; - } - } - final extensionState = ref.read(extensionProvider); - final hasEnabledDownloadProvider = extensionState.extensions.any( - (extension) => extension.enabled && extension.hasDownloadProvider, - ); - if (!hasEnabledDownloadProvider) { - return false; - } - return true; - } - - String _newNativeWorkerRunId() => - 'native-${DateTime.now().microsecondsSinceEpoch}-${Random().nextInt(1 << 32)}'; - - String _snapshotRunId(Map snapshot) { - final direct = snapshot['run_id']?.toString() ?? ''; - if (direct.isNotEmpty) return direct; - - final settingsJson = snapshot['settings_json']; - if (settingsJson is String && settingsJson.isNotEmpty) { - try { - final decoded = jsonDecode(settingsJson); - if (decoded is Map) { - return decoded['run_id']?.toString() ?? ''; - } - } catch (_) {} - } else if (settingsJson is Map) { - return settingsJson['run_id']?.toString() ?? ''; - } - return ''; - } - - bool _isNativeWorkerSnapshotContractCompatible( - Map snapshot, - ) { - final version = snapshot['contract_version']; - return version == DownloadRequestPayload.nativeWorkerContractVersion; - } - - /// Serial of the last fully-processed state snapshot, echoed back to the - /// native side so steady-state polls skip the per-item payload. Falls back - /// to the previous value for snapshots without the field. - int _snapshotStateSerial(Map snapshot, int previous) { - final serial = snapshot['state_serial']; - if (serial is num && serial > 0) { - return serial.toInt(); - } - return previous; - } - - bool _isNativeWorkerSnapshotForRun( - Map snapshot, - String runId, - ) => - runId.isNotEmpty && - _snapshotRunId(snapshot) == runId && - _isNativeWorkerSnapshotContractCompatible(snapshot); - - Future _persistNativeWorkerRunId(String runId) async { - _activeNativeWorkerRunId = runId; - final prefs = await SharedPreferences.getInstance(); - await prefs.setString(_nativeWorkerRunIdPrefsKey, runId); - } - - Future _loadNativeWorkerRunId() async { - if (_activeNativeWorkerRunId != null) return _activeNativeWorkerRunId; - final prefs = await SharedPreferences.getInstance(); - final runId = prefs.getString(_nativeWorkerRunIdPrefsKey); - if (runId != null && runId.isNotEmpty) { - _activeNativeWorkerRunId = runId; - return runId; - } - return null; - } - - Future _clearNativeWorkerRunId(String runId) async { - if (_activeNativeWorkerRunId == runId) { - _activeNativeWorkerRunId = null; - } - final prefs = await SharedPreferences.getInstance(); - if (prefs.getString(_nativeWorkerRunIdPrefsKey) == runId) { - await prefs.remove(_nativeWorkerRunIdPrefsKey); - } - } - - Future _tryAdoptAndroidNativeWorkerSnapshot( - List restoredItems, - ) async { - final settings = ref.read(settingsProvider); - // Adoption deliberately skips _canUseAndroidNativeWorker: that gate reads - // extension state that loads asynchronously after startup, and toggles the - // user may have flipped mid-run. If a native batch is already running we - // must reconcile with it regardless, or the Dart queue would download the - // same items a second time alongside it. - if (!Platform.isAndroid) { - return false; - } - - Map snapshot; - try { - snapshot = await PlatformBridge.getNativeDownloadWorkerSnapshot(); - } catch (_) { - return false; - } - final runId = await _loadNativeWorkerRunId(); - if (runId == null || - runId.isEmpty || - !_isNativeWorkerSnapshotForRun(snapshot, runId)) { - return false; - } - - final rawItems = snapshot['items']; - final rawItemIds = snapshot['item_ids']; - final snapshotIds = rawItems is List - ? rawItems - .whereType>() - .map((item) => item['item_id']?.toString() ?? '') - .where((id) => id.isNotEmpty) - .toSet() - : rawItemIds is List - ? rawItemIds - .map((id) => id?.toString() ?? '') - .where((id) => id.isNotEmpty) - .toSet() - : {}; - if (snapshotIds.isEmpty) { - return false; - } - if (!restoredItems.any((item) => snapshotIds.contains(item.id))) { - return false; - } - - final contexts = {}; - final pendingContextIds = {}; - for (final item in restoredItems) { - if (!snapshotIds.contains(item.id)) continue; - final context = await _buildAndroidNativeWorkerRequest(item, settings); - if (context != null) { - contexts[item.id] = context; - } else { - // Extensions may still be loading this early in startup; remember the - // item and retry building its context on later reconcile polls. - pendingContextIds.add(item.id); - } - } - - final snapshotClaimsRunning = snapshot['is_running'] == true; - if (snapshotClaimsRunning && !await _isNativeWorkerServiceAlive()) { - // The service died mid-batch (process kill/reboot): the snapshot is - // frozen at is_running=true and nothing will ever update it. Reconcile - // what the worker managed to finish, requeue the rest, and let the - // caller fall back to the Dart queue. - _log.w( - 'Native worker snapshot claims running but the service is dead; reclaiming run $runId', - ); - if (contexts.isNotEmpty) { - await _applyAndroidNativeWorkerSnapshot( - snapshot, - contexts, - {}, - settings, - ); - } - _requeueInFlightNativeWorkerItems(snapshotIds); - await _clearNativeWorkerRunId(runId); - return false; - } - if (contexts.isEmpty && pendingContextIds.isEmpty) { - return false; - } - - _log.i('Adopting Android native worker snapshot'); - final reconciledIds = {}; - _totalQueuedAtStart = contexts.length + pendingContextIds.length; - _completedInSession = 0; - _failedInSession = 0; - state = state.copyWith( - isProcessing: snapshot['is_running'] == true, - isPaused: snapshot['is_paused'] == true, - ); - await _applyAndroidNativeWorkerSnapshot( - snapshot, - contexts, - reconciledIds, - settings, - ); - - if (snapshot['is_running'] == true) { - unawaited( - _continueAndroidNativeWorkerAdoption( - contexts, - pendingContextIds, - reconciledIds, - settings, - runId, - ), - ); - } else if (state.items.any( - (item) => item.status == DownloadStatus.queued, - )) { - await _clearNativeWorkerRunId(runId); - Future.microtask(() => _processQueue()); - } else { - await _clearNativeWorkerRunId(runId); - } - - return true; - } - - Future _isNativeWorkerServiceAlive() async { - try { - return await PlatformBridge.isDownloadServiceRunning(); - } catch (_) { - return false; - } - } - - /// Puts items the dead native worker left mid-flight back into the queue so - /// the Dart queue can pick them up. - void _requeueInFlightNativeWorkerItems(Set itemIds) { - for (final id in itemIds) { - final current = _findItemById(id); - if (current == null) continue; - if (current.status == DownloadStatus.downloading || - current.status == DownloadStatus.finalizing) { - updateItemStatus(id, DownloadStatus.queued, progress: 0.0); - } - } - } - - Future _rebuildPendingNativeWorkerContexts( - Map contexts, - Set pendingContextIds, - AppSettings settings, - ) async { - if (pendingContextIds.isEmpty) return; - final resolved = {}; - for (final id in pendingContextIds) { - final item = _findItemById(id); - if (item == null) { - resolved.add(id); - continue; - } - final context = await _buildAndroidNativeWorkerRequest(item, settings); - if (context != null) { - contexts[id] = context; - resolved.add(id); - } - } - pendingContextIds.removeAll(resolved); - } - - Future _continueAndroidNativeWorkerAdoption( - Map contexts, - Set pendingContextIds, - Set reconciledIds, - AppSettings settings, - String runId, - ) async { - var deadServicePolls = 0; - var lastStateSerial = 0; - try { - while (true) { - final snapshot = await PlatformBridge.getNativeDownloadWorkerSnapshot( - sinceStateSerial: lastStateSerial, - ); - final matchesRun = _isNativeWorkerSnapshotForRun(snapshot, runId); - if (!matchesRun || snapshot['is_running'] == true) { - if (await _isNativeWorkerServiceAlive()) { - deadServicePolls = 0; - } else { - deadServicePolls++; - if (deadServicePolls >= 3) { - _log.w( - 'Native worker run $runId died without a final snapshot; reclaiming queue', - ); - if (matchesRun) { - await _applyAndroidNativeWorkerSnapshot( - snapshot, - contexts, - reconciledIds, - settings, - ); - } - _requeueInFlightNativeWorkerItems({ - ...contexts.keys, - ...pendingContextIds, - }); - await _clearNativeWorkerRunId(runId); - if (state.items.any( - (item) => item.status == DownloadStatus.queued, - )) { - Future.microtask(() => _processQueue()); - } - return; - } - } - } - if (!matchesRun) { - lastStateSerial = 0; - await Future.delayed(const Duration(seconds: 1)); - continue; - } - await _rebuildPendingNativeWorkerContexts( - contexts, - pendingContextIds, - settings, - ); - await _applyAndroidNativeWorkerSnapshot( - snapshot, - contexts, - reconciledIds, - settings, - ); - lastStateSerial = _snapshotStateSerial(snapshot, lastStateSerial); - if (snapshot['is_running'] != true) { - await _clearNativeWorkerRunId(runId); - // Items may have been requeued during reconciliation (e.g. batch - // mates of a verification challenge); hand them to the queue. - if (!state.isPaused && - state.items.any((item) => item.status == DownloadStatus.queued)) { - Future.microtask(() => _processQueue()); - } - break; - } - await Future.delayed(const Duration(seconds: 1)); - } - } catch (e) { - _log.w('Android native worker adoption stopped: $e'); - } finally { - state = state.copyWith(isProcessing: false, currentDownload: null); - } - } - - Future _tryProcessQueueWithAndroidNativeWorker( - AppSettings settings, - ) async { - if (!_canUseAndroidNativeWorker(settings)) { - return false; - } - - final queuedItems = state.items - .where((item) => item.status == DownloadStatus.queued) - .toList(growable: false); - if (queuedItems.isEmpty) { - return false; - } - - _log.i( - 'Starting Android native download worker for ${queuedItems.length} items', - ); - - final isSafMode = _isSafMode(settings); - if (!isSafMode && state.outputDir.isEmpty) { - await _initOutputDir(); - } - if (!isSafMode && state.outputDir.isEmpty) { - final musicDir = await _ensureDefaultDocumentsOutputDir(); - state = state.copyWith(outputDir: musicDir.path); - } - - final contexts = {}; - final requests = >[]; - for (final item in queuedItems) { - final context = await _buildAndroidNativeWorkerRequest(item, settings); - if (context == null) { - _log.w( - 'Native worker gate rejected ${item.track.name}; falling back to Dart queue', - ); - return false; - } - contexts[item.id] = context; - requests.add({ - 'contract_version': DownloadRequestPayload.nativeWorkerContractVersion, - 'item_id': item.id, - 'track_name': item.track.name, - 'artist_name': item.track.artistName, - 'item_json': jsonEncode(item.toJson()), - 'request_json': context.requestJson, - }); - } - - state = state.copyWith(isProcessing: true, isPaused: false); - _totalQueuedAtStart = queuedItems.length; - _completedInSession = 0; - _failedInSession = 0; - - final runId = _newNativeWorkerRunId(); - await _persistNativeWorkerRunId(runId); - final reconciledIds = {}; - try { - await PlatformBridge.startNativeDownloadWorker( - requests: requests, - settings: { - 'worker': 'android_native', - 'version': 1, - 'contract_version': - DownloadRequestPayload.nativeWorkerContractVersion, - 'run_id': runId, - 'created_at': DateTime.now().toIso8601String(), - 'save_download_history': settings.saveDownloadHistory, - }, - ); - - final runStartWait = Stopwatch()..start(); - var lastStateSerial = 0; - while (true) { - final snapshot = await PlatformBridge.getNativeDownloadWorkerSnapshot( - sinceStateSerial: lastStateSerial, - ); - if (!_isNativeWorkerSnapshotForRun(snapshot, runId)) { - lastStateSerial = 0; - if (runStartWait.elapsed > const Duration(seconds: 30)) { - throw _NativeWorkerStartupTimeout(); - } - await Future.delayed(const Duration(milliseconds: 250)); - continue; - } - await _applyAndroidNativeWorkerSnapshot( - snapshot, - contexts, - reconciledIds, - settings, - ); - lastStateSerial = _snapshotStateSerial(snapshot, lastStateSerial); - if (snapshot['is_running'] != true) { - await _clearNativeWorkerRunId(runId); - break; - } - await Future.delayed(const Duration(seconds: 1)); - } - } catch (e, stack) { - if (e is _NativeWorkerStartupTimeout) { - _log.w( - 'Android native worker did not publish a matching snapshot; cancelling native worker and falling back to Dart queue', - ); - try { - await PlatformBridge.cancelNativeDownloadWorker(); - } catch (cancelError) { - _log.w('Failed to cancel timed-out native worker: $cancelError'); - } - await _clearNativeWorkerRunId(runId); - state = state.copyWith(isProcessing: false, currentDownload: null); - await Future.delayed(const Duration(milliseconds: 500)); - return false; - } - _log.e('Android native worker failed: $e', e, stack); - // The native worker keeps downloading on its own; without cancelling - // it here the batch we just marked failed would continue in the - // background, its completions never reconciled, and the Dart queue - // could even restart alongside it. - try { - await PlatformBridge.cancelNativeDownloadWorker(); - } catch (cancelError) { - _log.w('Failed to cancel native worker after error: $cancelError'); - } - await _clearNativeWorkerRunId(runId); - for (final item in queuedItems) { - final current = _findItemById(item.id); - if (current == null || - current.status == DownloadStatus.completed || - current.status == DownloadStatus.failed || - current.status == DownloadStatus.skipped) { - continue; - } - updateItemStatus( - item.id, - DownloadStatus.failed, - error: 'Native download worker failed: $e', - errorType: DownloadErrorType.unknown, - ); - _failedInSession++; - } - } finally { - state = state.copyWith(isProcessing: false, currentDownload: null); - _stopConnectivityMonitoring(); - try { - await PlatformBridge.cleanupConnections(); - } catch (e) { - _log.e('Native worker cleanup failed: $e'); - } - } - - if (_totalQueuedAtStart > 0) { - await _notificationService.showQueueComplete( - completedCount: _completedInSession, - failedCount: _failedInSession, - ); - } - - final hasQueuedItems = state.items.any( - (item) => item.status == DownloadStatus.queued, - ); - if (hasQueuedItems && !state.isPaused) { - _log.i( - 'Found queued items after Android native worker finished, restarting queue...', - ); - Future.microtask(() => _processQueue()); - } - - return true; - } - - Future<_NativeWorkerRequestContext?> _buildAndroidNativeWorkerRequest( - DownloadItem item, - AppSettings settings, - ) async { - if (!_hasActiveDownloadProvider(item.service)) { - return null; - } - - var quality = item.qualityOverride ?? state.audioQuality; - if (quality == 'DEFAULT') quality = state.audioQuality; - - final isSafMode = _isSafMode(settings); - final rawOutputDir = isSafMode - ? await _buildRelativeOutputDir( - item.track, - settings.folderOrganization, - separateSingles: settings.separateSingles, - albumFolderStructure: settings.albumFolderStructure, - createPlaylistFolder: settings.createPlaylistFolder, - useAlbumArtistForFolders: settings.useAlbumArtistForFolders, - usePrimaryArtistOnly: settings.usePrimaryArtistOnly, - filterContributingArtistsInAlbumArtist: - settings.filterContributingArtistsInAlbumArtist, - playlistName: item.playlistName, - ) - : await _buildOutputDir( - item.track, - settings.folderOrganization, - separateSingles: settings.separateSingles, - albumFolderStructure: settings.albumFolderStructure, - createPlaylistFolder: settings.createPlaylistFolder, - useAlbumArtistForFolders: settings.useAlbumArtistForFolders, - usePrimaryArtistOnly: settings.usePrimaryArtistOnly, - filterContributingArtistsInAlbumArtist: - settings.filterContributingArtistsInAlbumArtist, - playlistName: item.playlistName, - ); - final outputDir = isSafMode - ? _sanitizeSafRelativeDir(rawOutputDir) - : rawOutputDir; - if (!isSafMode) { - await _ensureDirExists(outputDir, label: 'Output folder'); - } - - final outputExt = _determineOutputExt(quality, item.service); - if (settings.embedReplayGain && - outputExt != '.flac' && - outputExt != '.m4a') { - return null; - } - - String? safFileName; - final safOutputExt = isSafMode ? outputExt : ''; - final baseFilenameFormat = _shouldTreatAsSingleRelease(item.track) - ? state.singleFilenameFormat - : state.filenameFormat; - final effectiveFilenameFormat = _filenameFormatForItem( - item, - baseFilenameFormat, - ); - if (isSafMode) { - final baseName = await PlatformBridge.buildFilename( - effectiveFilenameFormat, - _filenameMetadataForTrack( - item.track, - playlistPosition: _validPlaylistPosition(item), - ), - ); - safFileName = await _buildSafFileName(baseName, safOutputExt); - } - - var trackForPayload = item.track; - String? nativeDeezerTrackId = await _resolveDeezerIdFromKnownOrIsrc( - trackForPayload, - item.id, - lookupContext: 'native worker ISRC', - ); - final providerResolved = await _resolveDeezerIdViaProviderIfNeeded( - trackForPayload, - nativeDeezerTrackId, - item.id, - ); - trackForPayload = providerResolved.track; - nativeDeezerTrackId = providerResolved.deezerTrackId; - - final extendedMetadata = await _loadExtendedMetadataForDeezerId( - nativeDeezerTrackId, - ); - - final extensionState = ref.read(extensionProvider); - final payload = _buildDownloadRequestPayload( - track: trackForPayload, - item: item, - settings: settings, - extensionState: extensionState, - quality: quality, - filenameFormat: effectiveFilenameFormat, - outputDir: outputDir, - outputExt: outputExt, - useSaf: isSafMode, - safFileName: safFileName, - deezerTrackId: nativeDeezerTrackId, - genre: extendedMetadata?.genre, - label: extendedMetadata?.label, - copyright: extendedMetadata?.copyright, - stageSafForDeferredPublish: isSafMode, - ).withStrategy(useExtensions: true, useFallback: state.autoFallback); - - return _NativeWorkerRequestContext( - item: item, - requestJson: jsonEncode(payload.toJson()), - outputDir: outputDir, - quality: quality, - storageMode: isSafMode ? 'saf' : 'app', - outputExt: outputExt, - downloadTreeUri: isSafMode ? settings.downloadTreeUri : null, - safRelativeDir: isSafMode ? outputDir : null, - safFileName: safFileName, - ); - } - - Future _applyAndroidNativeWorkerSnapshot( - Map snapshot, - Map contexts, - Set reconciledIds, - AppSettings settings, - ) async { - final rawItems = snapshot['items']; - final rawDelta = snapshot['item_delta']; - final itemSnapshots = >[]; - if (rawItems is List) { - for (final rawItem in rawItems) { - if (rawItem is Map) { - itemSnapshots.add(Map.from(rawItem)); - } - } - } - if (rawDelta is Map) { - itemSnapshots.add(Map.from(rawDelta)); - } - if (itemSnapshots.isEmpty) { - return; - } - - for (final itemSnapshot in itemSnapshots) { - final itemId = itemSnapshot['item_id']?.toString() ?? ''; - if (itemId.isEmpty || reconciledIds.contains(itemId)) { - continue; - } - final context = contexts[itemId]; - if (context == null) continue; - - final status = itemSnapshot['status']?.toString() ?? 'queued'; - final progress = ((itemSnapshot['progress'] as num?)?.toDouble() ?? 0.0) - .clamp(0.0, 1.0) - .toDouble(); - final current = _findItemById(itemId); - if (current == null) { - reconciledIds.add(itemId); - continue; - } - - if (status == 'queued') { - updateItemStatus(itemId, DownloadStatus.queued, progress: 0.0); - continue; - } - - if (status == 'preparing') { - updateItemStatus(itemId, DownloadStatus.downloading, progress: 0.0); - continue; - } - - if (status == 'downloading') { - updateItemStatus( - itemId, - DownloadStatus.downloading, - progress: progress, - ); - continue; - } - - if (status == 'finalizing') { - updateItemStatus( - itemId, - DownloadStatus.finalizing, - progress: progress <= 0 ? 0.95 : progress, - ); - continue; - } - - if (status == 'completed') { - final result = itemSnapshot['result']; - if (result is Map) { - reconciledIds.add(itemId); - await _completeAndroidNativeWorkerItem( - context, - Map.from(result), - settings, - ); - } - continue; - } - - if (status == 'failed' || status == 'skipped') { - reconciledIds.add(itemId); - final result = itemSnapshot['result']; - final error = itemSnapshot['error']?.toString(); - if (status == 'skipped') { - updateItemStatus(itemId, DownloadStatus.skipped); - } else { - final resultMap = result is Map - ? Map.from(result) - : null; - final errorMsg = (error == null || error.isEmpty) - ? (resultMap?['error']?.toString() ?? 'Download failed') - : error; - final backendErrorType = resultMap == null - ? DownloadErrorType.unknown - : _downloadErrorTypeFromBackend( - resultMap['error_type']?.toString(), - ); - final errorType = backendErrorType == DownloadErrorType.unknown - ? _downloadErrorTypeFromMessage(errorMsg) - : backendErrorType; - if (errorType == DownloadErrorType.verificationRequired) { - _log.i( - 'Android native worker requires verification for ${current.track.name}; switching back to interactive queue', - ); - // Cancelling the native worker marks every remaining batch item - // "skipped" on the native side. Those items did nothing wrong — - // remember them now and requeue them below so the batch resumes - // after the challenge instead of being abandoned. - final pendingBatchIds = []; - for (final pendingId in contexts.keys) { - if (pendingId == itemId || reconciledIds.contains(pendingId)) { - continue; - } - final pending = _findItemById(pendingId); - if (pending == null) continue; - if (pending.status == DownloadStatus.queued || - pending.status == DownloadStatus.downloading || - pending.status == DownloadStatus.finalizing) { - pendingBatchIds.add(pendingId); - } - } - try { - await PlatformBridge.cancelNativeDownloadWorker(); - } catch (e) { - _log.w('Failed to cancel native worker before verification: $e'); - } - for (final pendingId in pendingBatchIds) { - reconciledIds.add(pendingId); - updateItemStatus(pendingId, DownloadStatus.queued, progress: 0.0); - } - await _handleVerificationRequiredDownload( - current, - errorMsg, - _nativeWorkerVerificationService(resultMap, context), - ); - continue; - } - updateItemStatus( - itemId, - DownloadStatus.failed, - error: errorMsg, - errorType: errorType, - ); - _failedInSession++; - } - } - } - } - - Future _completeAndroidNativeWorkerItem( - _NativeWorkerRequestContext context, - Map result, - AppSettings settings, - ) async { - final item = context.item; - var filePath = result['file_path'] as String?; - if (filePath == null || filePath.isEmpty) { - updateItemStatus( - item.id, - DownloadStatus.failed, - error: 'Native worker completed without a file path', - errorType: DownloadErrorType.unknown, - ); - _failedInSession++; - return; - } - - if (result['native_finalized'] == true) { - updateItemStatus( - item.id, - DownloadStatus.completed, - progress: 1.0, - filePath: filePath, - ); - if (settings.saveDownloadHistory) { - final historyItem = result['history_item']; - if (historyItem is Map) { - try { - ref - .read(downloadHistoryProvider.notifier) - .adoptNativeHistoryItem( - DownloadHistoryItem.fromJson( - Map.from(historyItem), - ), - ); - } catch (e) { - _log.w('Failed to adopt native history item: $e'); - await ref - .read(downloadHistoryProvider.notifier) - .reloadFromStorage(); - } - } else if (result['history_written'] == true) { - await ref.read(downloadHistoryProvider.notifier).reloadFromStorage(); - } - } - _completedInSession++; - await _notificationService.showDownloadComplete( - trackName: item.track.name, - artistName: item.track.artistName, - completedCount: _completedInSession, - totalCount: _totalQueuedAtStart, - alreadyInLibrary: result['already_exists'] == true, - ); - removeItem(item.id); - return; - } - - final rawDecryptFileName = - (result['file_name'] as String?) ?? context.safFileName ?? 'track'; - final decryptOutcome = await _finalizeDecryption( - result: result, - filePath: filePath, - storageMode: context.storageMode, - downloadTreeUri: context.downloadTreeUri, - safRelativeDir: context.safRelativeDir ?? '', - baseName: rawDecryptFileName.replaceFirst(RegExp(r'\.[^.]+$'), ''), - extFallback: context.outputExt, - repairAc4: false, - onStart: (strategy) => _log.i( - 'Native-worker encrypted stream detected, decrypting via $strategy...', - ), - ); - if (decryptOutcome.path == null) { - updateItemStatus( - item.id, - DownloadStatus.failed, - error: 'Failed to decrypt encrypted stream', - errorType: DownloadErrorType.unknown, - ); - _failedInSession++; - return; - } - filePath = decryptOutcome.path!; - if (decryptOutcome.newFileName != null) { - result['file_name'] = decryptOutcome.newFileName; - } - - var actualQuality = context.quality; - final actualBitDepth = result['actual_bit_depth'] as int?; - final actualSampleRate = result['actual_sample_rate'] as int?; - final actualFormat = - normalizeAudioFormatValue( - result['audio_codec']?.toString() ?? result['format']?.toString(), - ) ?? - normalizeAudioFormatValue(audioFormatForPath(filePath)); - final actualBitrate = isLossyAudioFormat(actualFormat) - ? readPositiveBitrateKbps(result['bitrate'] ?? result['actual_bitrate']) - : null; - final resolvedQuality = resolveDisplayQuality( - filePath: filePath, - detectedFormat: actualFormat, - bitDepth: actualBitDepth, - sampleRate: actualSampleRate, - bitrateKbps: actualBitrate, - storedQuality: actualQuality, - ); - if (resolvedQuality != null) { - actualQuality = resolvedQuality; - } - - final resolvedAlbumArtist = _resolveAlbumArtistForMetadata( - item.track, - settings, - ); - final trackToDownload = _buildTrackForMetadataEmbedding( - item.track, - result, - resolvedAlbumArtist, - ); - final convertedHighPath = await _finalizeNativeWorkerHighConversion( - context: context, - result: result, - settings: settings, - track: trackToDownload, - filePath: filePath, - ); - if (convertedHighPath == null) { - updateItemStatus( - item.id, - DownloadStatus.failed, - error: 'Failed to convert HIGH quality download', - errorType: DownloadErrorType.unknown, - ); - _failedInSession++; - return; - } - filePath = convertedHighPath; - final nativeActualQuality = result['_native_actual_quality'] as String?; - if (nativeActualQuality != null && nativeActualQuality.isNotEmpty) { - actualQuality = nativeActualQuality; - } - final convertedContainerPath = - await _finalizeNativeWorkerContainerConversion( - context: context, - result: result, - settings: settings, - track: trackToDownload, - filePath: filePath, - ); - if (convertedContainerPath == null) { - updateItemStatus( - item.id, - DownloadStatus.failed, - error: 'Failed to convert downloaded container', - errorType: DownloadErrorType.unknown, - ); - _failedInSession++; - return; - } - filePath = convertedContainerPath; - - updateItemStatus( - item.id, - DownloadStatus.completed, - progress: 1.0, - filePath: filePath, - ); - await _saveExternalLrc( - result: result, - settings: settings, - extensionState: ref.read(extensionProvider), - track: trackToDownload, - service: context.item.service, - filePath: filePath, - storageMode: context.storageMode, - downloadTreeUri: context.downloadTreeUri, - safRelativeDir: context.safRelativeDir ?? '', - resolveBaseName: () async { - final resultFileName = result['file_name'] as String?; - final fileName = (resultFileName != null && resultFileName.isNotEmpty) - ? resultFileName - : context.safFileName; - return fileName != null && fileName.isNotEmpty - ? fileName.replaceFirst(RegExp(r'\.[^.]+$'), '') - : await PlatformBridge.sanitizeFilename( - '${trackToDownload.artistName} - ${trackToDownload.name}', - ); - }, - onFetchError: (e) => - _log.w('Failed to fetch native-worker external LRC: $e'), - ); - final postProcessedPath = await _runPostProcessingHooks( - filePath, - trackToDownload, - ); - if (postProcessedPath != null && postProcessedPath.isNotEmpty) { - filePath = postProcessedPath; - } - await _writeNativeWorkerReplayGain( - context: context, - settings: settings, - track: trackToDownload, - filePath: filePath, - ); - _completedInSession++; - - await _notificationService.showDownloadComplete( - trackName: item.track.name, - artistName: item.track.artistName, - completedCount: _completedInSession, - totalCount: _totalQueuedAtStart, - alreadyInLibrary: result['already_exists'] == true, - ); - - final resultSafFileName = result['file_name'] as String?; - final lowerFilePath = filePath.toLowerCase(); - final isLossyOutput = - isLossyAudioFormat(actualFormat) || - lowerFilePath.endsWith('.mp3') || - lowerFilePath.endsWith('.opus') || - lowerFilePath.endsWith('.ogg'); - - if (settings.saveDownloadHistory) { - ref - .read(downloadHistoryProvider.notifier) - .addToHistory( - _historyItemFromResult( - item: item, - trackToDownload: trackToDownload, - result: result, - filePath: filePath, - quality: actualQuality, - useSaf: context.storageMode == 'saf', - downloadTreeUri: context.downloadTreeUri, - safRelativeDir: context.safRelativeDir, - safFileName: - (resultSafFileName != null && resultSafFileName.isNotEmpty) - ? resultSafFileName - : context.safFileName, - bitDepth: isLossyOutput ? null : actualBitDepth, - sampleRate: isLossyOutput ? null : actualSampleRate, - bitrate: actualBitrate, - format: actualFormat, - genre: normalizeOptionalString(result['genre'] as String?), - label: normalizeOptionalString(result['label'] as String?), - copyright: normalizeOptionalString( - result['copyright'] as String?, - ), - ), - ); - } - - removeItem(item.id); - } - - static const _decryptStageSafAccess = 'safAccess'; - static const _decryptStageDecrypt = 'decrypt'; - static const _decryptStageSafWrite = 'safWrite'; - - /// Shared decrypt finalize used by both the inline single-item pipeline - /// and the native-worker pipeline. Divergences captured as parameters: - /// [repairAc4] (inline repairs AC-4 containers using the still-encrypted - /// source; native-worker does not) and [onStart] (inline logs its own - /// "detected" message; native-worker logs a differently worded one). - Future<_DecryptOutcome> _finalizeDecryption({ - required Map result, - required String filePath, - required String storageMode, - String? downloadTreeUri, - required String safRelativeDir, - required String baseName, - required String extFallback, - required bool repairAc4, - void Function(String strategy)? onStart, - }) async { - if (result['already_exists'] == true) { - return _DecryptOutcome(filePath); - } - - final descriptor = DownloadDecryptionDescriptor.fromDownloadResult(result); - if (descriptor == null) { - return _DecryptOutcome(filePath); - } - onStart?.call(descriptor.normalizedStrategy); - - if (storageMode == 'saf' && isContentUri(filePath)) { - if (downloadTreeUri == null || downloadTreeUri.isEmpty) { - return const _DecryptOutcome(null, failStage: _decryptStageSafAccess); - } - String? failStage; - var opStarted = false; - String? producedFileName; - final newUri = await _replaceSafFileVia( - uri: filePath, - treeUri: downloadTreeUri, - relativeDir: safRelativeDir, - op: (tempPath) async { - opStarted = true; - final decryptedTempPath = await FFmpegService.decryptWithDescriptor( - inputPath: tempPath, - descriptor: descriptor, - deleteOriginal: false, - ); - if (decryptedTempPath == null) { - failStage = _decryptStageDecrypt; - return null; - } - if (repairAc4) { - try { - await PlatformBridge.ensureAC4Config(decryptedTempPath, tempPath); - } catch (e) { - _log.w('AC-4 container repair skipped: $e'); - } - } - final dotIndex = decryptedTempPath.lastIndexOf('.'); - final decryptedExt = dotIndex >= 0 - ? decryptedTempPath.substring(dotIndex).toLowerCase() - : extFallback; - const allowedExt = {'.flac', '.m4a', '.mp4', '.mp3', '.opus'}; - final finalExt = allowedExt.contains(decryptedExt) - ? decryptedExt - : extFallback; - final newFileName = '$baseName$finalExt'; - producedFileName = newFileName; - return (decryptedTempPath, newFileName); - }, - ); - if (newUri == null) { - return _DecryptOutcome( - null, - failStage: - failStage ?? - (opStarted ? _decryptStageSafWrite : _decryptStageSafAccess), - ); - } - return _DecryptOutcome(newUri, newFileName: producedFileName); - } - - if (repairAc4) { - final decryptedPath = await FFmpegService.decryptWithDescriptor( - inputPath: filePath, - descriptor: descriptor, - deleteOriginal: false, - ); - if (decryptedPath == null) { - try { - await deleteFile(filePath); - } catch (_) {} - return const _DecryptOutcome(null, failStage: _decryptStageDecrypt); - } - try { - await PlatformBridge.ensureAC4Config(decryptedPath, filePath); - } catch (e) { - _log.w('AC-4 container repair skipped: $e'); - } - try { - await deleteFile(filePath); - } catch (_) {} - return _DecryptOutcome(decryptedPath); - } - - final decryptedPath = await FFmpegService.decryptWithDescriptor( - inputPath: filePath, - descriptor: descriptor, - deleteOriginal: true, - ); - return _DecryptOutcome( - decryptedPath, - failStage: decryptedPath == null ? _decryptStageDecrypt : null, - ); - } - - Future _finalizeNativeWorkerHighConversion({ - required _NativeWorkerRequestContext context, - required Map result, - required AppSettings settings, - required Track track, - required String filePath, - }) async { - if (context.quality != 'HIGH') { - return filePath; - } - - final lowerPath = filePath.toLowerCase(); - final resultFileName = (result['file_name'] as String?)?.toLowerCase(); - final looksLikeM4a = - lowerPath.endsWith('.m4a') || - lowerPath.endsWith('.mp4') || - (resultFileName != null && - (resultFileName.endsWith('.m4a') || - resultFileName.endsWith('.mp4'))); - if (!looksLikeM4a) { - return filePath; - } - - final tidalHighFormat = settings.tidalHighFormat; - final format = lossyFormatForSetting(tidalHighFormat); - final newExt = lossyExtensionForFormat(format); - final displayFormat = displayFormatForLossyFormat(format); - final bitrateDisplay = tidalHighFormat.contains('_') - ? '${tidalHighFormat.split('_').last}kbps' - : '320kbps'; - - Future embedConvertedMetadata(String convertedPath) async { - if (!settings.embedMetadata) return; - await _embedMetadataToFile( - convertedPath, - track, - format: metadataFormatForLossyFormat(format), - genre: result['genre'] as String?, - label: result['label'] as String?, - copyright: result['copyright'] as String?, - downloadService: context.item.service, - ); - } - - if (context.storageMode == 'saf' && isContentUri(filePath)) { - final treeUri = context.downloadTreeUri; - if (treeUri == null || treeUri.isEmpty) { - return null; - } - final rawFileName = - (result['file_name'] as String?) ?? context.safFileName ?? 'track'; - final baseName = rawFileName.replaceFirst(RegExp(r'\.[^.]+$'), ''); - final newFileName = '$baseName$newExt'; - final newUri = await _replaceSafFileVia( - uri: filePath, - treeUri: treeUri, - relativeDir: context.safRelativeDir ?? '', - op: (tempPath) async { - final convertedPath = await FFmpegService.convertM4aToLossy( - tempPath, - format: format, - bitrate: tidalHighFormat, - deleteOriginal: false, - ); - if (convertedPath == null) return null; - await embedConvertedMetadata(convertedPath); - return (convertedPath, newFileName); - }, - ); - if (newUri == null) { - return null; - } - result['file_name'] = newFileName; - result['_native_actual_quality'] = '$displayFormat $bitrateDisplay'; - return newUri; - } - - final convertedPath = await FFmpegService.convertM4aToLossy( - filePath, - format: format, - bitrate: tidalHighFormat, - deleteOriginal: true, - ); - if (convertedPath == null) { - return null; - } - await embedConvertedMetadata(convertedPath); - result['_native_actual_quality'] = '$displayFormat $bitrateDisplay'; - return convertedPath; - } - - Future _finalizeNativeWorkerContainerConversion({ - required _NativeWorkerRequestContext context, - required Map result, - required AppSettings settings, - required Track track, - required String filePath, - }) async { - if (context.quality == 'HIGH' || context.outputExt != '.flac') { - return filePath; - } - final resultAudioFormat = normalizeAudioFormatValue( - result['audio_codec']?.toString() ?? - result['actual_audio_codec']?.toString(), - ); - if (isLossyAudioFormat(resultAudioFormat)) { - _log.d( - 'Native-worker output is $resultAudioFormat; preserving native container.', - ); - return filePath; - } - final requiresContainerConversion = - result['requires_container_conversion'] == true || - result['requiresContainerConversion'] == true; - final resultOutputExt = _downloadResultOutputExt( - result, - filePath: filePath, - ); - final lowerPath = filePath.toLowerCase(); - final resultFileName = (result['file_name'] as String?)?.toLowerCase(); - final mayNeedContainerConversion = - requiresContainerConversion || - lowerPath.endsWith('.m4a') || - lowerPath.endsWith('.mp4') || - resultOutputExt == '.m4a' || - resultOutputExt == '.mp4' || - isContentUri(filePath); - if (!mayNeedContainerConversion) { - return filePath; - } - final requestedDecryptionExt = - DownloadDecryptionDescriptor.fromDownloadResult( - result, - )?.normalizedOutputExtension; - if (!requiresContainerConversion && - requestedDecryptionExt != null && - requestedDecryptionExt != '.flac') { - _log.d( - 'Native-worker decrypted output requested $requestedDecryptionExt; preserving native container.', - ); - return filePath; - } - final looksLikeM4a = - lowerPath.endsWith('.m4a') || - lowerPath.endsWith('.mp4') || - resultOutputExt == '.m4a' || - resultOutputExt == '.mp4' || - (resultFileName != null && - (resultFileName.endsWith('.m4a') || - resultFileName.endsWith('.mp4'))); - if (!requiresContainerConversion && - !looksLikeM4a && - !isContentUri(filePath)) { - return filePath; - } - - Future embedFlacMetadata(String flacPath) async { - if (!settings.embedMetadata) return; - await _embedMetadataToFile( - flacPath, - track, - format: 'flac', - genre: result['genre'] as String?, - label: result['label'] as String?, - copyright: result['copyright'] as String?, - downloadService: context.item.service, - writeExternalLrc: context.storageMode != 'saf', - ); - } - - if (context.storageMode == 'saf' && isContentUri(filePath)) { - final treeUri = context.downloadTreeUri; - if (treeUri == null || treeUri.isEmpty) { - return null; - } - var preserve = false; - String? producedFileName; - final newUri = await _replaceSafFileVia( - uri: filePath, - treeUri: treeUri, - relativeDir: context.safRelativeDir ?? '', - op: (tempPath) async { - 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.', - ); - preserve = true; - return null; - } - if (isAlreadyNativeFlac) { - _log.d( - 'Native FLAC payload detected in temporary container; publishing ' - 'as FLAC and embedding metadata.', - ); - await embedFlacMetadata(tempPath); - final rawFileName = - (result['file_name'] as String?) ?? - context.safFileName ?? - 'track'; - final baseName = rawFileName.replaceFirst(RegExp(r'\.[^.]+$'), ''); - final newFileName = '$baseName.flac'; - producedFileName = newFileName; - return (tempPath, newFileName); - } - final flacPath = await FFmpegService.convertM4aToFlac(tempPath); - if (flacPath == null) { - return null; - } - await embedFlacMetadata(flacPath); - final rawFileName = - (result['file_name'] as String?) ?? - context.safFileName ?? - 'track'; - final baseName = rawFileName.replaceFirst(RegExp(r'\.[^.]+$'), ''); - final newFileName = '$baseName.flac'; - producedFileName = newFileName; - return (flacPath, newFileName); - }, - ); - if (preserve) { - return filePath; - } - if (newUri == null) { - return null; - } - result['file_name'] = producedFileName; - return newUri; - } - - final codec = await FFmpegService.probePrimaryAudioCodec(filePath); - final isAlreadyNativeFlac = - codec == 'flac' && await FFmpegService.isNativeFlacFile(filePath); - if (!FFmpegService.isLosslessAudioCodec(codec)) { - _log.d( - 'Preserving native container; audio codec is ${codec ?? 'unknown'}, ' - 'no FLAC container conversion needed.', - ); - return filePath; - } - if (isAlreadyNativeFlac) { - var flacPath = filePath; - if (!filePath.toLowerCase().endsWith('.flac')) { - final renamedPath = filePath.replaceAll(RegExp(r'\.[^.]+$'), '.flac'); - final targetPath = renamedPath == filePath - ? '$filePath.flac' - : renamedPath; - await File(filePath).rename(targetPath); - flacPath = targetPath; - } - await embedFlacMetadata(flacPath); - return flacPath; - } - final flacPath = await FFmpegService.convertM4aToFlac(filePath); - if (flacPath == null) { - return null; - } - await embedFlacMetadata(flacPath); - return flacPath; - } - - Future _writeNativeWorkerReplayGain({ - required _NativeWorkerRequestContext context, - required AppSettings settings, - required Track track, - required String filePath, - }) async { - if (!settings.embedReplayGain) { - return; - } - if (context.outputExt != '.flac' && context.outputExt != '.m4a') { - return; - } - - try { - final rgResult = await FFmpegService.scanReplayGain(filePath); - if (rgResult == null) { - return; - } - await PlatformBridge.editFileMetadata(filePath, { - 'replaygain_track_gain': rgResult.trackGain, - 'replaygain_track_peak': rgResult.trackPeak, - }); - _storeTrackReplayGainForAlbum(track, filePath, rgResult); - _updateAlbumRgFilePath(track, filePath); - await _checkAndWriteAlbumReplayGain(track); - _log.d( - 'Native-worker ReplayGain written: gain=${rgResult.trackGain}, peak=${rgResult.trackPeak}', - ); - } catch (e) { - _log.w('Failed to write native-worker ReplayGain: $e'); - } - } - - /// Shared external-LRC finalize used by both the inline single-item - /// pipeline (SAF only; the local-file case is already handled during - /// metadata embedding) and the native-worker pipeline (both storage - /// modes). [resolveBaseName] and [onFetchError] are each caller's own - /// base-name fallback chain and fetch-failure log line, evaluated lazily - /// to match the original call sites exactly. - Future _saveExternalLrc({ - required Map result, - required AppSettings settings, - required ExtensionState extensionState, - required Track track, - required String service, - required String filePath, - required String storageMode, - String? downloadTreeUri, - required String safRelativeDir, - required Future Function() resolveBaseName, - required void Function(Object e) onFetchError, - }) async { - final lyricsMode = settings.lyricsMode; - final shouldSaveExternalLrc = - settings.embedMetadata && - settings.embedLyrics && - !_shouldSkipLyrics(extensionState, track.source, service) && - (lyricsMode == 'external' || lyricsMode == 'both'); - if (!shouldSaveExternalLrc) { - return; - } - - String? lrcContent = result['lyrics_lrc'] as String?; - if (lrcContent == null || lrcContent.isEmpty) { - try { - lrcContent = await PlatformBridge.getLyricsLRC( - track.id, - track.name, - track.artistName, - durationMs: track.duration * 1000, - ); - } catch (e) { - onFetchError(e); - } - } - if (lrcContent == null || lrcContent.isEmpty) { - return; - } - - if (storageMode == 'saf' && isContentUri(filePath)) { - if (downloadTreeUri == null || downloadTreeUri.isEmpty) { - return; - } - final baseName = await resolveBaseName(); - await _writeLrcToSaf( - treeUri: downloadTreeUri, - relativeDir: safRelativeDir, - baseName: baseName, - lrcContent: lrcContent, - ); - return; - } - - try { - final lrcPath = filePath.replaceAll(RegExp(r'\.[^.]+$'), '.lrc'); - final safeLrcPath = lrcPath == filePath ? '$filePath.lrc' : lrcPath; - await File(safeLrcPath).writeAsString(lrcContent); - _log.d('Native-worker external LRC saved: $safeLrcPath'); - } catch (e) { - _log.w('Failed to save native-worker external LRC: $e'); - } - } - DownloadErrorType _downloadErrorTypeFromBackend(String? errorType) { switch (errorType) { case 'not_found': @@ -5388,24 +1875,6 @@ class DownloadQueueNotifier extends Notifier { } } - String _nativeWorkerVerificationService( - Map? result, - _NativeWorkerRequestContext context, - ) { - if (result != null) { - for (final key in const [ - 'service', - 'verification_service', - 'provider', - 'source', - ]) { - final value = result[key]?.toString().trim() ?? ''; - if (value.isNotEmpty) return value; - } - } - return context.item.service; - } - DownloadErrorType _downloadErrorTypeFromMessage(String errorMsg) { final lowerMsg = errorMsg.toLowerCase(); if (isExtensionVerificationRequired(errorMsg)) { @@ -7548,51 +4017,6 @@ class DownloadQueueLookup { } } -class _NativeWorkerStartupTimeout implements Exception { - @override - String toString() => 'Native worker did not publish run snapshot'; -} - final downloadQueueLookupProvider = Provider((ref) { return ref.watch(downloadQueueProvider.select((s) => s.lookup)); }); - -class _AlbumRgTrackEntry { - String filePath; - final String trackId; - final double integratedLufs; - final double truePeakLinear; - final double durationSecs; - - _AlbumRgTrackEntry({ - required this.filePath, - required this.trackId, - required this.integratedLufs, - required this.truePeakLinear, - required this.durationSecs, - }); -} - -class _AlbumRgAccumulator { - final List<_AlbumRgTrackEntry> entries = []; -} - -class _DeezerLookupPreparation { - final Track track; - final String? deezerTrackId; - - const _DeezerLookupPreparation({required this.track, this.deezerTrackId}); -} - -class _DeezerExtendedMetadataFields { - final String? genre; - final String? label; - final String? copyright; - - const _DeezerExtendedMetadataFields({this.genre, this.label, this.copyright}); - - bool get hasAnyValue => - (genre != null && genre!.isNotEmpty) || - (label != null && label!.isNotEmpty) || - (copyright != null && copyright!.isNotEmpty); -} diff --git a/lib/providers/download_queue_provider_embedding.dart b/lib/providers/download_queue_provider_embedding.dart new file mode 100644 index 00000000..0d2483b7 --- /dev/null +++ b/lib/providers/download_queue_provider_embedding.dart @@ -0,0 +1,912 @@ +// ignore_for_file: invalid_use_of_protected_member, invalid_use_of_visible_for_testing_member +part of 'download_queue_provider.dart'; + +class _DeezerLookupPreparation { + final Track track; + final String? deezerTrackId; + + const _DeezerLookupPreparation({required this.track, this.deezerTrackId}); +} + +class _DeezerExtendedMetadataFields { + final String? genre; + final String? label; + final String? copyright; + + const _DeezerExtendedMetadataFields({this.genre, this.label, this.copyright}); + + bool get hasAnyValue => + (genre != null && genre!.isNotEmpty) || + (label != null && label!.isNotEmpty) || + (copyright != null && copyright!.isNotEmpty); +} + +extension _DownloadQueueEmbedding on DownloadQueueNotifier { + String? _resolveAlbumArtistForMetadata(Track track, AppSettings settings) { + var albumArtist = normalizeOptionalString(track.albumArtist); + if (settings.filterContributingArtistsInAlbumArtist) { + albumArtist = albumArtist == null + ? null + : normalizeOptionalString(_extractPrimaryArtist(albumArtist)); + } + return albumArtist; + } + + static final _isrcRegex = RegExp(r'^[A-Z]{2}[A-Z0-9]{3}\d{2}\d{5}$'); + + bool _isValidISRC(String value) { + return _isrcRegex.hasMatch(value.toUpperCase()); + } + + /// Returns true if any enabled extension matching [source] or [service] + /// declares `skipLyrics: true` in its manifest. + bool _shouldSkipLyrics( + ExtensionState extensionState, + String? source, + String? service, + ) { + final candidates = {}; + if (source != null && source.isNotEmpty) { + candidates.add(source.trim().toLowerCase()); + } + if (service != null && service.isNotEmpty) { + candidates.add(service.trim().toLowerCase()); + } + if (candidates.isEmpty) return false; + return extensionState.extensions.any( + (e) => + e.enabled && e.skipLyrics && candidates.contains(e.id.toLowerCase()), + ); + } + + String? _extractKnownDeezerTrackId(Track track) { + final deezerId = track.deezerId?.trim(); + if (deezerId != null && deezerId.isNotEmpty) { + return deezerId; + } + + if (track.id.startsWith('deezer:')) { + final rawId = track.id.substring('deezer:'.length).trim(); + if (rawId.isNotEmpty) { + return rawId; + } + } + + final availabilityDeezerId = track.availability?.deezerId?.trim(); + if (availabilityDeezerId != null && availabilityDeezerId.isNotEmpty) { + return availabilityDeezerId; + } + + return null; + } + + Future _searchDeezerTrackIdByIsrc( + String? isrc, { + required String lookupContext, + String? itemId, + }) async { + final normalizedIsrc = normalizeOptionalString(isrc); + if (normalizedIsrc == null || !_isValidISRC(normalizedIsrc)) { + return null; + } + + try { + _log.d('No Deezer ID, searching by $lookupContext: $normalizedIsrc'); + final deezerResult = await PlatformBridge.searchDeezerByISRC( + normalizedIsrc, + itemId: itemId, + ); + if (deezerResult['success'] == true && deezerResult['track_id'] != null) { + final deezerTrackId = deezerResult['track_id'].toString(); + _log.d('Found Deezer track ID via $lookupContext: $deezerTrackId'); + return deezerTrackId; + } + } catch (e) { + _log.w('Failed to search Deezer by $lookupContext: $e'); + } + + return null; + } + + Track _copyTrackWithResolvedMetadata( + Track track, { + String? resolvedIsrc, + int? trackNumber, + int? totalTracks, + int? discNumber, + int? totalDiscs, + String? releaseDate, + String? deezerId, + String? composer, + }) { + final normalizedIsrc = normalizeOptionalString(resolvedIsrc); + final normalizedComposer = normalizeOptionalString(composer); + + return Track( + id: track.id, + name: track.name, + artistName: track.artistName, + albumName: track.albumName, + albumArtist: track.albumArtist, + artistId: track.artistId, + albumId: track.albumId, + coverUrl: normalizeCoverReference(track.coverUrl), + duration: track.duration, + isrc: (normalizedIsrc != null && _isValidISRC(normalizedIsrc)) + ? normalizedIsrc + : track.isrc, + trackNumber: (track.trackNumber != null && track.trackNumber! > 0) + ? track.trackNumber + : trackNumber, + discNumber: (track.discNumber != null && track.discNumber! > 0) + ? track.discNumber + : discNumber, + totalDiscs: (track.totalDiscs != null && track.totalDiscs! > 0) + ? track.totalDiscs + : totalDiscs, + releaseDate: track.releaseDate ?? normalizeOptionalString(releaseDate), + deezerId: deezerId ?? track.deezerId, + availability: track.availability, + source: track.source, + albumType: track.albumType, + totalTracks: (track.totalTracks != null && track.totalTracks! > 0) + ? track.totalTracks + : totalTracks, + composer: (track.composer != null && track.composer!.isNotEmpty) + ? track.composer + : normalizedComposer, + itemType: track.itemType, + ); + } + + Future<_DeezerLookupPreparation> _resolveProviderTrackForDeezerLookup( + Track track, + String itemId, + ) async { + try { + final colonIdx = track.id.indexOf(':'); + final provider = track.id.substring(0, colonIdx); + final effectiveProvider = resolveEffectiveMetadataProvider( + provider, + ref.read(extensionProvider), + ); + final providerTrackId = track.id.substring(colonIdx + 1); + + _log.d( + 'No ISRC, fetching from ${effectiveProvider.isEmpty ? provider : effectiveProvider} API: $providerTrackId', + ); + final providerData = await PlatformBridge.getProviderMetadata( + effectiveProvider.isEmpty ? provider : effectiveProvider, + 'track', + providerTrackId, + ); + + final trackData = providerData['track'] as Map?; + if (trackData == null) { + return _DeezerLookupPreparation( + track: track, + deezerTrackId: _extractKnownDeezerTrackId(track), + ); + } + + final resolvedIsrc = normalizeOptionalString( + trackData['isrc'] as String?, + ); + if (resolvedIsrc == null || !_isValidISRC(resolvedIsrc)) { + return _DeezerLookupPreparation( + track: track, + deezerTrackId: _extractKnownDeezerTrackId(track), + ); + } + + _log.d( + 'Resolved ISRC from ${effectiveProvider.isEmpty ? provider : effectiveProvider}: $resolvedIsrc', + ); + + final updatedTrack = _copyTrackWithResolvedMetadata( + track, + resolvedIsrc: resolvedIsrc, + releaseDate: trackData['release_date'] as String?, + trackNumber: trackData['track_number'] as int?, + totalTracks: trackData['total_tracks'] as int?, + discNumber: trackData['disc_number'] as int?, + totalDiscs: trackData['total_discs'] as int?, + composer: trackData['composer'] as String?, + ); + final deezerTrackId = await _searchDeezerTrackIdByIsrc( + resolvedIsrc, + lookupContext: + '${effectiveProvider.isEmpty ? provider : effectiveProvider} ISRC', + itemId: itemId, + ); + + return _DeezerLookupPreparation( + track: deezerTrackId == null + ? updatedTrack + : _copyTrackWithResolvedMetadata( + updatedTrack, + deezerId: deezerTrackId, + ), + deezerTrackId: + deezerTrackId ?? _extractKnownDeezerTrackId(updatedTrack), + ); + } catch (e) { + _log.w('Failed to resolve ISRC from provider: $e'); + return _DeezerLookupPreparation( + track: track, + deezerTrackId: _extractKnownDeezerTrackId(track), + ); + } + } + + Future<_DeezerLookupPreparation> _resolveSpotifyTrackViaDeezer( + Track track, + ) async { + try { + var spotifyId = track.id; + if (spotifyId.startsWith('spotify:track:')) { + spotifyId = spotifyId.split(':').last; + } + _log.d('No Deezer ID, converting from Spotify via SongLink: $spotifyId'); + + final deezerData = await PlatformBridge.convertSpotifyToDeezer( + 'track', + spotifyId, + ); + final trackData = deezerData['track']; + + String? deezerTrackId; + if (trackData is Map) { + final rawId = trackData['spotify_id'] as String?; + if (rawId != null && rawId.startsWith('deezer:')) { + deezerTrackId = rawId.split(':')[1]; + _log.d('Found Deezer track ID via SongLink: $deezerTrackId'); + } else if (deezerData['id'] != null) { + deezerTrackId = deezerData['id'].toString(); + _log.d('Found Deezer track ID via SongLink (legacy): $deezerTrackId'); + } + + final deezerIsrc = normalizeOptionalString( + trackData['isrc'] as String?, + ); + final needsEnrich = + (track.releaseDate == null && + normalizeOptionalString(trackData['release_date'] as String?) != + null) || + (track.isrc == null && deezerIsrc != null) || + (!_isValidISRC(track.isrc ?? '') && deezerIsrc != null) || + ((track.trackNumber == null || track.trackNumber! <= 0) && + (trackData['track_number'] as int?) != null && + (trackData['track_number'] as int?)! > 0) || + ((track.totalTracks == null || track.totalTracks! <= 0) && + (trackData['total_tracks'] as int?) != null && + (trackData['total_tracks'] as int?)! > 0) || + ((track.discNumber == null || track.discNumber! <= 0) && + (trackData['disc_number'] as int?) != null && + (trackData['disc_number'] as int?)! > 0) || + ((track.totalDiscs == null || track.totalDiscs! <= 0) && + (trackData['total_discs'] as int?) != null && + (trackData['total_discs'] as int?)! > 0) || + ((track.composer == null || track.composer!.isEmpty) && + normalizeOptionalString(trackData['composer'] as String?) != + null) || + deezerTrackId != null; + + final updatedTrack = needsEnrich + ? _copyTrackWithResolvedMetadata( + track, + resolvedIsrc: deezerIsrc, + releaseDate: trackData['release_date'] as String?, + trackNumber: trackData['track_number'] as int?, + totalTracks: trackData['total_tracks'] as int?, + discNumber: trackData['disc_number'] as int?, + totalDiscs: trackData['total_discs'] as int?, + composer: trackData['composer'] as String?, + deezerId: deezerTrackId, + ) + : track; + + if (needsEnrich) { + _log.d( + 'Enriched track from Deezer - date: ${updatedTrack.releaseDate}, ISRC: ${updatedTrack.isrc}, track: ${updatedTrack.trackNumber}, disc: ${updatedTrack.discNumber}', + ); + } + + return _DeezerLookupPreparation( + track: updatedTrack, + deezerTrackId: + deezerTrackId ?? _extractKnownDeezerTrackId(updatedTrack), + ); + } + + if (deezerData['id'] != null) { + deezerTrackId = deezerData['id'].toString(); + _log.d('Found Deezer track ID via SongLink (flat): $deezerTrackId'); + return _DeezerLookupPreparation( + track: _copyTrackWithResolvedMetadata(track, deezerId: deezerTrackId), + deezerTrackId: deezerTrackId, + ); + } + } catch (e) { + _log.w('Failed to convert Spotify to Deezer via SongLink: $e'); + } + + return _DeezerLookupPreparation( + track: track, + deezerTrackId: _extractKnownDeezerTrackId(track), + ); + } + + Future<_DeezerExtendedMetadataFields> _loadDeezerExtendedMetadata( + String deezerTrackId, + ) async { + try { + final extendedMetadata = await PlatformBridge.getDeezerExtendedMetadata( + deezerTrackId, + ); + if (extendedMetadata == null) { + return const _DeezerExtendedMetadataFields(); + } + + final metadata = _DeezerExtendedMetadataFields( + genre: normalizeOptionalString(extendedMetadata['genre']), + label: normalizeOptionalString(extendedMetadata['label']), + copyright: normalizeOptionalString(extendedMetadata['copyright']), + ); + if (metadata.hasAnyValue) { + _log.d( + 'Extended metadata - Genre: ${metadata.genre}, Label: ${metadata.label}, Copyright: ${metadata.copyright}', + ); + } + return metadata; + } catch (e) { + _log.w('Failed to fetch extended metadata from Deezer: $e'); + return const _DeezerExtendedMetadataFields(); + } + } + + /// Returns a known Deezer track ID for [track], or resolves one via ISRC + /// search. Does not attempt provider-based resolution (see + /// [_resolveDeezerIdViaProviderIfNeeded]). + Future _resolveDeezerIdFromKnownOrIsrc( + Track track, + String itemId, { + required String lookupContext, + }) async { + final known = _extractKnownDeezerTrackId(track); + if (known != null) return known; + if (track.isrc != null && + track.isrc!.isNotEmpty && + _isValidISRC(track.isrc!)) { + return _searchDeezerTrackIdByIsrc( + track.isrc, + lookupContext: lookupContext, + itemId: itemId, + ); + } + return null; + } + + /// For tidal:/qobuz: tracks without a usable ISRC, resolves the ISRC (and + /// Deezer ID) from the provider API directly. Returns [track] and + /// [deezerTrackId] unchanged when resolution isn't needed or fails. + Future<_DeezerLookupPreparation> _resolveDeezerIdViaProviderIfNeeded( + Track track, + String? deezerTrackId, + String itemId, + ) async { + if (deezerTrackId == null && + (track.isrc == null || + track.isrc!.isEmpty || + !_isValidISRC(track.isrc!)) && + (track.id.startsWith('tidal:') || track.id.startsWith('qobuz:'))) { + final providerLookup = await _resolveProviderTrackForDeezerLookup( + track, + itemId, + ); + return _DeezerLookupPreparation( + track: providerLookup.track, + deezerTrackId: deezerTrackId ?? providerLookup.deezerTrackId, + ); + } + return _DeezerLookupPreparation(track: track, deezerTrackId: deezerTrackId); + } + + /// Loads extended Deezer metadata (genre/label/copyright) for + /// [deezerTrackId], or returns null when no ID is available. + Future<_DeezerExtendedMetadataFields?> _loadExtendedMetadataForDeezerId( + String? deezerTrackId, + ) { + if (deezerTrackId == null || deezerTrackId.isEmpty) { + return Future.value(null); + } + return _loadDeezerExtendedMetadata(deezerTrackId); + } + + /// Deezer CDN cover size pattern: /WxH-0-0-0-0.jpg + static final _deezerSizeRegex = RegExp(r'/(\d+)x(\d+)-\d+-\d+-\d+-\d+\.jpg$'); + + String _upgradeToMaxQualityCover(String coverUrl) { + const spotifySize300 = 'ab67616d00001e02'; + const spotifySize640 = 'ab67616d0000b273'; + const spotifySizeMax = 'ab67616d000082c1'; + + var result = coverUrl; + if (result.contains(spotifySize300)) { + result = result.replaceFirst(spotifySize300, spotifySize640); + } + if (result.contains(spotifySize640)) { + result = result.replaceFirst(spotifySize640, spotifySizeMax); + } + + if (result.contains('cdn-images.dzcdn.net')) { + final upgraded = result.replaceFirst( + _deezerSizeRegex, + '/1800x1800-000000-80-0-0.jpg', + ); + if (upgraded != result) { + _log.d('Cover URL upgraded (Deezer): 1800x1800'); + result = upgraded; + } + } + + // Tidal CDN upgrade (1280x1280 → origin) + if (result.contains('resources.tidal.com') && + result.contains('/1280x1280.jpg')) { + result = result.replaceFirst('/1280x1280.jpg', '/origin.jpg'); + _log.d('Cover URL upgraded (Tidal): origin'); + } + + return result; + } + + bool _isUsableIndex(int? number, int? total) { + if (number == null || number <= 0) return false; + return total == null || total <= 0 || number <= total; + } + + int? _resolvePositiveMetadataInt(int? sourceValue, int? backendValue) { + if (sourceValue != null && sourceValue > 0) return sourceValue; + return backendValue; + } + + int? _resolveMetadataIndex({ + required int? sourceValue, + required int? backendValue, + required int? total, + }) { + if (_isUsableIndex(sourceValue, total)) return sourceValue; + if (_isUsableIndex(backendValue, total)) return backendValue; + return sourceValue != null && sourceValue > 0 ? sourceValue : backendValue; + } + + String? _resolveMetadataText(String? sourceValue, String? backendValue) { + return normalizeOptionalString(sourceValue) ?? + normalizeOptionalString(backendValue); + } + + Track _buildTrackForMetadataEmbedding( + Track baseTrack, + Map backendResult, + String? resolvedAlbumArtist, + ) { + final backendTrackNum = readPositiveInt(backendResult['track_number']); + final backendDiscNum = readPositiveInt(backendResult['disc_number']); + final backendTotalTracks = readPositiveInt(backendResult['total_tracks']); + final backendTotalDiscs = readPositiveInt(backendResult['total_discs']); + final backendYear = normalizeOptionalString( + backendResult['release_date'] as String?, + ); + final backendAlbum = normalizeOptionalString( + backendResult['album'] as String?, + ); + final backendIsrc = normalizeOptionalString( + backendResult['isrc'] as String?, + ); + final backendCoverUrl = normalizeCoverReference( + backendResult['cover_url']?.toString(), + ); + final baseCoverUrl = normalizeCoverReference(baseTrack.coverUrl); + final resolvedCoverUrl = baseCoverUrl ?? backendCoverUrl; + final backendAlbumArtist = normalizeOptionalString( + backendResult['album_artist'] as String?, + ); + final backendComposer = normalizeOptionalString( + backendResult['composer']?.toString(), + ); + final sourceAlbumName = normalizeOptionalString(baseTrack.albumName); + final sourceAlbumArtist = normalizeOptionalString(baseTrack.albumArtist); + final sourceIsrc = normalizeOptionalString(baseTrack.isrc); + final sourceReleaseDate = normalizeOptionalString(baseTrack.releaseDate); + final sourceComposer = normalizeOptionalString(baseTrack.composer); + final resolvedTotalTracks = _resolvePositiveMetadataInt( + baseTrack.totalTracks, + backendTotalTracks, + ); + final resolvedTotalDiscs = _resolvePositiveMetadataInt( + baseTrack.totalDiscs, + backendTotalDiscs, + ); + final resolvedTrackNumber = _resolveMetadataIndex( + sourceValue: baseTrack.trackNumber, + backendValue: backendTrackNum, + total: resolvedTotalTracks, + ); + final resolvedDiscNumber = _resolveMetadataIndex( + sourceValue: baseTrack.discNumber, + backendValue: backendDiscNum, + total: resolvedTotalDiscs, + ); + + final hasOverrides = + resolvedTrackNumber != baseTrack.trackNumber || + resolvedDiscNumber != baseTrack.discNumber || + resolvedTotalTracks != baseTrack.totalTracks || + resolvedTotalDiscs != baseTrack.totalDiscs || + resolvedAlbumArtist != sourceAlbumArtist || + (sourceReleaseDate == null && backendYear != null) || + (sourceAlbumName == null && backendAlbum != null) || + (sourceIsrc == null && backendIsrc != null) || + (baseCoverUrl == null && backendCoverUrl != null) || + (sourceAlbumArtist == null && + resolvedAlbumArtist == null && + backendAlbumArtist != null) || + (sourceComposer == null && backendComposer != null); + + if (!hasOverrides) { + return baseTrack; + } + + return Track( + id: baseTrack.id, + name: baseTrack.name, + artistName: baseTrack.artistName, + albumName: sourceAlbumName ?? backendAlbum ?? baseTrack.albumName, + albumArtist: + resolvedAlbumArtist ?? sourceAlbumArtist ?? backendAlbumArtist, + artistId: baseTrack.artistId, + albumId: baseTrack.albumId, + coverUrl: resolvedCoverUrl, + duration: baseTrack.duration, + isrc: sourceIsrc ?? backendIsrc, + trackNumber: resolvedTrackNumber, + discNumber: resolvedDiscNumber, + totalDiscs: resolvedTotalDiscs, + releaseDate: sourceReleaseDate ?? backendYear, + deezerId: baseTrack.deezerId, + availability: baseTrack.availability, + albumType: baseTrack.albumType, + totalTracks: resolvedTotalTracks, + composer: sourceComposer ?? backendComposer, + source: baseTrack.source, + ); + } + + /// Unified metadata, cover, lyrics, and ReplayGain embedding for all formats. + /// + /// [format] must be one of `'flac'`, `'m4a'`, `'mp3'`, or `'opus'`. + /// [writeExternalLrc] only applies to FLAC and M4A (non-SAF paths handle LRC separately). + Future _embedMetadataToFile( + String filePath, + Track track, { + required String format, + String? genre, + String? label, + String? copyright, + String? downloadService, + bool writeExternalLrc = true, + }) async { + final settings = ref.read(settingsProvider); + if (!settings.embedMetadata) { + _log.d( + 'Metadata embedding disabled, skipping $format metadata/cover embed', + ); + return; + } + + final isFlac = format == 'flac'; + final isM4a = format == 'm4a'; + final isMp3 = format == 'mp3'; + + String? coverPath; + var coverUrl = normalizeRemoteHttpUrl(track.coverUrl); + if (coverUrl != null && coverUrl.isNotEmpty) { + try { + if (settings.maxQualityCover) { + coverUrl = _upgradeToMaxQualityCover(coverUrl); + _log.d('Cover URL upgraded to max quality for $format: $coverUrl'); + } + + final tempDir = await getTemporaryDirectory(); + final uniqueId = + '${DateTime.now().millisecondsSinceEpoch}_${Random().nextInt(10000)}'; + coverPath = '${tempDir.path}/cover_${format}_$uniqueId.jpg'; + + final httpClient = HttpClient(); + final request = await httpClient.getUrl(Uri.parse(coverUrl)); + final response = await request.close(); + if (response.statusCode == 200) { + final file = File(coverPath); + final sink = file.openWrite(); + await response.pipe(sink); + await sink.close(); + _log.d('Cover downloaded for $format: $coverPath'); + } else { + _log.w( + 'Failed to download cover for $format: HTTP ${response.statusCode}', + ); + coverPath = null; + } + httpClient.close(); + } catch (e) { + _log.e('Failed to download cover for $format: $e'); + coverPath = null; + } + } + + try { + final metadata = { + 'TITLE': track.name, + 'ARTIST': track.artistName, + 'ALBUM': track.albumName, + }; + String formatIndexTag(int number, int? total) { + if (total != null && total > 0) { + return '$number/$total'; + } + return number.toString(); + } + + final albumArtist = _resolveAlbumArtistForMetadata(track, settings); + if (albumArtist != null) { + metadata['ALBUMARTIST'] = albumArtist; + } + + if (track.trackNumber != null && track.trackNumber! > 0) { + final trackTag = formatIndexTag(track.trackNumber!, track.totalTracks); + metadata['TRACKNUMBER'] = trackTag; + if (isFlac || isMp3) metadata['TRACK'] = trackTag; + } + if (track.discNumber != null && track.discNumber! > 0) { + final discTag = formatIndexTag(track.discNumber!, track.totalDiscs); + metadata['DISCNUMBER'] = discTag; + if (isFlac || isMp3) metadata['DISC'] = discTag; + } + if (track.releaseDate != null) { + metadata['DATE'] = track.releaseDate!; + if (isFlac || isMp3) { + metadata['YEAR'] = track.releaseDate!.split('-').first; + } + } + if (track.isrc != null) metadata['ISRC'] = track.isrc!; + if (genre != null && genre.isNotEmpty) metadata['GENRE'] = genre; + if (label != null && label.isNotEmpty) metadata['ORGANIZATION'] = label; + if (copyright != null && copyright.isNotEmpty) { + metadata['COPYRIGHT'] = copyright; + } + if (track.composer != null && track.composer!.isNotEmpty) { + metadata['COMPOSER'] = track.composer!; + } + + final lyricsMode = settings.lyricsMode; + final extensionState = ref.read(extensionProvider); + final skipLyrics = _shouldSkipLyrics( + extensionState, + track.source, + downloadService, + ); + final shouldEmbedLyrics = + settings.embedLyrics && + !skipLyrics && + (lyricsMode == 'embed' || lyricsMode == 'both'); + final shouldSaveExternalLyrics = + settings.embedLyrics && + !skipLyrics && + (lyricsMode == 'external' || lyricsMode == 'both'); + String? lrcContent; + + if (shouldEmbedLyrics || shouldSaveExternalLyrics) { + try { + final fetchedLrc = await PlatformBridge.getLyricsLRC( + track.id, + track.name, + track.artistName, + filePath: '', + durationMs: track.duration * 1000, + ); + if (fetchedLrc.isNotEmpty && fetchedLrc != '[instrumental:true]') { + lrcContent = fetchedLrc; + _log.d('Lyrics fetched for $format (${fetchedLrc.length} chars)'); + } else if (fetchedLrc == '[instrumental:true]') { + _log.d('Track is instrumental, skipping lyrics handling'); + } + } catch (e) { + _log.w('Failed to fetch lyrics for $format: $e'); + } + } + + if (shouldEmbedLyrics && lrcContent != null) { + metadata['LYRICS'] = lrcContent; + if (isFlac || isMp3) metadata['UNSYNCEDLYRICS'] = lrcContent; + } else if ((isFlac || isM4a) && !shouldEmbedLyrics) { + metadata['LYRICS'] = ''; + if (isFlac) { + metadata['UNSYNCEDLYRICS'] = ''; + } + } + + if (writeExternalLrc && shouldSaveExternalLyrics && lrcContent != null) { + try { + final lrcPath = filePath.replaceAll(RegExp(r'\.[^.]+$'), '.lrc'); + final safeLrcPath = lrcPath == filePath ? '$filePath.lrc' : lrcPath; + await File(safeLrcPath).writeAsString(lrcContent); + _log.d('External LRC file saved: $safeLrcPath'); + } catch (e) { + _log.w('Failed to save external LRC file for $format: $e'); + } + } + + ReplayGainResult? scannedReplayGain; + + if (settings.embedReplayGain && !isFlac) { + try { + final rgResult = await FFmpegService.scanReplayGain(filePath); + if (rgResult != null) { + scannedReplayGain = rgResult; + metadata['REPLAYGAIN_TRACK_GAIN'] = rgResult.trackGain; + metadata['REPLAYGAIN_TRACK_PEAK'] = rgResult.trackPeak; + if (format == 'opus') { + final r128 = FFmpegService.replayGainDbToR128(rgResult.trackGain); + if (r128 != null) metadata['R128_TRACK_GAIN'] = r128; + } + _log.d( + 'ReplayGain for $format: gain=${rgResult.trackGain}, peak=${rgResult.trackPeak}', + ); + _storeTrackReplayGainForAlbum(track, filePath, rgResult); + } + } catch (e) { + _log.w('Failed to scan ReplayGain for $format: $e'); + } + } + + final validCover = coverPath != null && await File(coverPath).exists() + ? coverPath + : null; + + // AC-4 is passthrough-only: the FFmpeg mov muxer would re-wrap it as + // QuickTime and break the ISO MP4 from decryption. writeAC4Metadata is a + // no-op for non-AC-4 files, so other m4a downloads fall through to FFmpeg. + if (isM4a) { + try { + final ac4Meta = { + 'title': track.name, + 'artist': track.artistName, + 'album': track.albumName, + 'albumArtist': ?albumArtist, + if (track.releaseDate != null) 'date': track.releaseDate!, + if (genre != null && genre.isNotEmpty) 'genre': genre, + if (track.composer != null && track.composer!.isNotEmpty) + 'composer': track.composer!, + if (track.trackNumber != null && track.trackNumber! > 0) + 'trackNumber': track.trackNumber!.toString(), + if (track.totalTracks != null && track.totalTracks! > 0) + 'totalTracks': track.totalTracks!.toString(), + if (track.discNumber != null && track.discNumber! > 0) + 'discNumber': track.discNumber!.toString(), + if (track.totalDiscs != null && track.totalDiscs! > 0) + 'totalDiscs': track.totalDiscs!.toString(), + if (track.isrc != null) 'isrc': track.isrc!, + if (label != null && label.isNotEmpty) 'label': label, + if (copyright != null && copyright.isNotEmpty) + 'copyright': copyright, + if (shouldEmbedLyrics) 'lyrics': ?lrcContent, + }; + final ac4Result = await PlatformBridge.writeAC4Metadata( + filePath, + ac4Meta, + validCover ?? '', + ); + if (ac4Result['handled'] == true) { + _log.d('AC-4 metadata embedded natively for $format'); + return; + } + } catch (e) { + _log.w('AC-4 metadata path failed, falling back to FFmpeg: $e'); + } + } + + String? ffmpegResult; + if (isFlac) { + ffmpegResult = await FFmpegService.embedMetadata( + flacPath: filePath, + coverPath: validCover, + metadata: metadata, + artistTagMode: settings.artistTagMode, + ); + } else if (isM4a) { + ffmpegResult = await FFmpegService.embedMetadataToM4a( + m4aPath: filePath, + coverPath: validCover, + metadata: metadata, + ); + } else if (isMp3) { + ffmpegResult = await FFmpegService.embedMetadataToMp3( + mp3Path: filePath, + coverPath: validCover, + metadata: metadata, + ); + } else { + ffmpegResult = await FFmpegService.embedMetadataToOpus( + opusPath: filePath, + coverPath: validCover, + metadata: metadata, + artistTagMode: settings.artistTagMode, + ); + } + + if (ffmpegResult != null) { + _log.d('Metadata embedded to $format via FFmpeg'); + } else { + _log.w('FFmpeg $format metadata embed failed'); + } + + if (isM4a && settings.embedReplayGain && scannedReplayGain != null) { + try { + await PlatformBridge.editFileMetadata(filePath, { + 'replaygain_track_gain': scannedReplayGain.trackGain, + 'replaygain_track_peak': scannedReplayGain.trackPeak, + }); + _log.d( + 'ReplayGain compatibility tags written for $format: gain=${scannedReplayGain.trackGain}, peak=${scannedReplayGain.trackPeak}', + ); + } catch (e) { + _log.w('Failed to write native ReplayGain tags for $format: $e'); + } + } + + if (isFlac) { + if (settings.artistTagMode == artistTagModeSplitVorbis) { + try { + await PlatformBridge.rewriteSplitArtistTags( + filePath, + track.artistName, + albumArtist ?? '', + ); + _log.d('Split artist tags rewritten via native FLAC writer'); + } catch (e) { + _log.w('Failed to rewrite split artist tags: $e'); + } + } + + if (settings.embedReplayGain) { + try { + final rgResult = await FFmpegService.scanReplayGain(filePath); + if (rgResult != null) { + await PlatformBridge.editFileMetadata(filePath, { + 'replaygain_track_gain': rgResult.trackGain, + 'replaygain_track_peak': rgResult.trackPeak, + }); + _log.d( + 'ReplayGain for $format: gain=${rgResult.trackGain}, peak=${rgResult.trackPeak}', + ); + _storeTrackReplayGainForAlbum(track, filePath, rgResult); + } + } catch (e) { + _log.w('Failed to embed ReplayGain via native writer: $e'); + } + } + } + } catch (e) { + _log.e('Failed to embed metadata to $format: $e'); + } finally { + if (coverPath != null) { + try { + final coverFile = File(coverPath); + if (await coverFile.exists()) await coverFile.delete(); + } catch (e) { + _log.w('Failed to cleanup $format cover file: $e'); + } + } + } + } +} diff --git a/lib/providers/download_queue_provider_finalization.dart b/lib/providers/download_queue_provider_finalization.dart new file mode 100644 index 00000000..f6705a57 --- /dev/null +++ b/lib/providers/download_queue_provider_finalization.dart @@ -0,0 +1,695 @@ +// ignore_for_file: invalid_use_of_protected_member, invalid_use_of_visible_for_testing_member +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}); +} + +extension _DownloadQueueFinalization on DownloadQueueNotifier { + /// Builds the [DownloadHistoryItem] shared by the native-worker and inline + /// completion paths. Fields whose source/derivation legitimately differs + /// between the two callers (SAF location, probed vs. raw audio metadata, + /// genre/label/copyright fallback chain) are left as parameters. + DownloadHistoryItem _historyItemFromResult({ + required DownloadItem item, + required Track trackToDownload, + required Map result, + required String filePath, + required String quality, + required bool useSaf, + String? downloadTreeUri, + String? safRelativeDir, + String? safFileName, + int? bitDepth, + int? sampleRate, + int? bitrate, + String? format, + String? genre, + String? label, + String? copyright, + }) { + final backendTitle = result['title'] as String?; + final backendArtist = result['artist'] as String?; + final backendAlbum = result['album'] as String?; + final backendYear = result['release_date'] as String?; + final backendTrackNum = readPositiveInt(result['track_number']); + final backendDiscNum = readPositiveInt(result['disc_number']); + final backendTotalTracks = readPositiveInt(result['total_tracks']); + final backendTotalDiscs = readPositiveInt(result['total_discs']); + final backendISRC = result['isrc'] as String?; + final backendComposer = result['composer'] as String?; + + final historyTotalTracks = _resolvePositiveMetadataInt( + trackToDownload.totalTracks, + backendTotalTracks, + ); + final historyTotalDiscs = _resolvePositiveMetadataInt( + trackToDownload.totalDiscs, + backendTotalDiscs, + ); + final historyTrackNumber = _resolveMetadataIndex( + sourceValue: trackToDownload.trackNumber, + backendValue: backendTrackNum, + total: historyTotalTracks, + ); + final historyDiscNumber = _resolveMetadataIndex( + sourceValue: trackToDownload.discNumber, + backendValue: backendDiscNum, + total: historyTotalDiscs, + ); + final historyTitle = + _resolveMetadataText(trackToDownload.name, backendTitle) ?? + item.track.name; + final historyArtist = + _resolveMetadataText(trackToDownload.artistName, backendArtist) ?? + item.track.artistName; + final historyAlbum = + _resolveMetadataText(trackToDownload.albumName, backendAlbum) ?? + item.track.albumName; + final historyIsrc = _resolveMetadataText(trackToDownload.isrc, backendISRC); + final historyReleaseDate = _resolveMetadataText( + trackToDownload.releaseDate, + backendYear, + ); + final historyComposer = _resolveMetadataText( + trackToDownload.composer, + backendComposer, + ); + + return DownloadHistoryItem( + id: item.id, + trackName: historyTitle, + artistName: historyArtist, + albumName: historyAlbum, + albumArtist: normalizeOptionalString(trackToDownload.albumArtist), + coverUrl: normalizeCoverReference(trackToDownload.coverUrl), + filePath: filePath, + storageMode: useSaf ? 'saf' : 'app', + downloadTreeUri: useSaf ? downloadTreeUri : null, + safRelativeDir: useSaf ? safRelativeDir : null, + safFileName: useSaf ? safFileName : null, + safRepaired: false, + service: result['service'] as String? ?? item.service, + downloadedAt: DateTime.now(), + isrc: historyIsrc, + spotifyId: trackToDownload.id, + trackNumber: historyTrackNumber, + totalTracks: historyTotalTracks, + discNumber: historyDiscNumber, + totalDiscs: historyTotalDiscs, + duration: trackToDownload.duration, + releaseDate: historyReleaseDate, + quality: quality, + bitDepth: bitDepth, + sampleRate: sampleRate, + bitrate: bitrate, + format: format, + genre: genre, + composer: historyComposer, + label: label, + copyright: copyright, + ); + } + + Future _copySafToTemp(String uri) async { + try { + return await PlatformBridge.copyContentUriToTemp(uri); + } catch (e) { + _log.w('Failed to copy SAF uri to temp: $e'); + return null; + } + } + + Future _writeTempToSaf({ + required String treeUri, + required String relativeDir, + required String fileName, + required String mimeType, + required String srcPath, + }) async { + try { + return await PlatformBridge.createSafFileFromPath( + treeUri: treeUri, + relativeDir: relativeDir, + fileName: fileName, + mimeType: mimeType, + srcPath: srcPath, + ); + } catch (e) { + _log.w('Failed to write temp file to SAF: $e'); + return null; + } + } + + Future _writeLrcToSaf({ + required String treeUri, + required String relativeDir, + required String baseName, + required String lrcContent, + }) async { + try { + if (lrcContent.isEmpty) return; + final tempDir = await getTemporaryDirectory(); + final tempPath = '${tempDir.path}/$baseName.lrc'; + await File(tempPath).writeAsString(lrcContent); + final lrcName = '$baseName.lrc'; + final uri = await _writeTempToSaf( + treeUri: treeUri, + relativeDir: relativeDir, + fileName: lrcName, + mimeType: _mimeTypeForExt('.lrc'), + srcPath: tempPath, + ); + if (uri != null) { + _log.d('External LRC saved to SAF: $lrcName'); + } else { + _log.w('Failed to write external LRC to SAF'); + } + try { + await File(tempPath).delete(); + } catch (_) {} + } catch (e) { + _log.w('Failed to create external LRC in SAF: $e'); + } + } + + Future _deleteSafFile(String uri) async { + try { + await PlatformBridge.safDelete(uri); + } catch (e) { + _log.w('Failed to delete SAF file: $e'); + } + } + + /// Shared "SAF roundtrip" used by every finalize step that needs to + /// transform a SAF file: copies [uri] to a local temp file, lets [op] + /// transform it (returning the local path to publish plus the file name + /// to publish it under, or null to abort), writes that file back into the + /// SAF tree, deletes the original SAF file if its URI changed, and always + /// cleans up the local temp file(s). Returns the new content:// URI, or + /// null if the temp copy, [op], or the SAF write failed. + Future _replaceSafFileVia({ + required String uri, + required String treeUri, + required String relativeDir, + required Future<(String path, String fileName)?> Function(String tempPath) + op, + }) async { + final tempPath = await _copySafToTemp(uri); + if (tempPath == null) return null; + String? outPath; + try { + final produced = await op(tempPath); + if (produced == null) return null; + outPath = produced.$1; + final fileName = produced.$2; + final dotIndex = fileName.lastIndexOf('.'); + final ext = dotIndex >= 0 ? fileName.substring(dotIndex) : ''; + final newUri = await _writeTempToSaf( + treeUri: treeUri, + relativeDir: relativeDir, + fileName: fileName, + mimeType: _mimeTypeForExt(ext), + srcPath: outPath, + ); + if (newUri == null) return null; + if (newUri != uri) { + await _deleteSafFile(uri); + } + return newUri; + } finally { + try { + await File(tempPath).delete(); + } catch (_) {} + if (outPath != null && outPath != tempPath) { + try { + await File(outPath).delete(); + } catch (_) {} + } + } + } + + /// Shared decrypt finalize used by both the inline single-item pipeline + /// and the native-worker pipeline. Divergences captured as parameters: + /// [repairAc4] (inline repairs AC-4 containers using the still-encrypted + /// source; native-worker does not) and [onStart] (inline logs its own + /// "detected" message; native-worker logs a differently worded one). + Future<_DecryptOutcome> _finalizeDecryption({ + required Map result, + required String filePath, + required String storageMode, + String? downloadTreeUri, + required String safRelativeDir, + required String baseName, + required String extFallback, + required bool repairAc4, + void Function(String strategy)? onStart, + }) async { + if (result['already_exists'] == true) { + return _DecryptOutcome(filePath); + } + + final descriptor = DownloadDecryptionDescriptor.fromDownloadResult(result); + if (descriptor == null) { + return _DecryptOutcome(filePath); + } + onStart?.call(descriptor.normalizedStrategy); + + if (storageMode == 'saf' && isContentUri(filePath)) { + if (downloadTreeUri == null || downloadTreeUri.isEmpty) { + return const _DecryptOutcome( + null, + failStage: DownloadQueueNotifier._decryptStageSafAccess, + ); + } + String? failStage; + var opStarted = false; + String? producedFileName; + final newUri = await _replaceSafFileVia( + uri: filePath, + treeUri: downloadTreeUri, + relativeDir: safRelativeDir, + op: (tempPath) async { + opStarted = true; + final decryptedTempPath = await FFmpegService.decryptWithDescriptor( + inputPath: tempPath, + descriptor: descriptor, + deleteOriginal: false, + ); + if (decryptedTempPath == null) { + failStage = DownloadQueueNotifier._decryptStageDecrypt; + return null; + } + if (repairAc4) { + try { + await PlatformBridge.ensureAC4Config(decryptedTempPath, tempPath); + } catch (e) { + _log.w('AC-4 container repair skipped: $e'); + } + } + final dotIndex = decryptedTempPath.lastIndexOf('.'); + final decryptedExt = dotIndex >= 0 + ? decryptedTempPath.substring(dotIndex).toLowerCase() + : extFallback; + const allowedExt = {'.flac', '.m4a', '.mp4', '.mp3', '.opus'}; + final finalExt = allowedExt.contains(decryptedExt) + ? decryptedExt + : extFallback; + final newFileName = '$baseName$finalExt'; + producedFileName = newFileName; + return (decryptedTempPath, newFileName); + }, + ); + if (newUri == null) { + return _DecryptOutcome( + null, + failStage: + failStage ?? + (opStarted + ? DownloadQueueNotifier._decryptStageSafWrite + : DownloadQueueNotifier._decryptStageSafAccess), + ); + } + return _DecryptOutcome(newUri, newFileName: producedFileName); + } + + if (repairAc4) { + final decryptedPath = await FFmpegService.decryptWithDescriptor( + inputPath: filePath, + descriptor: descriptor, + deleteOriginal: false, + ); + if (decryptedPath == null) { + try { + await deleteFile(filePath); + } catch (_) {} + return const _DecryptOutcome( + null, + failStage: DownloadQueueNotifier._decryptStageDecrypt, + ); + } + try { + await PlatformBridge.ensureAC4Config(decryptedPath, filePath); + } catch (e) { + _log.w('AC-4 container repair skipped: $e'); + } + try { + await deleteFile(filePath); + } catch (_) {} + return _DecryptOutcome(decryptedPath); + } + + final decryptedPath = await FFmpegService.decryptWithDescriptor( + inputPath: filePath, + descriptor: descriptor, + deleteOriginal: true, + ); + return _DecryptOutcome( + decryptedPath, + failStage: decryptedPath == null + ? DownloadQueueNotifier._decryptStageDecrypt + : null, + ); + } + + Future _finalizeNativeWorkerHighConversion({ + required _NativeWorkerRequestContext context, + required Map result, + required AppSettings settings, + required Track track, + required String filePath, + }) async { + if (context.quality != 'HIGH') { + return filePath; + } + + final lowerPath = filePath.toLowerCase(); + final resultFileName = (result['file_name'] as String?)?.toLowerCase(); + final looksLikeM4a = + lowerPath.endsWith('.m4a') || + lowerPath.endsWith('.mp4') || + (resultFileName != null && + (resultFileName.endsWith('.m4a') || + resultFileName.endsWith('.mp4'))); + if (!looksLikeM4a) { + return filePath; + } + + final tidalHighFormat = settings.tidalHighFormat; + final format = lossyFormatForSetting(tidalHighFormat); + final newExt = lossyExtensionForFormat(format); + final displayFormat = displayFormatForLossyFormat(format); + final bitrateDisplay = tidalHighFormat.contains('_') + ? '${tidalHighFormat.split('_').last}kbps' + : '320kbps'; + + Future embedConvertedMetadata(String convertedPath) async { + if (!settings.embedMetadata) return; + await _embedMetadataToFile( + convertedPath, + track, + format: metadataFormatForLossyFormat(format), + genre: result['genre'] as String?, + label: result['label'] as String?, + copyright: result['copyright'] as String?, + downloadService: context.item.service, + ); + } + + if (context.storageMode == 'saf' && isContentUri(filePath)) { + final treeUri = context.downloadTreeUri; + if (treeUri == null || treeUri.isEmpty) { + return null; + } + final rawFileName = + (result['file_name'] as String?) ?? context.safFileName ?? 'track'; + final baseName = rawFileName.replaceFirst(RegExp(r'\.[^.]+$'), ''); + final newFileName = '$baseName$newExt'; + final newUri = await _replaceSafFileVia( + uri: filePath, + treeUri: treeUri, + relativeDir: context.safRelativeDir ?? '', + op: (tempPath) async { + final convertedPath = await FFmpegService.convertM4aToLossy( + tempPath, + format: format, + bitrate: tidalHighFormat, + deleteOriginal: false, + ); + if (convertedPath == null) return null; + await embedConvertedMetadata(convertedPath); + return (convertedPath, newFileName); + }, + ); + if (newUri == null) { + return null; + } + result['file_name'] = newFileName; + result['_native_actual_quality'] = '$displayFormat $bitrateDisplay'; + return newUri; + } + + final convertedPath = await FFmpegService.convertM4aToLossy( + filePath, + format: format, + bitrate: tidalHighFormat, + deleteOriginal: true, + ); + if (convertedPath == null) { + return null; + } + await embedConvertedMetadata(convertedPath); + result['_native_actual_quality'] = '$displayFormat $bitrateDisplay'; + return convertedPath; + } + + Future _finalizeNativeWorkerContainerConversion({ + required _NativeWorkerRequestContext context, + required Map result, + required AppSettings settings, + required Track track, + required String filePath, + }) async { + if (context.quality == 'HIGH' || context.outputExt != '.flac') { + return filePath; + } + final resultAudioFormat = normalizeAudioFormatValue( + result['audio_codec']?.toString() ?? + result['actual_audio_codec']?.toString(), + ); + if (isLossyAudioFormat(resultAudioFormat)) { + _log.d( + 'Native-worker output is $resultAudioFormat; preserving native container.', + ); + return filePath; + } + final requiresContainerConversion = + result['requires_container_conversion'] == true || + result['requiresContainerConversion'] == true; + final resultOutputExt = _downloadResultOutputExt( + result, + filePath: filePath, + ); + final lowerPath = filePath.toLowerCase(); + final resultFileName = (result['file_name'] as String?)?.toLowerCase(); + final mayNeedContainerConversion = + requiresContainerConversion || + lowerPath.endsWith('.m4a') || + lowerPath.endsWith('.mp4') || + resultOutputExt == '.m4a' || + resultOutputExt == '.mp4' || + isContentUri(filePath); + if (!mayNeedContainerConversion) { + return filePath; + } + final requestedDecryptionExt = + DownloadDecryptionDescriptor.fromDownloadResult( + result, + )?.normalizedOutputExtension; + if (!requiresContainerConversion && + requestedDecryptionExt != null && + requestedDecryptionExt != '.flac') { + _log.d( + 'Native-worker decrypted output requested $requestedDecryptionExt; preserving native container.', + ); + return filePath; + } + final looksLikeM4a = + lowerPath.endsWith('.m4a') || + lowerPath.endsWith('.mp4') || + resultOutputExt == '.m4a' || + resultOutputExt == '.mp4' || + (resultFileName != null && + (resultFileName.endsWith('.m4a') || + resultFileName.endsWith('.mp4'))); + if (!requiresContainerConversion && + !looksLikeM4a && + !isContentUri(filePath)) { + return filePath; + } + + Future embedFlacMetadata(String flacPath) async { + if (!settings.embedMetadata) return; + await _embedMetadataToFile( + flacPath, + track, + format: 'flac', + genre: result['genre'] as String?, + label: result['label'] as String?, + copyright: result['copyright'] as String?, + downloadService: context.item.service, + writeExternalLrc: context.storageMode != 'saf', + ); + } + + if (context.storageMode == 'saf' && isContentUri(filePath)) { + final treeUri = context.downloadTreeUri; + if (treeUri == null || treeUri.isEmpty) { + return null; + } + var preserve = false; + String? producedFileName; + final newUri = await _replaceSafFileVia( + uri: filePath, + treeUri: treeUri, + relativeDir: context.safRelativeDir ?? '', + op: (tempPath) async { + 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.', + ); + preserve = true; + return null; + } + if (isAlreadyNativeFlac) { + _log.d( + 'Native FLAC payload detected in temporary container; publishing ' + 'as FLAC and embedding metadata.', + ); + await embedFlacMetadata(tempPath); + final rawFileName = + (result['file_name'] as String?) ?? + context.safFileName ?? + 'track'; + final baseName = rawFileName.replaceFirst(RegExp(r'\.[^.]+$'), ''); + final newFileName = '$baseName.flac'; + producedFileName = newFileName; + return (tempPath, newFileName); + } + final flacPath = await FFmpegService.convertM4aToFlac(tempPath); + if (flacPath == null) { + return null; + } + await embedFlacMetadata(flacPath); + final rawFileName = + (result['file_name'] as String?) ?? + context.safFileName ?? + 'track'; + final baseName = rawFileName.replaceFirst(RegExp(r'\.[^.]+$'), ''); + final newFileName = '$baseName.flac'; + producedFileName = newFileName; + return (flacPath, newFileName); + }, + ); + if (preserve) { + return filePath; + } + if (newUri == null) { + return null; + } + result['file_name'] = producedFileName; + return newUri; + } + + final codec = await FFmpegService.probePrimaryAudioCodec(filePath); + final isAlreadyNativeFlac = + codec == 'flac' && await FFmpegService.isNativeFlacFile(filePath); + if (!FFmpegService.isLosslessAudioCodec(codec)) { + _log.d( + 'Preserving native container; audio codec is ${codec ?? 'unknown'}, ' + 'no FLAC container conversion needed.', + ); + return filePath; + } + if (isAlreadyNativeFlac) { + var flacPath = filePath; + if (!filePath.toLowerCase().endsWith('.flac')) { + final renamedPath = filePath.replaceAll(RegExp(r'\.[^.]+$'), '.flac'); + final targetPath = renamedPath == filePath + ? '$filePath.flac' + : renamedPath; + await File(filePath).rename(targetPath); + flacPath = targetPath; + } + await embedFlacMetadata(flacPath); + return flacPath; + } + final flacPath = await FFmpegService.convertM4aToFlac(filePath); + if (flacPath == null) { + return null; + } + await embedFlacMetadata(flacPath); + return flacPath; + } + + /// Shared external-LRC finalize used by both the inline single-item + /// pipeline (SAF only; the local-file case is already handled during + /// metadata embedding) and the native-worker pipeline (both storage + /// modes). [resolveBaseName] and [onFetchError] are each caller's own + /// base-name fallback chain and fetch-failure log line, evaluated lazily + /// to match the original call sites exactly. + Future _saveExternalLrc({ + required Map result, + required AppSettings settings, + required ExtensionState extensionState, + required Track track, + required String service, + required String filePath, + required String storageMode, + String? downloadTreeUri, + required String safRelativeDir, + required Future Function() resolveBaseName, + required void Function(Object e) onFetchError, + }) async { + final lyricsMode = settings.lyricsMode; + final shouldSaveExternalLrc = + settings.embedMetadata && + settings.embedLyrics && + !_shouldSkipLyrics(extensionState, track.source, service) && + (lyricsMode == 'external' || lyricsMode == 'both'); + if (!shouldSaveExternalLrc) { + return; + } + + String? lrcContent = result['lyrics_lrc'] as String?; + if (lrcContent == null || lrcContent.isEmpty) { + try { + lrcContent = await PlatformBridge.getLyricsLRC( + track.id, + track.name, + track.artistName, + durationMs: track.duration * 1000, + ); + } catch (e) { + onFetchError(e); + } + } + if (lrcContent == null || lrcContent.isEmpty) { + return; + } + + if (storageMode == 'saf' && isContentUri(filePath)) { + if (downloadTreeUri == null || downloadTreeUri.isEmpty) { + return; + } + final baseName = await resolveBaseName(); + await _writeLrcToSaf( + treeUri: downloadTreeUri, + relativeDir: safRelativeDir, + baseName: baseName, + lrcContent: lrcContent, + ); + return; + } + + try { + final lrcPath = filePath.replaceAll(RegExp(r'\.[^.]+$'), '.lrc'); + final safeLrcPath = lrcPath == filePath ? '$filePath.lrc' : lrcPath; + await File(safeLrcPath).writeAsString(lrcContent); + _log.d('Native-worker external LRC saved: $safeLrcPath'); + } catch (e) { + _log.w('Failed to save native-worker external LRC: $e'); + } + } +} diff --git a/lib/providers/download_queue_provider_native_worker.dart b/lib/providers/download_queue_provider_native_worker.dart new file mode 100644 index 00000000..a996acdc --- /dev/null +++ b/lib/providers/download_queue_provider_native_worker.dart @@ -0,0 +1,1139 @@ +// ignore_for_file: invalid_use_of_protected_member, invalid_use_of_visible_for_testing_member +part of 'download_queue_provider.dart'; + +class _NativeWorkerRequestContext { + final DownloadItem item; + final String requestJson; + final String outputDir; + final String quality; + final String storageMode; + final String outputExt; + final String? downloadTreeUri; + final String? safRelativeDir; + final String? safFileName; + + const _NativeWorkerRequestContext({ + required this.item, + required this.requestJson, + required this.outputDir, + required this.quality, + required this.storageMode, + required this.outputExt, + this.downloadTreeUri, + this.safRelativeDir, + this.safFileName, + }); +} + +class _NativeWorkerStartupTimeout implements Exception { + @override + String toString() => 'Native worker did not publish run snapshot'; +} + +extension _DownloadQueueNativeWorker on DownloadQueueNotifier { + bool _canUseAndroidNativeWorker(AppSettings settings) { + if (!Platform.isAndroid || !settings.nativeDownloadWorkerEnabled) { + return false; + } + if (settings.concurrentDownloads > 1) { + // The experimental native worker downloads strictly sequentially, so + // prefer the Dart queue when the user enabled concurrent downloads. + _log.i('Concurrent downloads enabled; skipping native worker'); + return false; + } + if (!settings.useExtensionProviders) { + return false; + } + if (_isSafMode(settings)) { + if (settings.downloadTreeUri.isEmpty) { + return false; + } + } + final extensionState = ref.read(extensionProvider); + final hasEnabledDownloadProvider = extensionState.extensions.any( + (extension) => extension.enabled && extension.hasDownloadProvider, + ); + if (!hasEnabledDownloadProvider) { + return false; + } + return true; + } + + String _newNativeWorkerRunId() => + 'native-${DateTime.now().microsecondsSinceEpoch}-${Random().nextInt(1 << 32)}'; + + String _snapshotRunId(Map snapshot) { + final direct = snapshot['run_id']?.toString() ?? ''; + if (direct.isNotEmpty) return direct; + + final settingsJson = snapshot['settings_json']; + if (settingsJson is String && settingsJson.isNotEmpty) { + try { + final decoded = jsonDecode(settingsJson); + if (decoded is Map) { + return decoded['run_id']?.toString() ?? ''; + } + } catch (_) {} + } else if (settingsJson is Map) { + return settingsJson['run_id']?.toString() ?? ''; + } + return ''; + } + + bool _isNativeWorkerSnapshotContractCompatible( + Map snapshot, + ) { + final version = snapshot['contract_version']; + return version == DownloadRequestPayload.nativeWorkerContractVersion; + } + + /// Serial of the last fully-processed state snapshot, echoed back to the + /// native side so steady-state polls skip the per-item payload. Falls back + /// to the previous value for snapshots without the field. + int _snapshotStateSerial(Map snapshot, int previous) { + final serial = snapshot['state_serial']; + if (serial is num && serial > 0) { + return serial.toInt(); + } + return previous; + } + + bool _isNativeWorkerSnapshotForRun( + Map snapshot, + String runId, + ) => + runId.isNotEmpty && + _snapshotRunId(snapshot) == runId && + _isNativeWorkerSnapshotContractCompatible(snapshot); + + Future _persistNativeWorkerRunId(String runId) async { + _activeNativeWorkerRunId = runId; + final prefs = await SharedPreferences.getInstance(); + await prefs.setString( + DownloadQueueNotifier._nativeWorkerRunIdPrefsKey, + runId, + ); + } + + Future _loadNativeWorkerRunId() async { + if (_activeNativeWorkerRunId != null) return _activeNativeWorkerRunId; + final prefs = await SharedPreferences.getInstance(); + final runId = prefs.getString( + DownloadQueueNotifier._nativeWorkerRunIdPrefsKey, + ); + if (runId != null && runId.isNotEmpty) { + _activeNativeWorkerRunId = runId; + return runId; + } + return null; + } + + Future _clearNativeWorkerRunId(String runId) async { + if (_activeNativeWorkerRunId == runId) { + _activeNativeWorkerRunId = null; + } + final prefs = await SharedPreferences.getInstance(); + if (prefs.getString(DownloadQueueNotifier._nativeWorkerRunIdPrefsKey) == + runId) { + await prefs.remove(DownloadQueueNotifier._nativeWorkerRunIdPrefsKey); + } + } + + Future _tryAdoptAndroidNativeWorkerSnapshot( + List restoredItems, + ) async { + final settings = ref.read(settingsProvider); + // Adoption deliberately skips _canUseAndroidNativeWorker: that gate reads + // extension state that loads asynchronously after startup, and toggles the + // user may have flipped mid-run. If a native batch is already running we + // must reconcile with it regardless, or the Dart queue would download the + // same items a second time alongside it. + if (!Platform.isAndroid) { + return false; + } + + Map snapshot; + try { + snapshot = await PlatformBridge.getNativeDownloadWorkerSnapshot(); + } catch (_) { + return false; + } + final runId = await _loadNativeWorkerRunId(); + if (runId == null || + runId.isEmpty || + !_isNativeWorkerSnapshotForRun(snapshot, runId)) { + return false; + } + + final rawItems = snapshot['items']; + final rawItemIds = snapshot['item_ids']; + final snapshotIds = rawItems is List + ? rawItems + .whereType>() + .map((item) => item['item_id']?.toString() ?? '') + .where((id) => id.isNotEmpty) + .toSet() + : rawItemIds is List + ? rawItemIds + .map((id) => id?.toString() ?? '') + .where((id) => id.isNotEmpty) + .toSet() + : {}; + if (snapshotIds.isEmpty) { + return false; + } + if (!restoredItems.any((item) => snapshotIds.contains(item.id))) { + return false; + } + + final contexts = {}; + final pendingContextIds = {}; + for (final item in restoredItems) { + if (!snapshotIds.contains(item.id)) continue; + final context = await _buildAndroidNativeWorkerRequest(item, settings); + if (context != null) { + contexts[item.id] = context; + } else { + // Extensions may still be loading this early in startup; remember the + // item and retry building its context on later reconcile polls. + pendingContextIds.add(item.id); + } + } + + final snapshotClaimsRunning = snapshot['is_running'] == true; + if (snapshotClaimsRunning && !await _isNativeWorkerServiceAlive()) { + // The service died mid-batch (process kill/reboot): the snapshot is + // frozen at is_running=true and nothing will ever update it. Reconcile + // what the worker managed to finish, requeue the rest, and let the + // caller fall back to the Dart queue. + _log.w( + 'Native worker snapshot claims running but the service is dead; reclaiming run $runId', + ); + if (contexts.isNotEmpty) { + await _applyAndroidNativeWorkerSnapshot( + snapshot, + contexts, + {}, + settings, + ); + } + _requeueInFlightNativeWorkerItems(snapshotIds); + await _clearNativeWorkerRunId(runId); + return false; + } + if (contexts.isEmpty && pendingContextIds.isEmpty) { + return false; + } + + _log.i('Adopting Android native worker snapshot'); + final reconciledIds = {}; + _totalQueuedAtStart = contexts.length + pendingContextIds.length; + _completedInSession = 0; + _failedInSession = 0; + state = state.copyWith( + isProcessing: snapshot['is_running'] == true, + isPaused: snapshot['is_paused'] == true, + ); + await _applyAndroidNativeWorkerSnapshot( + snapshot, + contexts, + reconciledIds, + settings, + ); + + if (snapshot['is_running'] == true) { + unawaited( + _continueAndroidNativeWorkerAdoption( + contexts, + pendingContextIds, + reconciledIds, + settings, + runId, + ), + ); + } else if (state.items.any( + (item) => item.status == DownloadStatus.queued, + )) { + await _clearNativeWorkerRunId(runId); + Future.microtask(() => _processQueue()); + } else { + await _clearNativeWorkerRunId(runId); + } + + return true; + } + + Future _isNativeWorkerServiceAlive() async { + try { + return await PlatformBridge.isDownloadServiceRunning(); + } catch (_) { + return false; + } + } + + /// Puts items the dead native worker left mid-flight back into the queue so + /// the Dart queue can pick them up. + void _requeueInFlightNativeWorkerItems(Set itemIds) { + for (final id in itemIds) { + final current = _findItemById(id); + if (current == null) continue; + if (current.status == DownloadStatus.downloading || + current.status == DownloadStatus.finalizing) { + updateItemStatus(id, DownloadStatus.queued, progress: 0.0); + } + } + } + + Future _rebuildPendingNativeWorkerContexts( + Map contexts, + Set pendingContextIds, + AppSettings settings, + ) async { + if (pendingContextIds.isEmpty) return; + final resolved = {}; + for (final id in pendingContextIds) { + final item = _findItemById(id); + if (item == null) { + resolved.add(id); + continue; + } + final context = await _buildAndroidNativeWorkerRequest(item, settings); + if (context != null) { + contexts[id] = context; + resolved.add(id); + } + } + pendingContextIds.removeAll(resolved); + } + + Future _continueAndroidNativeWorkerAdoption( + Map contexts, + Set pendingContextIds, + Set reconciledIds, + AppSettings settings, + String runId, + ) async { + var deadServicePolls = 0; + var lastStateSerial = 0; + try { + while (true) { + final snapshot = await PlatformBridge.getNativeDownloadWorkerSnapshot( + sinceStateSerial: lastStateSerial, + ); + final matchesRun = _isNativeWorkerSnapshotForRun(snapshot, runId); + if (!matchesRun || snapshot['is_running'] == true) { + if (await _isNativeWorkerServiceAlive()) { + deadServicePolls = 0; + } else { + deadServicePolls++; + if (deadServicePolls >= 3) { + _log.w( + 'Native worker run $runId died without a final snapshot; reclaiming queue', + ); + if (matchesRun) { + await _applyAndroidNativeWorkerSnapshot( + snapshot, + contexts, + reconciledIds, + settings, + ); + } + _requeueInFlightNativeWorkerItems({ + ...contexts.keys, + ...pendingContextIds, + }); + await _clearNativeWorkerRunId(runId); + if (state.items.any( + (item) => item.status == DownloadStatus.queued, + )) { + Future.microtask(() => _processQueue()); + } + return; + } + } + } + if (!matchesRun) { + lastStateSerial = 0; + await Future.delayed(const Duration(seconds: 1)); + continue; + } + await _rebuildPendingNativeWorkerContexts( + contexts, + pendingContextIds, + settings, + ); + await _applyAndroidNativeWorkerSnapshot( + snapshot, + contexts, + reconciledIds, + settings, + ); + lastStateSerial = _snapshotStateSerial(snapshot, lastStateSerial); + if (snapshot['is_running'] != true) { + await _clearNativeWorkerRunId(runId); + // Items may have been requeued during reconciliation (e.g. batch + // mates of a verification challenge); hand them to the queue. + if (!state.isPaused && + state.items.any((item) => item.status == DownloadStatus.queued)) { + Future.microtask(() => _processQueue()); + } + break; + } + await Future.delayed(const Duration(seconds: 1)); + } + } catch (e) { + _log.w('Android native worker adoption stopped: $e'); + } finally { + state = state.copyWith(isProcessing: false, currentDownload: null); + } + } + + Future _tryProcessQueueWithAndroidNativeWorker( + AppSettings settings, + ) async { + if (!_canUseAndroidNativeWorker(settings)) { + return false; + } + + final queuedItems = state.items + .where((item) => item.status == DownloadStatus.queued) + .toList(growable: false); + if (queuedItems.isEmpty) { + return false; + } + + _log.i( + 'Starting Android native download worker for ${queuedItems.length} items', + ); + + final isSafMode = _isSafMode(settings); + if (!isSafMode && state.outputDir.isEmpty) { + await _initOutputDir(); + } + if (!isSafMode && state.outputDir.isEmpty) { + final musicDir = await _ensureDefaultDocumentsOutputDir(); + state = state.copyWith(outputDir: musicDir.path); + } + + final contexts = {}; + final requests = >[]; + for (final item in queuedItems) { + final context = await _buildAndroidNativeWorkerRequest(item, settings); + if (context == null) { + _log.w( + 'Native worker gate rejected ${item.track.name}; falling back to Dart queue', + ); + return false; + } + contexts[item.id] = context; + requests.add({ + 'contract_version': DownloadRequestPayload.nativeWorkerContractVersion, + 'item_id': item.id, + 'track_name': item.track.name, + 'artist_name': item.track.artistName, + 'item_json': jsonEncode(item.toJson()), + 'request_json': context.requestJson, + }); + } + + state = state.copyWith(isProcessing: true, isPaused: false); + _totalQueuedAtStart = queuedItems.length; + _completedInSession = 0; + _failedInSession = 0; + + final runId = _newNativeWorkerRunId(); + await _persistNativeWorkerRunId(runId); + final reconciledIds = {}; + try { + await PlatformBridge.startNativeDownloadWorker( + requests: requests, + settings: { + 'worker': 'android_native', + 'version': 1, + 'contract_version': + DownloadRequestPayload.nativeWorkerContractVersion, + 'run_id': runId, + 'created_at': DateTime.now().toIso8601String(), + 'save_download_history': settings.saveDownloadHistory, + }, + ); + + final runStartWait = Stopwatch()..start(); + var lastStateSerial = 0; + while (true) { + final snapshot = await PlatformBridge.getNativeDownloadWorkerSnapshot( + sinceStateSerial: lastStateSerial, + ); + if (!_isNativeWorkerSnapshotForRun(snapshot, runId)) { + lastStateSerial = 0; + if (runStartWait.elapsed > const Duration(seconds: 30)) { + throw _NativeWorkerStartupTimeout(); + } + await Future.delayed(const Duration(milliseconds: 250)); + continue; + } + await _applyAndroidNativeWorkerSnapshot( + snapshot, + contexts, + reconciledIds, + settings, + ); + lastStateSerial = _snapshotStateSerial(snapshot, lastStateSerial); + if (snapshot['is_running'] != true) { + await _clearNativeWorkerRunId(runId); + break; + } + await Future.delayed(const Duration(seconds: 1)); + } + } catch (e, stack) { + if (e is _NativeWorkerStartupTimeout) { + _log.w( + 'Android native worker did not publish a matching snapshot; cancelling native worker and falling back to Dart queue', + ); + try { + await PlatformBridge.cancelNativeDownloadWorker(); + } catch (cancelError) { + _log.w('Failed to cancel timed-out native worker: $cancelError'); + } + await _clearNativeWorkerRunId(runId); + state = state.copyWith(isProcessing: false, currentDownload: null); + await Future.delayed(const Duration(milliseconds: 500)); + return false; + } + _log.e('Android native worker failed: $e', e, stack); + // The native worker keeps downloading on its own; without cancelling + // it here the batch we just marked failed would continue in the + // background, its completions never reconciled, and the Dart queue + // could even restart alongside it. + try { + await PlatformBridge.cancelNativeDownloadWorker(); + } catch (cancelError) { + _log.w('Failed to cancel native worker after error: $cancelError'); + } + await _clearNativeWorkerRunId(runId); + for (final item in queuedItems) { + final current = _findItemById(item.id); + if (current == null || + current.status == DownloadStatus.completed || + current.status == DownloadStatus.failed || + current.status == DownloadStatus.skipped) { + continue; + } + updateItemStatus( + item.id, + DownloadStatus.failed, + error: 'Native download worker failed: $e', + errorType: DownloadErrorType.unknown, + ); + _failedInSession++; + } + } finally { + state = state.copyWith(isProcessing: false, currentDownload: null); + _stopConnectivityMonitoring(); + try { + await PlatformBridge.cleanupConnections(); + } catch (e) { + _log.e('Native worker cleanup failed: $e'); + } + } + + if (_totalQueuedAtStart > 0) { + await _notificationService.showQueueComplete( + completedCount: _completedInSession, + failedCount: _failedInSession, + ); + } + + final hasQueuedItems = state.items.any( + (item) => item.status == DownloadStatus.queued, + ); + if (hasQueuedItems && !state.isPaused) { + _log.i( + 'Found queued items after Android native worker finished, restarting queue...', + ); + Future.microtask(() => _processQueue()); + } + + return true; + } + + Future<_NativeWorkerRequestContext?> _buildAndroidNativeWorkerRequest( + DownloadItem item, + AppSettings settings, + ) async { + if (!_hasActiveDownloadProvider(item.service)) { + return null; + } + + var quality = item.qualityOverride ?? state.audioQuality; + if (quality == 'DEFAULT') quality = state.audioQuality; + + final isSafMode = _isSafMode(settings); + final rawOutputDir = isSafMode + ? await _buildRelativeOutputDir( + item.track, + settings.folderOrganization, + separateSingles: settings.separateSingles, + albumFolderStructure: settings.albumFolderStructure, + createPlaylistFolder: settings.createPlaylistFolder, + useAlbumArtistForFolders: settings.useAlbumArtistForFolders, + usePrimaryArtistOnly: settings.usePrimaryArtistOnly, + filterContributingArtistsInAlbumArtist: + settings.filterContributingArtistsInAlbumArtist, + playlistName: item.playlistName, + ) + : await _buildOutputDir( + item.track, + settings.folderOrganization, + separateSingles: settings.separateSingles, + albumFolderStructure: settings.albumFolderStructure, + createPlaylistFolder: settings.createPlaylistFolder, + useAlbumArtistForFolders: settings.useAlbumArtistForFolders, + usePrimaryArtistOnly: settings.usePrimaryArtistOnly, + filterContributingArtistsInAlbumArtist: + settings.filterContributingArtistsInAlbumArtist, + playlistName: item.playlistName, + ); + final outputDir = isSafMode + ? _sanitizeSafRelativeDir(rawOutputDir) + : rawOutputDir; + if (!isSafMode) { + await _ensureDirExists(outputDir, label: 'Output folder'); + } + + final outputExt = _determineOutputExt(quality, item.service); + if (settings.embedReplayGain && + outputExt != '.flac' && + outputExt != '.m4a') { + return null; + } + + String? safFileName; + final safOutputExt = isSafMode ? outputExt : ''; + final baseFilenameFormat = _shouldTreatAsSingleRelease(item.track) + ? state.singleFilenameFormat + : state.filenameFormat; + final effectiveFilenameFormat = _filenameFormatForItem( + item, + baseFilenameFormat, + ); + if (isSafMode) { + final baseName = await PlatformBridge.buildFilename( + effectiveFilenameFormat, + _filenameMetadataForTrack( + item.track, + playlistPosition: _validPlaylistPosition(item), + ), + ); + safFileName = await _buildSafFileName(baseName, safOutputExt); + } + + var trackForPayload = item.track; + String? nativeDeezerTrackId = await _resolveDeezerIdFromKnownOrIsrc( + trackForPayload, + item.id, + lookupContext: 'native worker ISRC', + ); + final providerResolved = await _resolveDeezerIdViaProviderIfNeeded( + trackForPayload, + nativeDeezerTrackId, + item.id, + ); + trackForPayload = providerResolved.track; + nativeDeezerTrackId = providerResolved.deezerTrackId; + + final extendedMetadata = await _loadExtendedMetadataForDeezerId( + nativeDeezerTrackId, + ); + + final extensionState = ref.read(extensionProvider); + final payload = _buildDownloadRequestPayload( + track: trackForPayload, + item: item, + settings: settings, + extensionState: extensionState, + quality: quality, + filenameFormat: effectiveFilenameFormat, + outputDir: outputDir, + outputExt: outputExt, + useSaf: isSafMode, + safFileName: safFileName, + deezerTrackId: nativeDeezerTrackId, + genre: extendedMetadata?.genre, + label: extendedMetadata?.label, + copyright: extendedMetadata?.copyright, + stageSafForDeferredPublish: isSafMode, + ).withStrategy(useExtensions: true, useFallback: state.autoFallback); + + return _NativeWorkerRequestContext( + item: item, + requestJson: jsonEncode(payload.toJson()), + outputDir: outputDir, + quality: quality, + storageMode: isSafMode ? 'saf' : 'app', + outputExt: outputExt, + downloadTreeUri: isSafMode ? settings.downloadTreeUri : null, + safRelativeDir: isSafMode ? outputDir : null, + safFileName: safFileName, + ); + } + + Future _applyAndroidNativeWorkerSnapshot( + Map snapshot, + Map contexts, + Set reconciledIds, + AppSettings settings, + ) async { + final rawItems = snapshot['items']; + final rawDelta = snapshot['item_delta']; + final itemSnapshots = >[]; + if (rawItems is List) { + for (final rawItem in rawItems) { + if (rawItem is Map) { + itemSnapshots.add(Map.from(rawItem)); + } + } + } + if (rawDelta is Map) { + itemSnapshots.add(Map.from(rawDelta)); + } + if (itemSnapshots.isEmpty) { + return; + } + + for (final itemSnapshot in itemSnapshots) { + final itemId = itemSnapshot['item_id']?.toString() ?? ''; + if (itemId.isEmpty || reconciledIds.contains(itemId)) { + continue; + } + final context = contexts[itemId]; + if (context == null) continue; + + final status = itemSnapshot['status']?.toString() ?? 'queued'; + final progress = ((itemSnapshot['progress'] as num?)?.toDouble() ?? 0.0) + .clamp(0.0, 1.0) + .toDouble(); + final current = _findItemById(itemId); + if (current == null) { + reconciledIds.add(itemId); + continue; + } + + if (status == 'queued') { + updateItemStatus(itemId, DownloadStatus.queued, progress: 0.0); + continue; + } + + if (status == 'preparing') { + updateItemStatus(itemId, DownloadStatus.downloading, progress: 0.0); + continue; + } + + if (status == 'downloading') { + updateItemStatus( + itemId, + DownloadStatus.downloading, + progress: progress, + ); + continue; + } + + if (status == 'finalizing') { + updateItemStatus( + itemId, + DownloadStatus.finalizing, + progress: progress <= 0 ? 0.95 : progress, + ); + continue; + } + + if (status == 'completed') { + final result = itemSnapshot['result']; + if (result is Map) { + reconciledIds.add(itemId); + await _completeAndroidNativeWorkerItem( + context, + Map.from(result), + settings, + ); + } + continue; + } + + if (status == 'failed' || status == 'skipped') { + reconciledIds.add(itemId); + final result = itemSnapshot['result']; + final error = itemSnapshot['error']?.toString(); + if (status == 'skipped') { + updateItemStatus(itemId, DownloadStatus.skipped); + } else { + final resultMap = result is Map + ? Map.from(result) + : null; + final errorMsg = (error == null || error.isEmpty) + ? (resultMap?['error']?.toString() ?? 'Download failed') + : error; + final backendErrorType = resultMap == null + ? DownloadErrorType.unknown + : _downloadErrorTypeFromBackend( + resultMap['error_type']?.toString(), + ); + final errorType = backendErrorType == DownloadErrorType.unknown + ? _downloadErrorTypeFromMessage(errorMsg) + : backendErrorType; + if (errorType == DownloadErrorType.verificationRequired) { + _log.i( + 'Android native worker requires verification for ${current.track.name}; switching back to interactive queue', + ); + // Cancelling the native worker marks every remaining batch item + // "skipped" on the native side. Those items did nothing wrong — + // remember them now and requeue them below so the batch resumes + // after the challenge instead of being abandoned. + final pendingBatchIds = []; + for (final pendingId in contexts.keys) { + if (pendingId == itemId || reconciledIds.contains(pendingId)) { + continue; + } + final pending = _findItemById(pendingId); + if (pending == null) continue; + if (pending.status == DownloadStatus.queued || + pending.status == DownloadStatus.downloading || + pending.status == DownloadStatus.finalizing) { + pendingBatchIds.add(pendingId); + } + } + try { + await PlatformBridge.cancelNativeDownloadWorker(); + } catch (e) { + _log.w('Failed to cancel native worker before verification: $e'); + } + for (final pendingId in pendingBatchIds) { + reconciledIds.add(pendingId); + updateItemStatus(pendingId, DownloadStatus.queued, progress: 0.0); + } + await _handleVerificationRequiredDownload( + current, + errorMsg, + _nativeWorkerVerificationService(resultMap, context), + ); + continue; + } + updateItemStatus( + itemId, + DownloadStatus.failed, + error: errorMsg, + errorType: errorType, + ); + _failedInSession++; + } + } + } + } + + Future _completeAndroidNativeWorkerItem( + _NativeWorkerRequestContext context, + Map result, + AppSettings settings, + ) async { + final item = context.item; + var filePath = result['file_path'] as String?; + if (filePath == null || filePath.isEmpty) { + updateItemStatus( + item.id, + DownloadStatus.failed, + error: 'Native worker completed without a file path', + errorType: DownloadErrorType.unknown, + ); + _failedInSession++; + return; + } + + if (result['native_finalized'] == true) { + updateItemStatus( + item.id, + DownloadStatus.completed, + progress: 1.0, + filePath: filePath, + ); + if (settings.saveDownloadHistory) { + final historyItem = result['history_item']; + if (historyItem is Map) { + try { + ref + .read(downloadHistoryProvider.notifier) + .adoptNativeHistoryItem( + DownloadHistoryItem.fromJson( + Map.from(historyItem), + ), + ); + } catch (e) { + _log.w('Failed to adopt native history item: $e'); + await ref + .read(downloadHistoryProvider.notifier) + .reloadFromStorage(); + } + } else if (result['history_written'] == true) { + await ref.read(downloadHistoryProvider.notifier).reloadFromStorage(); + } + } + _completedInSession++; + await _notificationService.showDownloadComplete( + trackName: item.track.name, + artistName: item.track.artistName, + completedCount: _completedInSession, + totalCount: _totalQueuedAtStart, + alreadyInLibrary: result['already_exists'] == true, + ); + removeItem(item.id); + return; + } + + final rawDecryptFileName = + (result['file_name'] as String?) ?? context.safFileName ?? 'track'; + final decryptOutcome = await _finalizeDecryption( + result: result, + filePath: filePath, + storageMode: context.storageMode, + downloadTreeUri: context.downloadTreeUri, + safRelativeDir: context.safRelativeDir ?? '', + baseName: rawDecryptFileName.replaceFirst(RegExp(r'\.[^.]+$'), ''), + extFallback: context.outputExt, + repairAc4: false, + onStart: (strategy) => _log.i( + 'Native-worker encrypted stream detected, decrypting via $strategy...', + ), + ); + if (decryptOutcome.path == null) { + updateItemStatus( + item.id, + DownloadStatus.failed, + error: 'Failed to decrypt encrypted stream', + errorType: DownloadErrorType.unknown, + ); + _failedInSession++; + return; + } + filePath = decryptOutcome.path!; + if (decryptOutcome.newFileName != null) { + result['file_name'] = decryptOutcome.newFileName; + } + + var actualQuality = context.quality; + final actualBitDepth = result['actual_bit_depth'] as int?; + final actualSampleRate = result['actual_sample_rate'] as int?; + final actualFormat = + normalizeAudioFormatValue( + result['audio_codec']?.toString() ?? result['format']?.toString(), + ) ?? + normalizeAudioFormatValue(audioFormatForPath(filePath)); + final actualBitrate = isLossyAudioFormat(actualFormat) + ? readPositiveBitrateKbps(result['bitrate'] ?? result['actual_bitrate']) + : null; + final resolvedQuality = resolveDisplayQuality( + filePath: filePath, + detectedFormat: actualFormat, + bitDepth: actualBitDepth, + sampleRate: actualSampleRate, + bitrateKbps: actualBitrate, + storedQuality: actualQuality, + ); + if (resolvedQuality != null) { + actualQuality = resolvedQuality; + } + + final resolvedAlbumArtist = _resolveAlbumArtistForMetadata( + item.track, + settings, + ); + final trackToDownload = _buildTrackForMetadataEmbedding( + item.track, + result, + resolvedAlbumArtist, + ); + final convertedHighPath = await _finalizeNativeWorkerHighConversion( + context: context, + result: result, + settings: settings, + track: trackToDownload, + filePath: filePath, + ); + if (convertedHighPath == null) { + updateItemStatus( + item.id, + DownloadStatus.failed, + error: 'Failed to convert HIGH quality download', + errorType: DownloadErrorType.unknown, + ); + _failedInSession++; + return; + } + filePath = convertedHighPath; + final nativeActualQuality = result['_native_actual_quality'] as String?; + if (nativeActualQuality != null && nativeActualQuality.isNotEmpty) { + actualQuality = nativeActualQuality; + } + final convertedContainerPath = + await _finalizeNativeWorkerContainerConversion( + context: context, + result: result, + settings: settings, + track: trackToDownload, + filePath: filePath, + ); + if (convertedContainerPath == null) { + updateItemStatus( + item.id, + DownloadStatus.failed, + error: 'Failed to convert downloaded container', + errorType: DownloadErrorType.unknown, + ); + _failedInSession++; + return; + } + filePath = convertedContainerPath; + + updateItemStatus( + item.id, + DownloadStatus.completed, + progress: 1.0, + filePath: filePath, + ); + await _saveExternalLrc( + result: result, + settings: settings, + extensionState: ref.read(extensionProvider), + track: trackToDownload, + service: context.item.service, + filePath: filePath, + storageMode: context.storageMode, + downloadTreeUri: context.downloadTreeUri, + safRelativeDir: context.safRelativeDir ?? '', + resolveBaseName: () async { + final resultFileName = result['file_name'] as String?; + final fileName = (resultFileName != null && resultFileName.isNotEmpty) + ? resultFileName + : context.safFileName; + return fileName != null && fileName.isNotEmpty + ? fileName.replaceFirst(RegExp(r'\.[^.]+$'), '') + : await PlatformBridge.sanitizeFilename( + '${trackToDownload.artistName} - ${trackToDownload.name}', + ); + }, + onFetchError: (e) => + _log.w('Failed to fetch native-worker external LRC: $e'), + ); + final postProcessedPath = await _runPostProcessingHooks( + filePath, + trackToDownload, + ); + if (postProcessedPath != null && postProcessedPath.isNotEmpty) { + filePath = postProcessedPath; + } + await _writeNativeWorkerReplayGain( + context: context, + settings: settings, + track: trackToDownload, + filePath: filePath, + ); + _completedInSession++; + + await _notificationService.showDownloadComplete( + trackName: item.track.name, + artistName: item.track.artistName, + completedCount: _completedInSession, + totalCount: _totalQueuedAtStart, + alreadyInLibrary: result['already_exists'] == true, + ); + + final resultSafFileName = result['file_name'] as String?; + final lowerFilePath = filePath.toLowerCase(); + final isLossyOutput = + isLossyAudioFormat(actualFormat) || + lowerFilePath.endsWith('.mp3') || + lowerFilePath.endsWith('.opus') || + lowerFilePath.endsWith('.ogg'); + + if (settings.saveDownloadHistory) { + ref + .read(downloadHistoryProvider.notifier) + .addToHistory( + _historyItemFromResult( + item: item, + trackToDownload: trackToDownload, + result: result, + filePath: filePath, + quality: actualQuality, + useSaf: context.storageMode == 'saf', + downloadTreeUri: context.downloadTreeUri, + safRelativeDir: context.safRelativeDir, + safFileName: + (resultSafFileName != null && resultSafFileName.isNotEmpty) + ? resultSafFileName + : context.safFileName, + bitDepth: isLossyOutput ? null : actualBitDepth, + sampleRate: isLossyOutput ? null : actualSampleRate, + bitrate: actualBitrate, + format: actualFormat, + genre: normalizeOptionalString(result['genre'] as String?), + label: normalizeOptionalString(result['label'] as String?), + copyright: normalizeOptionalString( + result['copyright'] as String?, + ), + ), + ); + } + + removeItem(item.id); + } + + Future _writeNativeWorkerReplayGain({ + required _NativeWorkerRequestContext context, + required AppSettings settings, + required Track track, + required String filePath, + }) async { + if (!settings.embedReplayGain) { + return; + } + if (context.outputExt != '.flac' && context.outputExt != '.m4a') { + return; + } + + try { + final rgResult = await FFmpegService.scanReplayGain(filePath); + if (rgResult == null) { + return; + } + await PlatformBridge.editFileMetadata(filePath, { + 'replaygain_track_gain': rgResult.trackGain, + 'replaygain_track_peak': rgResult.trackPeak, + }); + _storeTrackReplayGainForAlbum(track, filePath, rgResult); + _updateAlbumRgFilePath(track, filePath); + await _checkAndWriteAlbumReplayGain(track); + _log.d( + 'Native-worker ReplayGain written: gain=${rgResult.trackGain}, peak=${rgResult.trackPeak}', + ); + } catch (e) { + _log.w('Failed to write native-worker ReplayGain: $e'); + } + } + + String _nativeWorkerVerificationService( + Map? result, + _NativeWorkerRequestContext context, + ) { + if (result != null) { + for (final key in const [ + 'service', + 'verification_service', + 'provider', + 'source', + ]) { + final value = result[key]?.toString().trim() ?? ''; + if (value.isNotEmpty) return value; + } + } + return context.item.service; + } +} diff --git a/lib/providers/download_queue_provider_paths.dart b/lib/providers/download_queue_provider_paths.dart new file mode 100644 index 00000000..58457546 --- /dev/null +++ b/lib/providers/download_queue_provider_paths.dart @@ -0,0 +1,564 @@ +// ignore_for_file: invalid_use_of_protected_member, invalid_use_of_visible_for_testing_member +part of 'download_queue_provider.dart'; + +extension _DownloadQueuePaths on DownloadQueueNotifier { + Directory _defaultDocumentsOutputDir(String documentsPath) { + return Directory('$documentsPath/$_defaultOutputFolderName'); + } + + Directory _defaultAndroidMusicOutputDir(String storageRootPath) { + return Directory('$storageRootPath/$_defaultAndroidMusicSubpath'); + } + + Future _ensureDefaultDocumentsOutputDir() async { + final dir = await getApplicationDocumentsDirectory(); + final musicDir = _defaultDocumentsOutputDir(dir.path); + if (!await musicDir.exists()) { + await musicDir.create(recursive: true); + } + return musicDir; + } + + Future _ensureDefaultAndroidMusicOutputDir() async { + final dir = await getExternalStorageDirectory(); + if (dir == null) return null; + + final musicDir = _defaultAndroidMusicOutputDir( + dir.parent.parent.parent.parent.path, + ); + if (!await musicDir.exists()) { + await musicDir.create(recursive: true); + } + return musicDir; + } + + Future _initOutputDir() async { + if (state.outputDir.isEmpty) { + try { + if (Platform.isIOS) { + final musicDir = await _ensureDefaultDocumentsOutputDir(); + state = state.copyWith(outputDir: musicDir.path); + } else { + final musicDir = + await _ensureDefaultAndroidMusicOutputDir() ?? + await _ensureDefaultDocumentsOutputDir(); + state = state.copyWith(outputDir: musicDir.path); + } + } catch (e) { + final musicDir = await _ensureDefaultDocumentsOutputDir(); + state = state.copyWith(outputDir: musicDir.path); + } + } + } + + Future _ensureDirExists(String path, {String? label}) async { + if (_ensuredDirs.contains(path)) return; + final dir = Directory(path); + if (!await dir.exists()) { + await dir.create(recursive: true); + if (label != null) { + _log.d('Created $label: $path'); + } else { + _log.d('Created folder: $path'); + } + } + _ensuredDirs.add(path); + } + + bool _shouldTreatAsSingleRelease(Track track) { + if (track.isSingle) { + return true; + } + + final normalizedAlbumType = normalizeOptionalString( + track.albumType, + )?.toLowerCase(); + if (normalizedAlbumType != null && normalizedAlbumType.isNotEmpty) { + return false; + } + + final totalTracks = track.totalTracks; + if (totalTracks == 1) { + return true; + } + + final normalizedAlbumName = normalizeOptionalString( + track.albumName, + )?.toLowerCase(); + if (normalizedAlbumName == 'single' || normalizedAlbumName == 'singles') { + return totalTracks == null || totalTracks <= 2; + } + + return false; + } + + Future _buildOutputDir( + Track track, + String folderOrganization, { + bool separateSingles = false, + String albumFolderStructure = 'artist_album', + bool createPlaylistFolder = false, + bool useAlbumArtistForFolders = true, + bool usePrimaryArtistOnly = false, + bool filterContributingArtistsInAlbumArtist = false, + String? playlistName, + }) async { + final baseDir = state.outputDir; + if (createPlaylistFolder && + folderOrganization != 'playlist' && + playlistName != null && + playlistName.isNotEmpty) { + final playlistFolder = _sanitizeFolderName(playlistName); + if (playlistFolder.isNotEmpty) { + await _ensureDirExists( + '$baseDir${Platform.pathSeparator}$playlistFolder', + label: 'Playlist folder', + ); + } + } + + final relativeDir = await _buildRelativeOutputDir( + track, + folderOrganization, + separateSingles: separateSingles, + albumFolderStructure: albumFolderStructure, + createPlaylistFolder: createPlaylistFolder, + useAlbumArtistForFolders: useAlbumArtistForFolders, + usePrimaryArtistOnly: usePrimaryArtistOnly, + filterContributingArtistsInAlbumArtist: + filterContributingArtistsInAlbumArtist, + playlistName: playlistName, + ); + if (relativeDir.isEmpty) { + return baseDir; + } + + final fullPath = + '$baseDir${Platform.pathSeparator}' + '${relativeDir.replaceAll('/', Platform.pathSeparator)}'; + await _ensureDirExists(fullPath); + return fullPath; + } + + String _sanitizeFolderName(String name) { + final buffer = StringBuffer(); + for (final rune in name.runes) { + if (rune < 0x20 || rune == 0x7f) { + continue; + } + final char = String.fromCharCode(rune); + if (_invalidFolderChars.hasMatch(char)) { + buffer.write(' '); + continue; + } + buffer.write(char); + } + + var sanitized = buffer.toString().trim(); + sanitized = sanitized.replaceAll(_trimDotsAndSpacesRegex, ''); + sanitized = sanitized.replaceAll(_multiWhitespaceRegex, ' '); + sanitized = sanitized.replaceAll(_multiUnderscoreRegex, '_'); + sanitized = sanitized.replaceAll(_trimUnderscoresAndSpacesRegex, ''); + + if (sanitized.isEmpty) { + return 'Unknown'; + } + return sanitized; + } + + String _truncateUtf8Bytes(String value, int maxBytes) { + if (maxBytes <= 0 || utf8.encode(value).length <= maxBytes) { + return value; + } + + final buffer = StringBuffer(); + var usedBytes = 0; + for (final rune in value.runes) { + final char = String.fromCharCode(rune); + final charBytes = utf8.encode(char).length; + if (usedBytes + charBytes > maxBytes) break; + buffer.write(char); + usedBytes += charBytes; + } + return buffer.toString(); + } + + String _trimSafeName(String value) { + var trimmed = value.trim(); + trimmed = trimmed.replaceAll(_trimDotsAndSpacesRegex, ''); + trimmed = trimmed.replaceAll(_trimUnderscoresAndSpacesRegex, ''); + return trimmed.isEmpty ? 'Unknown' : trimmed; + } + + String _sanitizeSafRelativeDir(String relativeDir) { + if (relativeDir.trim().isEmpty) return ''; + final parts = relativeDir + .split('/') + .map(_sanitizeFolderName) + .map((part) { + final truncated = _truncateUtf8Bytes( + part, + _maxSafDirSegmentUtf8Bytes, + ); + return _trimSafeName(truncated); + }) + .where((part) => part.isNotEmpty && part != '.' && part != '..') + .toList(growable: false); + return parts.join('/'); + } + + Future _buildSafFileName(String baseName, String outputExt) async { + final sanitized = await PlatformBridge.sanitizeFilename(baseName); + final extBytes = utf8.encode(outputExt).length; + final maxBaseBytes = max(1, _maxSafFilenameUtf8Bytes - extBytes); + final truncated = _truncateUtf8Bytes(sanitized, maxBaseBytes); + return '${_trimSafeName(truncated)}$outputExt'; + } + + static final _featuredArtistPattern = RegExp( + r'\s*[,;]\s*|\s+(?:feat\.?|ft\.?|featuring|with|x)\s+', + caseSensitive: false, + ); + + String _extractPrimaryArtist(String artist) { + final match = _featuredArtistPattern.firstMatch(artist); + if (match != null && match.start > 0) { + return artist.substring(0, match.start).trim(); + } + return artist; + } + + Future _buildRelativeOutputDir( + Track track, + String folderOrganization, { + bool separateSingles = false, + String albumFolderStructure = 'artist_album', + bool createPlaylistFolder = false, + bool useAlbumArtistForFolders = true, + bool usePrimaryArtistOnly = false, + bool filterContributingArtistsInAlbumArtist = false, + String? playlistName, + }) async { + final playlistPrefix = + createPlaylistFolder && + folderOrganization != 'playlist' && + playlistName != null && + playlistName.isNotEmpty + ? _sanitizeFolderName(playlistName) + : ''; + final normalizedAlbumArtist = normalizeOptionalString(track.albumArtist); + var folderArtist = useAlbumArtistForFolders + ? normalizedAlbumArtist ?? track.artistName + : track.artistName; + if (useAlbumArtistForFolders && + filterContributingArtistsInAlbumArtist && + normalizedAlbumArtist != null) { + folderArtist = _extractPrimaryArtist(folderArtist); + } + if (usePrimaryArtistOnly) { + folderArtist = _extractPrimaryArtist(folderArtist); + } + + if (separateSingles) { + final isSingle = _shouldTreatAsSingleRelease(track); + final artistName = _sanitizeFolderName(folderArtist); + + if (albumFolderStructure == 'artist_album_singles') { + if (isSingle) { + return _joinRelativePath(playlistPrefix, '$artistName/Singles'); + } + final albumName = _sanitizeFolderName(track.albumName); + return _joinRelativePath(playlistPrefix, '$artistName/$albumName'); + } + + if (albumFolderStructure == 'artist_album_flat') { + if (isSingle) { + return _joinRelativePath(playlistPrefix, artistName); + } + final albumName = _sanitizeFolderName(track.albumName); + return _joinRelativePath(playlistPrefix, '$artistName/$albumName'); + } + + if (isSingle) { + return _joinRelativePath(playlistPrefix, 'Singles'); + } + + final albumName = _sanitizeFolderName(track.albumName); + final year = _extractYear(track.releaseDate); + switch (albumFolderStructure) { + case 'album_only': + return _joinRelativePath(playlistPrefix, 'Albums/$albumName'); + case 'artist_year_album': + final yearAlbum = year != null ? '[$year] $albumName' : albumName; + return _joinRelativePath( + playlistPrefix, + 'Albums/$artistName/$yearAlbum', + ); + case 'year_album': + final yearAlbum = year != null ? '[$year] $albumName' : albumName; + return _joinRelativePath(playlistPrefix, 'Albums/$yearAlbum'); + default: + return _joinRelativePath( + playlistPrefix, + 'Albums/$artistName/$albumName', + ); + } + } + + if (folderOrganization == 'none') { + return playlistPrefix; + } + + switch (folderOrganization) { + case 'playlist': + if (playlistName != null && playlistName.isNotEmpty) { + return _sanitizeFolderName(playlistName); + } + return ''; + case 'artist': + return _joinRelativePath( + playlistPrefix, + _sanitizeFolderName(folderArtist), + ); + case 'album': + return _joinRelativePath( + playlistPrefix, + _sanitizeFolderName(track.albumName), + ); + case 'artist_album': + final artistName = _sanitizeFolderName(folderArtist); + final albumName = _sanitizeFolderName(track.albumName); + return _joinRelativePath(playlistPrefix, '$artistName/$albumName'); + default: + return playlistPrefix; + } + } + + String _joinRelativePath(String prefix, String suffix) { + if (prefix.isEmpty) return suffix; + if (suffix.isEmpty) return prefix; + return '$prefix/$suffix'; + } + + String? _extensionPreferredOutputExt(String service) { + final normalizedService = service.trim().toLowerCase(); + if (normalizedService.isEmpty) return null; + + final extensionState = ref.read(extensionProvider); + for (final ext in extensionState.extensions) { + if (!ext.enabled || !ext.hasDownloadProvider) continue; + if (ext.id.toLowerCase() != normalizedService) continue; + + final preferred = ext.preferredDownloadOutputExtension; + if (preferred == null) return null; + + final normalized = preferred.startsWith('.') + ? preferred.toLowerCase() + : '.${preferred.toLowerCase()}'; + if (normalized == '.mp4') { + return '.m4a'; + } + const allowed = {'.flac', '.m4a', '.mp3', '.opus'}; + if (allowed.contains(normalized)) { + return normalized; + } + return null; + } + + return null; + } + + bool _extensionPreservesNativeOutputExt(String service, String ext) { + final normalizedService = service.trim().toLowerCase(); + final normalizedExt = ext.trim().toLowerCase(); + if (normalizedService.isEmpty || normalizedExt.isEmpty) return false; + + final extensionState = ref.read(extensionProvider); + return extensionState.extensions.any( + (ext) => + ext.enabled && + ext.hasDownloadProvider && + ext.id.toLowerCase() == normalizedService && + ext.preservedNativeOutputExtensions.contains(normalizedExt), + ); + } + + bool _extensionRequiresNativeContainerConversion(String service) { + final normalizedService = service.trim().toLowerCase(); + if (normalizedService.isEmpty) return false; + + final extensionState = ref.read(extensionProvider); + return extensionState.extensions.any( + (ext) => + ext.enabled && + ext.hasDownloadProvider && + (ext.id.toLowerCase() == normalizedService || + ext.replacesBuiltInProviders.contains(normalizedService)) && + ext.requiresNativeContainerConversion, + ); + } + + bool _shouldRequestContainerConversion(String service, String outputExt) { + return outputExt.trim().toLowerCase() == '.flac' && + _extensionRequiresNativeContainerConversion(service); + } + + String _determineOutputExt(String quality, String service) { + final extensionPreferred = _extensionPreferredOutputExt(service); + if (extensionPreferred != null) { + return extensionPreferred; + } + if (_downloadProviderReplacesLegacyProvider(service, 'tidal') && + quality == 'HIGH') { + return '.m4a'; + } + final q = quality.toLowerCase(); + if (q == 'alac' || q.startsWith('aac')) return '.m4a'; + if (q.startsWith('opus')) return '.opus'; + if (q.startsWith('mp3')) return '.mp3'; + return '.flac'; + } + + bool _downloadProviderReplacesLegacyProvider( + String service, + String legacyProviderId, + ) { + return ref + .read(extensionProvider.notifier) + .downloadProviderReplacesLegacyProvider(service, legacyProviderId); + } + + String _mimeTypeForExt(String ext) { + switch (ext.toLowerCase()) { + case '.m4a': + case '.mp4': + return 'audio/mp4'; + case '.mp3': + return 'audio/mpeg'; + case '.opus': + return 'audio/ogg'; + case '.flac': + return 'audio/flac'; + case '.lrc': + return 'application/octet-stream'; + default: + return 'application/octet-stream'; + } + } + + String? _normalizeAudioExt(Object? value) { + final raw = value?.toString().trim().toLowerCase(); + if (raw == null || raw.isEmpty) return null; + final normalized = raw.startsWith('.') ? raw : '.$raw'; + const allowed = {'.flac', '.m4a', '.mp4', '.mp3', '.opus', '.ogg', '.aac'}; + return allowed.contains(normalized) ? normalized : null; + } + + String? _downloadResultOutputExt( + Map result, { + String? filePath, + }) { + final explicit = + _normalizeAudioExt(result['actual_extension']) ?? + _normalizeAudioExt(result['output_extension']) ?? + _normalizeAudioExt(result['actual_container']) ?? + _normalizeAudioExt(result['container']); + if (explicit != null) return explicit; + + for (final candidate in [ + result['file_name'] as String?, + filePath, + result['file_path'] as String?, + ]) { + if (candidate == null) continue; + final lower = candidate.trim().toLowerCase(); + for (final ext in const [ + '.flac', + '.m4a', + '.mp4', + '.mp3', + '.opus', + '.ogg', + '.aac', + ]) { + if (lower.endsWith(ext)) return ext; + } + } + + // Generic safety net: when neither an explicit extension field nor a + // recognizable path suffix is available (e.g. SAF content URIs that drop + // the suffix), fall back to the actual audio codec reported by the backend + // probe. This keeps any extension that returns a non-FLAC container (Opus, + // MP3, AAC) from being mislabeled as FLAC. + final codec = normalizeAudioFormatValue( + result['audio_codec']?.toString() ?? + result['actual_audio_codec']?.toString() ?? + result['format']?.toString(), + ); + switch (codec) { + case 'opus': + return '.opus'; + case 'mp3': + return '.mp3'; + case 'aac': + case 'alac': + case 'm4a': + return '.m4a'; + case 'flac': + return '.flac'; + } + return null; + } + + Future _getSafMimeType(String uri) async { + try { + final stat = await PlatformBridge.safStat(uri); + return stat['mime_type'] as String?; + } catch (_) { + return null; + } + } + + String? _extractYear(String? releaseDate) { + if (releaseDate == null || releaseDate.isEmpty) return null; + final match = _yearRegex.firstMatch(releaseDate); + return match?.group(1); + } + + int _validPlaylistPosition(DownloadItem item) { + final position = item.playlistPosition; + if (position == null || position <= 0) return 0; + return position; + } + + String _filenameFormatForItem(DownloadItem item, String baseFormat) { + if (!item.fromBatch) { + return baseFormat; + } + final trimmed = baseFormat.trim(); + if (trimmed.isEmpty) { + return baseFormat; + } + if (_batchUniqueFilenameTokenPattern.hasMatch(trimmed)) { + return baseFormat; + } + return '$trimmed - {track:02} - {title}'; + } + + Map _filenameMetadataForTrack( + Track track, { + int playlistPosition = 0, + }) { + return { + 'title': track.name, + 'artist': track.artistName, + 'album': track.albumName, + 'track': track.trackNumber ?? 0, + 'disc': track.discNumber ?? 0, + 'year': _extractYear(track.releaseDate) ?? '', + 'date': track.releaseDate ?? '', + 'playlist_position': playlistPosition, + 'playlistPosition': playlistPosition, + }; + } +} diff --git a/lib/providers/download_queue_provider_replaygain.dart b/lib/providers/download_queue_provider_replaygain.dart new file mode 100644 index 00000000..21978762 --- /dev/null +++ b/lib/providers/download_queue_provider_replaygain.dart @@ -0,0 +1,307 @@ +// ignore_for_file: invalid_use_of_protected_member, invalid_use_of_visible_for_testing_member +part of 'download_queue_provider.dart'; + +class _AlbumRgTrackEntry { + String filePath; + final String trackId; + final double integratedLufs; + final double truePeakLinear; + final double durationSecs; + + _AlbumRgTrackEntry({ + required this.filePath, + required this.trackId, + required this.integratedLufs, + required this.truePeakLinear, + required this.durationSecs, + }); +} + +class _AlbumRgAccumulator { + final List<_AlbumRgTrackEntry> entries = []; +} + +extension _DownloadQueueReplayGain on DownloadQueueNotifier { + String _albumRgKey(Track track) { + if (track.albumId != null && track.albumId!.isNotEmpty) { + return 'id:${track.albumId}'; + } + return 'name:${track.albumName}|${track.albumArtist ?? ''}'; + } + + /// Purge a track's stale ReplayGain accumulator entry, dropping the whole + /// album accumulator once it becomes empty. + void _purgeAlbumRgEntry(Track track) { + final key = _albumRgKey(track); + final accumulator = _albumRgData[key]; + if (accumulator == null) return; + accumulator.entries.removeWhere((e) => e.trackId == track.id); + if (accumulator.entries.isEmpty) { + _albumRgData.remove(key); + } + } + + /// Store a track's ReplayGain scan result for later album gain computation. + void _storeTrackReplayGainForAlbum( + Track track, + String filePath, + ReplayGainResult rg, + ) { + final key = _albumRgKey(track); + _albumRgData.putIfAbsent(key, () => _AlbumRgAccumulator()); + // Remove any stale entry for this track (e.g. from a previous failed + // attempt that was retried). Without this, the same track can accumulate + // multiple entries and bias the album loudness calculation. + _albumRgData[key]!.entries.removeWhere((e) => e.trackId == track.id); + _albumRgData[key]!.entries.add( + _AlbumRgTrackEntry( + filePath: filePath, + trackId: track.id, + integratedLufs: rg.integratedLufs, + truePeakLinear: rg.truePeakLinear, + durationSecs: track.duration.toDouble(), + ), + ); + } + + /// Replace the temp path stored in the accumulator with the final output + /// path. For SAF downloads the embed happens on a temp file which is later + /// deleted — this ensures the album-gain writer targets the real file. + void _updateAlbumRgFilePath(Track track, String finalPath) { + final key = _albumRgKey(track); + final accumulator = _albumRgData[key]; + if (accumulator == null) return; + for (final entry in accumulator.entries) { + if (entry.trackId == track.id) { + entry.filePath = finalPath; + break; + } + } + } + + /// After a track completes, check whether all tracks from the same album + /// in the current queue are done. If so, compute album gain and write it + /// to every track's file. + Future _checkAndWriteAlbumReplayGain(Track track) async { + final settings = ref.read(settingsProvider); + if (!settings.embedReplayGain) return; + + final key = _albumRgKey(track); + final accumulator = _albumRgData[key]; + if (accumulator == null || accumulator.entries.isEmpty) return; + + // Find queue items for this album that are STILL in the queue. + // Completed tracks may have already been removed by removeItem(), so + // their absence means they finished successfully (not that they're + // still pending). + final albumItemsInQueue = state.items + .where((item) => _albumRgKey(item.track) == key) + .toList(); + + final pending = albumItemsInQueue.where( + (item) => + item.status == DownloadStatus.queued || + item.status == DownloadStatus.downloading || + item.status == DownloadStatus.finalizing, + ); + if (pending.isNotEmpty) return; + + // If any item is failed/skipped, the user might retry it later. + // Don't finalize album RG with partial data — wait until all album + // tracks are either completed (and possibly removed) or retried. + final retryable = albumItemsInQueue.where( + (item) => + item.status == DownloadStatus.failed || + item.status == DownloadStatus.skipped, + ); + if (retryable.isNotEmpty) return; + + // The accumulator entries represent successfully scanned tracks. Entries + // are only added after a successful ReplayGain scan, removed on retry or + // when a non-completed item is removed from the queue, so every entry + // here corresponds to a track that completed (or is about to complete) + // its download. + final validEntries = accumulator.entries.toList(); + + // Single-track albums: album gain == track gain, no extra write needed. + if (validEntries.length <= 1) { + _albumRgData.remove(key); + return; + } + + // Compute album gain using duration-weighted power-mean of LUFS values. + // album_loudness = 10 * log10( Σ(10^(Li/10) * di) / Σ(di) ) + // This weights longer tracks more, matching "whole program" loudness. + double sumWeightedPower = 0; + double sumDuration = 0; + double maxPeak = 0; + for (final entry in validEntries) { + final weight = entry.durationSecs > 0 ? entry.durationSecs : 1.0; + sumWeightedPower += pow(10, entry.integratedLufs / 10.0) * weight; + sumDuration += weight; + if (entry.truePeakLinear > maxPeak) { + maxPeak = entry.truePeakLinear; + } + } + final albumLufs = 10.0 * _log10(sumWeightedPower / sumDuration); + const replayGainReferenceLufs = -18.0; + final albumGainDb = replayGainReferenceLufs - albumLufs; + + final albumGain = + '${albumGainDb >= 0 ? "+" : ""}${albumGainDb.toStringAsFixed(2)} dB'; + final albumPeak = maxPeak.toStringAsFixed(6); + + _log.i( + 'Album ReplayGain for "$key": gain=$albumGain, peak=$albumPeak (${validEntries.length} tracks, album LUFS=${albumLufs.toStringAsFixed(1)})', + ); + + for (final entry in validEntries) { + try { + await _writeAlbumReplayGain(entry.filePath, albumGain, albumPeak); + } catch (e) { + _log.w('Failed to write album ReplayGain to ${entry.filePath}: $e'); + } + } + + _albumRgData.remove(key); + } + + /// Write album ReplayGain tags to a single file. + Future _writeAlbumReplayGain( + String filePath, + String albumGain, + String albumPeak, + ) async { + final lower = filePath.toLowerCase(); + if (lower.endsWith('.flac') || + lower.endsWith('.ape') || + lower.endsWith('.wv') || + lower.endsWith('.mpc')) { + // Native writer — only touches the provided fields, preserves the rest. + await PlatformBridge.editFileMetadata(filePath, { + 'replaygain_album_gain': albumGain, + 'replaygain_album_peak': albumPeak, + }); + } else if (isContentUri(filePath)) { + // SAF content:// URI — FFmpeg can read it but can't write back directly. + // Get the temp output from FFmpeg, then copy it to the SAF URI. + String? tempPath; + final ok = await FFmpegService.writeAlbumReplayGainTags( + filePath, + albumGain, + albumPeak, + returnTempPath: true, + onTempReady: (path) => tempPath = path, + ); + if (ok && tempPath != null) { + try { + final safOk = await PlatformBridge.writeTempToSaf( + tempPath!, + filePath, + ); + if (!safOk) { + _log.w('SAF write-back failed for album RG: $filePath'); + } + } finally { + try { + final tmp = File(tempPath!); + if (await tmp.exists()) await tmp.delete(); + } catch (_) {} + } + } else { + _log.w('FFmpeg album ReplayGain write failed for SAF: $filePath'); + } + } else { + // Local MP3 / Opus — use FFmpeg copy-with-metadata approach. + final ok = await FFmpegService.writeAlbumReplayGainTags( + filePath, + albumGain, + albumPeak, + ); + if (!ok) { + _log.w('FFmpeg album ReplayGain write failed for: $filePath'); + } + } + } + + /// Re-check album ReplayGain for all albums that still have accumulator data. + /// Called after removing/dismissing a failed or skipped item, which may + /// unblock an album that was waiting for retryable items to be resolved. + void _retriggerAlbumRgChecks() { + if (_albumRgData.isEmpty) return; + final settings = ref.read(settingsProvider); + if (!settings.embedReplayGain) return; + + // Snapshot the keys — _checkAndWriteAlbumReplayGain may mutate the map. + final keys = _albumRgData.keys.toList(); + for (final key in keys) { + final acc = _albumRgData[key]; + if (acc == null || acc.entries.isEmpty) continue; + // Use the first entry's trackId to find a representative track. + // _checkAndWriteAlbumReplayGain only needs it for _albumRgKey(), so any + // track from the album works. + final albumItems = state.items + .where((item) => _albumRgKey(item.track) == key) + .toList(); + // If there are no items left in queue for this album but we have + // accumulator data, all items were completed and removed. Use a + // synthetic call — we need a Track to call the check, but the items + // are gone. For this case, directly check conditions inline. + if (albumItems.isEmpty) { + // All items removed → no pending/retryable. Trigger computation. + if (acc.entries.length > 1) { + _computeAndWriteAlbumRg(key, acc); + } + continue; + } + final representative = albumItems.first; + _checkAndWriteAlbumReplayGain(representative.track); + } + } + + /// Compute album RG and write it — extracted from _checkAndWriteAlbumReplayGain + /// for use when no queue items remain (all completed and removed). + Future _computeAndWriteAlbumRg( + String key, + _AlbumRgAccumulator accumulator, + ) async { + final validEntries = accumulator.entries.toList(); + if (validEntries.length <= 1) { + _albumRgData.remove(key); + return; + } + + double sumWeightedPower = 0; + double sumDuration = 0; + double maxPeak = 0; + for (final entry in validEntries) { + final weight = entry.durationSecs > 0 ? entry.durationSecs : 1.0; + sumWeightedPower += pow(10, entry.integratedLufs / 10.0) * weight; + sumDuration += weight; + if (entry.truePeakLinear > maxPeak) { + maxPeak = entry.truePeakLinear; + } + } + final albumLufs = 10.0 * _log10(sumWeightedPower / sumDuration); + const replayGainReferenceLufs = -18.0; + final albumGainDb = replayGainReferenceLufs - albumLufs; + + final albumGain = + '${albumGainDb >= 0 ? "+" : ""}${albumGainDb.toStringAsFixed(2)} dB'; + final albumPeak = maxPeak.toStringAsFixed(6); + + _log.i( + 'Album ReplayGain for "$key": gain=$albumGain, peak=$albumPeak (${validEntries.length} tracks, album LUFS=${albumLufs.toStringAsFixed(1)})', + ); + + for (final entry in validEntries) { + try { + await _writeAlbumReplayGain(entry.filePath, albumGain, albumPeak); + } catch (e) { + _log.w('Failed to write album ReplayGain to ${entry.filePath}: $e'); + } + } + + _albumRgData.remove(key); + } +}