From 7b9405aaf6efbc4642c4fd7883ace60cb881fc1d Mon Sep 17 00:00:00 2001 From: zarzet <42882290+zarzet@users.noreply.github.com> Date: Tue, 6 Oct 2026 13:09:45 +0700 Subject: [PATCH] refactor(downloads): separate metadata resolution and embedding --- lib/providers/download_queue_provider.dart | 27 +- .../download_queue_provider_embedding.dart | 968 +----------------- .../download_queue_provider_finalization.dart | 6 +- ...download_queue_provider_native_worker.dart | 46 +- .../download_queue_provider_single_item.dart | 60 +- lib/services/download_metadata_embedding.dart | 484 +++++++++ lib/services/download_metadata_resolver.dart | 354 +++++++ test/download_metadata_embedding_test.dart | 597 +++++++++++ test/download_metadata_resolver_test.dart | 423 ++++++++ 9 files changed, 1958 insertions(+), 1007 deletions(-) create mode 100644 lib/services/download_metadata_embedding.dart create mode 100644 lib/services/download_metadata_resolver.dart create mode 100644 test/download_metadata_embedding_test.dart create mode 100644 test/download_metadata_resolver_test.dart diff --git a/lib/providers/download_queue_provider.dart b/lib/providers/download_queue_provider.dart index f2077178..fbde8467 100644 --- a/lib/providers/download_queue_provider.dart +++ b/lib/providers/download_queue_provider.dart @@ -22,11 +22,12 @@ import 'package:spotiflac_android/services/download_queue_persistence.dart'; import 'package:spotiflac_android/services/download_progress.dart'; import 'package:spotiflac_android/services/download_file_finalizer.dart'; import 'package:spotiflac_android/services/download_container_finalizer.dart'; +import 'package:spotiflac_android/services/download_metadata_resolver.dart'; +import 'package:spotiflac_android/services/download_metadata_embedding.dart'; import 'package:spotiflac_android/services/download_saf_file_replacer.dart'; import 'package:spotiflac_android/services/download_connectivity_policy.dart'; import 'package:spotiflac_android/services/platform_bridge.dart'; import 'package:spotiflac_android/services/download_request_payload.dart'; -import 'package:spotiflac_android/services/download_album_metadata.dart'; import 'package:spotiflac_android/services/download_motion_artwork_source.dart'; import 'package:spotiflac_android/services/ffmpeg_service.dart'; import 'package:spotiflac_android/services/hires_check_service.dart'; @@ -46,10 +47,11 @@ import 'package:spotiflac_android/utils/progress_stream_poller.dart'; import 'package:spotiflac_android/providers/download_history_provider.dart'; import 'package:spotiflac_android/services/native_download_history.dart'; -import 'package:spotiflac_android/services/download_track_metadata.dart'; export 'package:spotiflac_android/providers/download_history_provider.dart'; export 'package:spotiflac_android/providers/download_queue_state.dart'; +export 'package:spotiflac_android/services/download_metadata_resolver.dart' + show copyTrackWithResolvedMetadata; export 'package:spotiflac_android/services/download_queue_persistence.dart' show encodeDownloadQueueItemForPersistence, downloadQueuePersistenceStatus; @@ -318,10 +320,10 @@ class DownloadQueueNotifier extends Notifier { static const _nativeWorkerRunIdPrefsKey = 'download_queue_native_worker_run_id'; final NotificationService _notificationService = NotificationService(); - // Shared across tracks in a batch: an album's tracks embed the same cover, - // so fetch it once instead of once per track. LRU-capped; files are deleted - // on eviction and when the queue drains. - final Map> _embedCoverCache = {}; + final _metadataResolver = DownloadMetadataResolver(); + late final _metadataEmbedding = DownloadMetadataEmbedding( + onReplayGain: _storeTrackReplayGainForAlbum, + ); late final ProgressStreamPoller> _progressPoller = ProgressStreamPoller>( streamProvider: PlatformBridge.downloadProgressStream, @@ -631,7 +633,10 @@ class DownloadQueueNotifier extends Notifier { bool stageSafForDeferredPublish = false, bool qualityVariantCollisionOnly = false, }) { - final resolvedAlbumArtist = _resolveAlbumArtistForMetadata(track, settings); + final resolvedAlbumArtist = DownloadMetadataResolver.albumArtistForMetadata( + track, + settings, + ); final postProcessingEnabled = settings.useExtensionProviders && extensionState.extensions.any((e) => e.enabled && e.hasPostProcessing); @@ -693,7 +698,11 @@ class DownloadQueueNotifier extends Notifier { embedLyrics: settings.embedMetadata && settings.embedLyrics && - !_shouldSkipLyrics(extensionState, track.source, item.service), + !DownloadMetadataResolver.shouldSkipLyrics( + extensionState, + track.source, + item.service, + ), embedReplayGain: settings.embedReplayGain, postProcessingEnabled: postProcessingEnabled, autoConvertDownloads: settings.autoConvertDownloads, @@ -1735,7 +1744,7 @@ class DownloadQueueNotifier extends Notifier { stoppedWhilePaused && _networkPausedByWifiOnly; _stopProgressPolling(); - _clearEmbedCoverCache(); + _metadataEmbedding.clearCoverCache(); if (!keepConnectivityMonitoring) { _stopConnectivityMonitoring(); } diff --git a/lib/providers/download_queue_provider_embedding.dart b/lib/providers/download_queue_provider_embedding.dart index 5963f9d9..31032e0f 100644 --- a/lib/providers/download_queue_provider_embedding.dart +++ b/lib/providers/download_queue_provider_embedding.dart @@ -1,441 +1,16 @@ // 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 { - Future _prepareDownloadSourceTrack(Track track) async { - if (!track.id.startsWith('deezer:')) return track; - final id = track.id.substring('deezer:'.length); - final enriched = await enrichIncompleteDownloadTrack(track, 'deezer', id); - return identical(enriched, track) ? track : enriched.copyWith(deezerId: id); - } - Future _resolveDownloadAlbumCredit( Track track, AppSettings settings, - ) async { - if (!settings.embedMetadata || - normalizeOptionalString(track.albumId) == null) { - return track; - } - final source = - normalizeOptionalString(track.source)?.toLowerCase() ?? - (track.id.contains(':') ? track.id.split(':').first.toLowerCase() : ''); - if (source.isEmpty) return track; - final providers = ref - .read(extensionProvider) - .extensions - .where( - (extension) => extension.enabled && extension.hasMetadataProvider, - ); - final provider = - providers - .where((extension) => extension.id.toLowerCase() == source) - .firstOrNull ?? - providers - .where( - (extension) => - extension.replacesBuiltInProviders.contains(source), - ) - .firstOrNull; - // Never send an album ID to an unrelated provider selected as a fallback. - // This resolves the source release itself, even when the provider disables - // supplemental cross-provider enrichment (for example genre/label lookup). - if (provider == null) return track; - return resolveDownloadAlbumArtist(track, provider.id); - } + ) => _metadataResolver.resolveAlbumCredit( + track, + settings, + ref.read(extensionProvider), + ); - String? _resolveAlbumArtistForMetadata(Track track, AppSettings settings) { - var albumArtist = normalizeOptionalString(track.albumArtist); - if (settings.filterContributingArtistsInAlbumArtist) { - albumArtist = albumArtist == null - ? null - : normalizeOptionalString(_extractPrimaryArtist(albumArtist)); - } - return albumArtist; - } - - /// 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()), - ); - } - - bool _shouldSkipMetadataEnrichment( - ExtensionState extensionState, - String? source, - String? service, - ) { - final candidates = {}; - for (final value in [source, service]) { - final normalized = value?.trim().toLowerCase(); - if (normalized != null && normalized.isNotEmpty) { - candidates.add(normalized); - } - } - if (candidates.isEmpty) return false; - return extensionState.extensions.any( - (extension) => - extension.enabled && - extension.skipMetadataEnrichment && - candidates.contains(extension.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; - } - - 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); - } - - /// 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). - /// Returns the fetched LRC content (when lyrics were fetched) so callers - /// can reuse it — e.g. the SAF external-.lrc save — without a second - /// network fetch. Future _embedMetadataToFile( String filePath, Track track, { @@ -447,526 +22,19 @@ extension _DownloadQueueEmbedding on DownloadQueueNotifier { String? lyricsLrc, String? downloadService, bool writeExternalLrc = true, - }) async { - final settings = ref.read(settingsProvider); - if (!settings.embedMetadata) { - _log.d( - 'Metadata embedding disabled, skipping $format metadata/cover embed', - ); - if (settings.embedReplayGain) { - final rg = await ReplayGainService.scanAndApplyToFile(filePath); - if (rg != null) _storeTrackReplayGainForAlbum(track, filePath, rg); - } - return null; - } - - final isFlac = format == 'flac'; - final isM4a = format == 'm4a'; - final isMp3 = format == 'mp3'; - final resolvedGenre = genre ?? track.genre; - final resolvedLabel = label ?? track.label; - final resolvedCopyright = copyright ?? track.copyright; - final resolvedComment = comment ?? track.comment; - - Future? coverFuture; - final coverUrl = normalizeRemoteHttpUrl(track.coverUrl); - if (coverUrl != null && coverUrl.isNotEmpty) { - // Started here, awaited only after the lyrics fetch below so the two - // network round trips overlap. Errors are handled inside the fetch - // (it resolves to null), never as an unhandled rejection. - coverFuture = _sharedEmbedCover( - coverUrl, - settings.embeddedCoverMaxDimension, - ); - } - - String? lrcContent; - 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 (resolvedGenre != null && resolvedGenre.isNotEmpty) { - metadata['GENRE'] = resolvedGenre; - } - if (resolvedLabel != null && resolvedLabel.isNotEmpty) { - metadata['ORGANIZATION'] = resolvedLabel; - } - if (resolvedCopyright != null && resolvedCopyright.isNotEmpty) { - metadata['COPYRIGHT'] = resolvedCopyright; - } - if (resolvedComment != null && resolvedComment.isNotEmpty) { - metadata['COMMENT'] = resolvedComment; - } - if (track.composer != null && track.composer!.isNotEmpty) { - metadata['COMPOSER'] = track.composer!; - } - if (track.isExplicit) { - metadata['ITUNESADVISORY'] = '1'; - } - final resolvedAlbumType = track.albumType; - if (resolvedAlbumType != null && resolvedAlbumType.isNotEmpty) { - metadata['RELEASETYPE'] = resolvedAlbumType.toLowerCase(); - if (resolvedAlbumType.toLowerCase() == 'compilation') { - metadata['COMPILATION'] = '1'; - } - } - if (track.upc != null && track.upc!.isNotEmpty) { - metadata['BARCODE'] = track.upc!; - } - - 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'); - - if (shouldEmbedLyrics || shouldSaveExternalLyrics) { - try { - final fetchedLrc = - lyricsLrc != null && hasUsableLyricsContent(lyricsLrc) - ? lyricsLrc - : await PlatformBridge.getLyricsLRC( - track.id, - track.name, - track.artistName, - filePath: '', - durationMs: track.duration * 1000, - ); - if (hasUsableLyricsContent(fetchedLrc) && - !isInstrumentalLyricsMarker(fetchedLrc)) { - lrcContent = fetchedLrc; - _log.d('Lyrics fetched for $format (${fetchedLrc.length} chars)'); - } else if (isInstrumentalLyricsMarker(fetchedLrc)) { - _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; - if (format != 'opus') { - metadata['REPLAYGAIN_TRACK_GAIN'] = rgResult.trackGain; - metadata['REPLAYGAIN_TRACK_PEAK'] = rgResult.trackPeak; - } - _log.d( - 'ReplayGain for $format: gain=${rgResult.trackGain}, peak=${rgResult.trackPeak}', - ); - if (format != 'opus') { - _storeTrackReplayGainForAlbum(track, filePath, rgResult); - } - } - } catch (e) { - _log.w('Failed to scan ReplayGain for $format: $e'); - } - } - - final coverPath = coverFuture == null ? null : await coverFuture; - 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 { - // The AC-4 writer takes no artist mode; resolve it here. - final ac4Meta = { - 'title': track.name, - 'artist': artistTagValueForMode( - track.artistName, - settings.artistTagMode, - ), - 'album': track.albumName, - if (albumArtist != null) - 'albumArtist': artistTagValueForMode( - albumArtist, - settings.artistTagMode, - ), - if (track.releaseDate != null) 'date': track.releaseDate!, - if (resolvedGenre != null && resolvedGenre.isNotEmpty) - 'genre': resolvedGenre, - 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 (resolvedLabel != null && resolvedLabel.isNotEmpty) - 'label': resolvedLabel, - if (resolvedCopyright != null && resolvedCopyright.isNotEmpty) - 'copyright': resolvedCopyright, - if (resolvedComment != null && resolvedComment.isNotEmpty) - 'comment': resolvedComment, - 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 lrcContent; - } - } catch (e) { - _log.w('AC-4 metadata path failed, falling back to FFmpeg: $e'); - } - } - - // Native Rust tag writers handle these formats without spawning FFmpeg. - // Edits publish staged output atomically; identical FLAC headers skip - // the audio copy. The backend answers method=ffmpeg for unsupported files, - // and any failure falls back to FFmpeg below. - // Opus ReplayGain is written and verified separately below, through the - // same native R128 writer used by manual scans and album gain updates. - var embeddedNatively = false; - if (scannedReplayGain == null || format == 'opus') { - try { - final nativeFields = { - 'title': track.name, - 'artist': track.artistName, - 'album': track.albumName, - 'artist_tag_mode': settings.artistTagMode, - 'album_artist': ?albumArtist, - if (track.releaseDate != null) 'date': track.releaseDate!, - if (track.isrc != null) 'isrc': track.isrc!, - if (track.composer != null && track.composer!.isNotEmpty) - 'composer': track.composer!, - if (resolvedGenre != null && resolvedGenre.isNotEmpty) - 'genre': resolvedGenre, - if (resolvedLabel != null && resolvedLabel.isNotEmpty) - 'label': resolvedLabel, - if (resolvedCopyright != null && resolvedCopyright.isNotEmpty) - 'copyright': resolvedCopyright, - if (resolvedComment != null && resolvedComment.isNotEmpty) - 'comment': resolvedComment, - if (track.trackNumber != null && track.trackNumber! > 0) - 'track_number': track.trackNumber!.toString(), - if (track.totalTracks != null && track.totalTracks! > 0) - 'track_total': track.totalTracks!.toString(), - if (track.discNumber != null && track.discNumber! > 0) - 'disc_number': track.discNumber!.toString(), - if (track.totalDiscs != null && track.totalDiscs! > 0) - 'disc_total': track.totalDiscs!.toString(), - if (track.isExplicit) 'explicit': '1', - if (track.albumType != null && track.albumType!.isNotEmpty) - 'album_type': track.albumType!, - if (track.upc != null && track.upc!.isNotEmpty) 'upc': track.upc!, - if (track.albumType?.toLowerCase() == 'compilation') - 'compilation': '1', - 'cover_path': ?validCover, - if (shouldEmbedLyrics && lrcContent != null) ...{ - 'lyrics': lrcContent, - 'unsyncedlyrics': lrcContent, - } else if ((isFlac || isM4a) && !shouldEmbedLyrics) ...{ - // Mirrors the FFmpeg map: embedding disabled strips lyrics a - // provider may have pre-embedded (set-or-clear semantics). - 'lyrics': '', - if (isFlac) 'unsyncedlyrics': '', - }, - }; - final response = await PlatformBridge.editFileMetadata( - filePath, - nativeFields, - ); - embeddedNatively = - response['success'] == true && - response['error'] == null && - response['method'] != 'ffmpeg'; - } catch (e) { - _log.w('Native $format tag embed failed, falling back to FFmpeg: $e'); - } - } - - if (embeddedNatively) { - _log.d('Metadata embedded to $format natively'); - } else { - 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, - artistTagMode: settings.artistTagMode, - ); - } else if (isMp3) { - ffmpegResult = await FFmpegService.embedMetadataToMp3( - mp3Path: filePath, - coverPath: validCover, - metadata: metadata, - artistTagMode: settings.artistTagMode, - ); - } 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 (format == 'opus' && scannedReplayGain != null) { - final written = await ReplayGainService.writeTrackTags( - filePath, - scannedReplayGain.trackGain, - scannedReplayGain.trackPeak, - ); - if (written) { - _storeTrackReplayGainForAlbum(track, filePath, scannedReplayGain); - } else { - _log.w('Failed to write Opus ReplayGain'); - } - } - - 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) { - // The native embed already wrote split tags via artist_tag_mode; - // this second full-file rewrite is only needed after FFmpeg, whose - // metadata map can't express repeated ARTIST tags. - if (!embeddedNatively && - 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 ReplayGainService.scanAndApplyToFile( - filePath, - ); - if (rgResult != null) { - _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'); - } - // The cover file is owned by _embedCoverCache: kept for the next track - // of the same release, deleted on LRU eviction or when the queue drains. - return lrcContent; - } - - static const _embedCoverCacheMax = 8; - - /// One cover fetch per URL, shared by every track in the batch. - Future _sharedEmbedCover(String coverUrl, int maxDimension) { - final cacheKey = '$maxDimension\u0000$coverUrl'; - final existing = _embedCoverCache.remove(cacheKey); - if (existing != null) { - _embedCoverCache[cacheKey] = existing; // LRU touch - return existing; - } - final fetch = _downloadEmbedCover(coverUrl, maxDimension).then((path) { - if (path == null) _embedCoverCache.remove(cacheKey); // allow retry - return path; - }); - _embedCoverCache[cacheKey] = fetch; - while (_embedCoverCache.length > _embedCoverCacheMax) { - _evictEmbedCover(_embedCoverCache.keys.first); - } - return fetch; - } - - Future _downloadEmbedCover(String coverUrl, int maxDimension) async { - try { - final tempDir = await getTemporaryDirectory(); - final uniqueId = - '${DateTime.now().millisecondsSinceEpoch}_${Random().nextInt(10000)}'; - final coverPath = '${tempDir.path}/cover_embed_$uniqueId.jpg'; - - // Mobile backends allocate temporary media inside their owned scope. - final result = await PlatformBridge.downloadCoverToFile( - coverUrl, - Platform.isAndroid || Platform.isIOS ? '' : coverPath, - maxDimension: maxDimension, - ); - if (result['error'] != null) { - _log.w('Failed to download cover: ${result['error']}'); - return null; - } - return result['file_path'] as String? ?? coverPath; - } catch (e) { - _log.e('Failed to download cover for embedding: $e'); - return null; - } - } - - void _evictEmbedCover(String coverUrl) { - final pending = _embedCoverCache.remove(coverUrl); - pending?.then((path) { - if (path != null) { - File(path).delete().catchError((_) => File(path)); - } - }); - } - - void _clearEmbedCoverCache() { - for (final url in _embedCoverCache.keys.toList()) { - _evictEmbedCover(url); - } - } -} - -final _isrcRegex = RegExp(r'^[A-Z]{2}[A-Z0-9]{3}\d{2}\d{5}$'); - -bool _isValidISRC(String value) { - return _isrcRegex.hasMatch(value.toUpperCase()); -} - -/// Fills resolved identifiers and missing metadata without dropping other tags. -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.copyWith( - coverUrl: normalizeCoverReference(track.coverUrl), - 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, - totalTracks: (track.totalTracks != null && track.totalTracks! > 0) - ? track.totalTracks - : totalTracks, - composer: (track.composer != null && track.composer!.isNotEmpty) - ? track.composer - : normalizedComposer, + }) => _metadataEmbedding.embed( + filePath, + track, + settings: ref.read(settingsProvider), + extensionState: ref.read(extensionProvider), + format: format, + genre: genre, + label: label, + copyright: copyright, + comment: comment, + lyricsLrc: lyricsLrc, + downloadService: downloadService, + writeExternalLrc: writeExternalLrc, ); } diff --git a/lib/providers/download_queue_provider_finalization.dart b/lib/providers/download_queue_provider_finalization.dart index a638b654..52228d2c 100644 --- a/lib/providers/download_queue_provider_finalization.dart +++ b/lib/providers/download_queue_provider_finalization.dart @@ -815,7 +815,11 @@ extension _DownloadQueueFinalization on DownloadQueueNotifier { final shouldSaveExternalLrc = settings.embedMetadata && settings.embedLyrics && - !_shouldSkipLyrics(extensionState, track.source, service) && + !DownloadMetadataResolver.shouldSkipLyrics( + extensionState, + track.source, + service, + ) && (lyricsMode == 'external' || lyricsMode == 'both'); if (!shouldSaveExternalLrc) { return false; diff --git a/lib/providers/download_queue_provider_native_worker.dart b/lib/providers/download_queue_provider_native_worker.dart index 09c06e1a..ce1a84ee 100644 --- a/lib/providers/download_queue_provider_native_worker.dart +++ b/lib/providers/download_queue_provider_native_worker.dart @@ -885,7 +885,7 @@ extension _DownloadQueueNativeWorker on DownloadQueueNotifier { return null; } - final sourceTrack = await _prepareDownloadSourceTrack(item.track); + final sourceTrack = await _metadataResolver.prepareSourceTrack(item.track); item = item.copyWith( track: await _resolveDownloadAlbumCredit(sourceTrack, settings), ); @@ -948,32 +948,40 @@ extension _DownloadQueueNativeWorker on DownloadQueueNotifier { var trackForPayload = item.track; final extensionState = ref.read(extensionProvider); - final skipMetadataEnrichment = _shouldSkipMetadataEnrichment( - extensionState, - trackForPayload.source, - item.service, - ); + final skipMetadataEnrichment = + DownloadMetadataResolver.shouldSkipMetadataEnrichment( + extensionState, + trackForPayload.source, + item.service, + ); String? nativeDeezerTrackId; if (skipMetadataEnrichment) { - nativeDeezerTrackId = _extractKnownDeezerTrackId(trackForPayload); + nativeDeezerTrackId = DownloadMetadataResolver.knownDeezerTrackId( + trackForPayload, + ); } else { - nativeDeezerTrackId = await _resolveDeezerIdFromKnownOrIsrc( - trackForPayload, - item.id, - lookupContext: 'native worker ISRC', - ); - final providerResolved = await _resolveDeezerIdViaProviderIfNeeded( - trackForPayload, - nativeDeezerTrackId, - item.id, - ); + nativeDeezerTrackId = await _metadataResolver + .resolveDeezerIdFromKnownOrIsrc( + trackForPayload, + item.id, + lookupContext: 'native worker ISRC', + ); + final providerResolved = await _metadataResolver + .resolveDeezerIdViaProviderIfNeeded( + trackForPayload, + nativeDeezerTrackId, + item.id, + extensionState: extensionState, + ); trackForPayload = providerResolved.track; nativeDeezerTrackId = providerResolved.deezerTrackId; } final extendedMetadata = skipMetadataEnrichment ? null - : await _loadExtendedMetadataForDeezerId(nativeDeezerTrackId); + : await _metadataResolver.loadExtendedMetadataForDeezerId( + nativeDeezerTrackId, + ); final payload = _buildDownloadRequestPayload( track: trackForPayload, @@ -1353,7 +1361,7 @@ extension _DownloadQueueNativeWorker on DownloadQueueNotifier { actualQuality = resolvedQuality; } - final resolvedAlbumArtist = _resolveAlbumArtistForMetadata( + final resolvedAlbumArtist = DownloadMetadataResolver.albumArtistForMetadata( item.track, settings, ); diff --git a/lib/providers/download_queue_provider_single_item.dart b/lib/providers/download_queue_provider_single_item.dart index 38c318b3..94edfe96 100644 --- a/lib/providers/download_queue_provider_single_item.dart +++ b/lib/providers/download_queue_provider_single_item.dart @@ -13,10 +13,8 @@ extension _SingleItemDownload on DownloadQueueNotifier { try { final settings = ref.read(settingsProvider); final extensionState = ref.read(extensionProvider); - final resolvedAlbumArtist = _resolveAlbumArtistForMetadata( - track, - settings, - ); + final resolvedAlbumArtist = + DownloadMetadataResolver.albumArtistForMetadata(track, settings); if (!settings.useExtensionProviders) return null; @@ -207,7 +205,7 @@ class _DownloadRun { ); if (await _shouldAbort('during album metadata lookup')) return; - resolvedAlbumArtist = n._resolveAlbumArtistForMetadata( + resolvedAlbumArtist = DownloadMetadataResolver.albumArtistForMetadata( trackToDownload, settings, ); @@ -220,13 +218,13 @@ class _DownloadRun { // with the download instead of delaying its start; it is awaited right // after the download returns. final extendedMetadataFuture = shouldSkipMetadataEnrichment - ? Future<_DeezerExtendedMetadataFields?>.value(null) - : n._loadExtendedMetadataForDeezerId(deezerTrackId).catchError(( - Object e, - ) { - _log.w('Extended metadata lookup failed: $e'); - return null; - }); + ? Future.value(null) + : n._metadataResolver + .loadExtendedMetadataForDeezerId(deezerTrackId) + .catchError((Object e) { + _log.w('Extended metadata lookup failed: $e'); + return null; + }); if (await _shouldAbort('before native download start')) { return; @@ -326,7 +324,9 @@ class _DownloadRun { } Future _enrichDeezerTrackIfNeeded() async { - trackToDownload = await n._prepareDownloadSourceTrack(trackToDownload); + trackToDownload = await n._metadataResolver.prepareSourceTrack( + trackToDownload, + ); return !await _shouldAbort('during metadata enrichment'); } @@ -414,22 +414,25 @@ class _DownloadRun { e.hasMetadataProvider && e.id.toLowerCase() == trackSource, ); - shouldSkipMetadataEnrichment = n._shouldSkipMetadataEnrichment( - extensionState, - trackToDownload.source, - item.service, - ); + shouldSkipMetadataEnrichment = + DownloadMetadataResolver.shouldSkipMetadataEnrichment( + extensionState, + trackToDownload.source, + item.service, + ); final hasActiveExtensions = extensionState.extensions.any((e) => e.enabled); useExtensions = settings.useExtensionProviders && hasActiveExtensions; } Future _resolveTrackIdentifiers() async { if (shouldSkipMetadataEnrichment) { - deezerTrackId = n._extractKnownDeezerTrackId(trackToDownload); + deezerTrackId = DownloadMetadataResolver.knownDeezerTrackId( + trackToDownload, + ); _log.d('Skipping cross-provider metadata enrichment for ${item.service}'); return true; } - deezerTrackId = await n._resolveDeezerIdFromKnownOrIsrc( + deezerTrackId = await n._metadataResolver.resolveDeezerIdFromKnownOrIsrc( trackToDownload, item.id, lookupContext: 'ISRC', @@ -440,11 +443,13 @@ class _DownloadRun { // For tidal:/qobuz: tracks without ISRC, resolve ISRC from provider // API directly (faster than SongLink and avoids rate limits). - final providerResolved = await n._resolveDeezerIdViaProviderIfNeeded( - trackToDownload, - deezerTrackId, - item.id, - ); + final providerResolved = await n._metadataResolver + .resolveDeezerIdViaProviderIfNeeded( + trackToDownload, + deezerTrackId, + item.id, + extensionState: extensionState, + ); trackToDownload = providerResolved.track; deezerTrackId = providerResolved.deezerTrackId; if (await _shouldAbort('during provider ISRC resolution')) { @@ -459,9 +464,8 @@ class _DownloadRun { !trackToDownload.id.startsWith('extension:') && !trackToDownload.id.startsWith('tidal:') && !trackToDownload.id.startsWith('qobuz:')) { - final spotifyLookup = await n._resolveSpotifyTrackViaDeezer( - trackToDownload, - ); + final spotifyLookup = await n._metadataResolver + .resolveSpotifyTrackViaDeezer(trackToDownload); trackToDownload = spotifyLookup.track; deezerTrackId ??= spotifyLookup.deezerTrackId; diff --git a/lib/services/download_metadata_embedding.dart b/lib/services/download_metadata_embedding.dart new file mode 100644 index 00000000..93b3fbce --- /dev/null +++ b/lib/services/download_metadata_embedding.dart @@ -0,0 +1,484 @@ +import 'dart:io'; +import 'dart:math'; + +import 'package:path_provider/path_provider.dart'; +import 'package:spotiflac_android/models/settings.dart'; +import 'package:spotiflac_android/models/track.dart'; +import 'package:spotiflac_android/providers/extension_provider.dart'; +import 'package:spotiflac_android/services/download_metadata_resolver.dart'; +import 'package:spotiflac_android/services/ffmpeg_service.dart'; +import 'package:spotiflac_android/services/platform_bridge.dart'; +import 'package:spotiflac_android/services/replaygain_service.dart'; +import 'package:spotiflac_android/utils/artist_utils.dart'; +import 'package:spotiflac_android/utils/logger.dart'; +import 'package:spotiflac_android/utils/lyrics_metadata_helper.dart'; +import 'package:spotiflac_android/utils/string_utils.dart'; + +/// Embeds tags into a local working file; the queue owns publication and state. +class DownloadMetadataEmbedding { + static final _log = AppLogger('DownloadEmbedding'); + static const _coverCacheMax = 8; + final Map> _coverCache = {}; + final void Function(Track, String, ReplayGainResult) onReplayGain; + final Future Function(String, int) _loadCover; + final Future Function(Track) _loadLyrics; + final Future> Function(String, Map) + _editMetadata; + final Future Function(String) _scanReplayGain; + final Future Function(String) _scanAndApplyReplayGain; + final Future Function(String, String, String) _writeReplayGain; + final Future Function( + String, + String, + String?, + Map, + String, + ) + _writeWithFFmpeg; + + DownloadMetadataEmbedding({ + required this.onReplayGain, + Future Function(String, int)? loadCover, + Future Function(Track)? loadLyrics, + Future> Function(String, Map)? + editMetadata, + Future Function(String)? scanReplayGain, + Future Function(String)? scanAndApplyReplayGain, + Future Function(String, String, String)? writeReplayGain, + Future Function( + String, + String, + String?, + Map, + String, + )? + writeWithFFmpeg, + }) : _loadCover = loadCover ?? _downloadCover, + _loadLyrics = loadLyrics ?? _fetchLyrics, + _editMetadata = editMetadata ?? PlatformBridge.editFileMetadata, + _scanReplayGain = scanReplayGain ?? FFmpegService.scanReplayGain, + _scanAndApplyReplayGain = + scanAndApplyReplayGain ?? ReplayGainService.scanAndApplyToFile, + _writeReplayGain = writeReplayGain ?? ReplayGainService.writeTrackTags, + _writeWithFFmpeg = writeWithFFmpeg ?? _embedWithFFmpeg; + + static Future _fetchLyrics(Track track) => + PlatformBridge.getLyricsLRC( + track.id, + track.name, + track.artistName, + filePath: '', + durationMs: track.duration * 1000, + ); + + /// Returns fetched lyrics for a later SAF sidecar save without refetching. + Future embed( + String filePath, + Track track, { + required AppSettings settings, + required ExtensionState extensionState, + required String format, + String? genre, + String? label, + String? copyright, + String? comment, + String? lyricsLrc, + String? downloadService, + bool writeExternalLrc = true, + }) async { + if (!settings.embedMetadata) { + if (settings.embedReplayGain) { + final gain = await _scanAndApplyReplayGain(filePath); + if (gain != null) onReplayGain(track, filePath, gain); + } + return null; + } + + final isFlac = format == 'flac'; + final isM4a = format == 'm4a'; + final isMp3 = format == 'mp3'; + final albumArtist = DownloadMetadataResolver.albumArtistForMetadata( + track, + settings, + ); + final fields = _trackFields( + track, + albumArtist: albumArtist, + genre: genre ?? track.genre, + label: label ?? track.label, + copyright: copyright ?? track.copyright, + comment: comment ?? track.comment, + ); + // Start artwork before fetching lyrics so both network round trips overlap. + final coverUrl = normalizeRemoteHttpUrl(track.coverUrl); + final coverFuture = coverUrl == null || coverUrl.isEmpty + ? null + : _sharedCover(coverUrl, settings.embeddedCoverMaxDimension); + String? lrcContent; + try { + final metadata = _ffmpegFields(fields, format); + final lyricsEnabled = + settings.embedLyrics && + !DownloadMetadataResolver.shouldSkipLyrics( + extensionState, + track.source, + downloadService, + ); + final shouldEmbedLyrics = + lyricsEnabled && + (settings.lyricsMode == 'embed' || settings.lyricsMode == 'both'); + final shouldSaveExternalLyrics = + lyricsEnabled && + (settings.lyricsMode == 'external' || settings.lyricsMode == 'both'); + if (shouldEmbedLyrics || shouldSaveExternalLyrics) { + try { + final fetched = lyricsLrc != null && hasUsableLyricsContent(lyricsLrc) + ? lyricsLrc + : await _loadLyrics(track); + if (hasUsableLyricsContent(fetched) && + !isInstrumentalLyricsMarker(fetched)) { + lrcContent = fetched; + } + } catch (error) { + _log.w('Failed to fetch lyrics for $format: $error'); + } + } + 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'); + await File( + lrcPath == filePath ? '$filePath.lrc' : lrcPath, + ).writeAsString(lrcContent); + } catch (error) { + _log.w('Failed to save external LRC file for $format: $error'); + } + } + + ReplayGainResult? scannedReplayGain; + if (settings.embedReplayGain && !isFlac) { + try { + scannedReplayGain = await _scanReplayGain(filePath); + if (scannedReplayGain != null && format != 'opus') { + metadata['REPLAYGAIN_TRACK_GAIN'] = scannedReplayGain.trackGain; + metadata['REPLAYGAIN_TRACK_PEAK'] = scannedReplayGain.trackPeak; + onReplayGain(track, filePath, scannedReplayGain); + } + } catch (error) { + _log.w('Failed to scan ReplayGain for $format: $error'); + } + } + final coverPath = coverFuture == null ? null : await coverFuture; + final validCover = coverPath != null && await File(coverPath).exists() + ? coverPath + : null; + + // AC-4 needs its native MP4 writer. FFmpeg's mov muxer would rewrap it + // as QuickTime; non-AC-4 M4A falls through to the usual tag writers. + if (isM4a) { + try { + final ac4Result = await PlatformBridge.writeAC4Metadata(filePath, { + ..._ac4Fields(fields, settings.artistTagMode), + if (shouldEmbedLyrics) 'lyrics': ?lrcContent, + }, validCover ?? ''); + if (ac4Result['handled'] == true) return lrcContent; + } catch (error) { + _log.w('AC-4 metadata path failed, falling back to FFmpeg: $error'); + } + } + + var embeddedNatively = false; + // Opus R128 tags are written and verified after the metadata writer. + if (scannedReplayGain == null || format == 'opus') { + try { + final response = await _editMetadata(filePath, { + ...fields, + 'artist_tag_mode': settings.artistTagMode, + 'cover_path': ?validCover, + if (shouldEmbedLyrics && lrcContent != null) ...{ + 'lyrics': lrcContent, + 'unsyncedlyrics': lrcContent, + } else if ((isFlac || isM4a) && !shouldEmbedLyrics) ...{ + 'lyrics': '', + if (isFlac) 'unsyncedlyrics': '', + }, + }); + embeddedNatively = + response['success'] == true && + response['error'] == null && + response['method'] != 'ffmpeg'; + } catch (error) { + _log.w( + 'Native $format tag embed failed, falling back to FFmpeg: $error', + ); + } + } + if (!embeddedNatively) { + final result = await _writeWithFFmpeg( + filePath, + format, + validCover, + metadata, + settings.artistTagMode, + ); + if (result == null) _log.w('FFmpeg $format metadata embed failed'); + } + if (format == 'opus' && scannedReplayGain != null) { + if (await _writeReplayGain( + filePath, + scannedReplayGain.trackGain, + scannedReplayGain.trackPeak, + )) { + onReplayGain(track, filePath, scannedReplayGain); + } else { + _log.w('Failed to write Opus ReplayGain'); + } + } + if (isM4a && settings.embedReplayGain && scannedReplayGain != null) { + try { + await _editMetadata(filePath, { + 'replaygain_track_gain': scannedReplayGain.trackGain, + 'replaygain_track_peak': scannedReplayGain.trackPeak, + }); + } catch (error) { + _log.w('Failed to write native ReplayGain tags for $format: $error'); + } + } + if (isFlac) { + // Only FFmpeg needs a second rewrite for repeated Vorbis artist tags. + if (!embeddedNatively && + settings.artistTagMode == artistTagModeSplitVorbis) { + try { + await PlatformBridge.rewriteSplitArtistTags( + filePath, + track.artistName, + albumArtist ?? '', + ); + } catch (error) { + _log.w('Failed to rewrite split artist tags: $error'); + } + } + if (settings.embedReplayGain) { + try { + final gain = await _scanAndApplyReplayGain(filePath); + if (gain != null) onReplayGain(track, filePath, gain); + } catch (error) { + _log.w('Failed to embed ReplayGain via native writer: $error'); + } + } + } + } catch (error) { + _log.e('Failed to embed metadata to $format: $error'); + } + // The cache owns artwork until eviction or queue drain, allowing reuse. + return lrcContent; + } + + static Map _trackFields( + Track track, { + String? albumArtist, + String? genre, + String? label, + String? copyright, + String? comment, + }) => { + 'title': track.name, + 'artist': track.artistName, + 'album': track.albumName, + 'album_artist': ?albumArtist, + if (track.releaseDate != null) 'date': track.releaseDate!, + if (track.isrc != null) 'isrc': track.isrc!, + for (final entry in { + 'genre': genre, + 'label': label, + 'copyright': copyright, + 'comment': comment, + 'composer': track.composer, + 'album_type': track.albumType, + 'upc': track.upc, + }.entries) + if (entry.value != null && entry.value!.isNotEmpty) + entry.key: entry.value!, + for (final entry in { + 'track_number': track.trackNumber, + 'track_total': track.totalTracks, + 'disc_number': track.discNumber, + 'disc_total': track.totalDiscs, + }.entries) + if (entry.value != null && entry.value! > 0) + entry.key: entry.value!.toString(), + if (track.isExplicit) 'explicit': '1', + if (track.albumType?.toLowerCase() == 'compilation') 'compilation': '1', + }; + + static const _ffmpegKeys = { + 'title': 'TITLE', + 'artist': 'ARTIST', + 'album': 'ALBUM', + 'album_artist': 'ALBUMARTIST', + 'date': 'DATE', + 'isrc': 'ISRC', + 'genre': 'GENRE', + 'label': 'ORGANIZATION', + 'copyright': 'COPYRIGHT', + 'comment': 'COMMENT', + 'composer': 'COMPOSER', + 'explicit': 'ITUNESADVISORY', + 'album_type': 'RELEASETYPE', + 'upc': 'BARCODE', + 'compilation': 'COMPILATION', + }; + + static Map _ffmpegFields( + Map fields, + String format, + ) { + final metadata = { + for (final entry in _ffmpegKeys.entries) + if (fields[entry.key] case final String value) + entry.value: entry.key == 'album_type' ? value.toLowerCase() : value, + }; + final hasLegacyTags = format == 'flac' || format == 'mp3'; + for (final (number, total, tag, legacyTag) in [ + ('track_number', 'track_total', 'TRACKNUMBER', 'TRACK'), + ('disc_number', 'disc_total', 'DISCNUMBER', 'DISC'), + ]) { + final value = fields[number]; + if (value == null) continue; + final formatted = fields[total] == null + ? value + : '$value/${fields[total]}'; + metadata[tag] = formatted; + if (hasLegacyTags) metadata[legacyTag] = formatted; + } + if (hasLegacyTags && fields['date'] != null) { + metadata['YEAR'] = fields['date']!.split('-').first; + } + return metadata; + } + + static const _ac4Keys = { + 'title': 'title', + 'artist': 'artist', + 'album': 'album', + 'album_artist': 'albumArtist', + 'date': 'date', + 'genre': 'genre', + 'composer': 'composer', + 'track_number': 'trackNumber', + 'track_total': 'totalTracks', + 'disc_number': 'discNumber', + 'disc_total': 'totalDiscs', + 'isrc': 'isrc', + 'label': 'label', + 'copyright': 'copyright', + 'comment': 'comment', + }; + + static Map _ac4Fields( + Map fields, + String artistMode, + ) => { + for (final entry in _ac4Keys.entries) + if (fields[entry.key] case final String value) + entry.value: entry.key == 'artist' || entry.key == 'album_artist' + ? artistTagValueForMode(value, artistMode) + : value, + }; + + static Future _embedWithFFmpeg( + String path, + String format, + String? cover, + Map metadata, + String artistMode, + ) => switch (format) { + 'flac' => FFmpegService.embedMetadata( + flacPath: path, + coverPath: cover, + metadata: metadata, + artistTagMode: artistMode, + ), + 'm4a' => FFmpegService.embedMetadataToM4a( + m4aPath: path, + coverPath: cover, + metadata: metadata, + artistTagMode: artistMode, + ), + 'mp3' => FFmpegService.embedMetadataToMp3( + mp3Path: path, + coverPath: cover, + metadata: metadata, + artistTagMode: artistMode, + ), + _ => FFmpegService.embedMetadataToOpus( + opusPath: path, + coverPath: cover, + metadata: metadata, + artistTagMode: artistMode, + ), + }; + + Future _sharedCover(String url, int maxDimension) { + final key = '$maxDimension\u0000$url'; + final existing = _coverCache.remove(key); + if (existing != null) { + _coverCache[key] = existing; + return existing; + } + final fetch = Future.sync(() => _loadCover(url, maxDimension)) + .catchError((Object error) { + _log.w('Failed to load cover for embedding: $error'); + return null; + }) + .then((path) { + if (path == null) _coverCache.remove(key); + return path; + }); + _coverCache[key] = fetch; + while (_coverCache.length > _coverCacheMax) { + _evictCover(_coverCache.keys.first); + } + return fetch; + } + + static Future _downloadCover(String url, int maxDimension) async { + try { + final tempDir = await getTemporaryDirectory(); + final uniqueId = + '${DateTime.now().millisecondsSinceEpoch}_${Random().nextInt(10000)}'; + final path = '${tempDir.path}/cover_embed_$uniqueId.jpg'; + final result = await PlatformBridge.downloadCoverToFile( + url, + Platform.isAndroid || Platform.isIOS ? '' : path, + maxDimension: maxDimension, + ); + if (result['error'] != null) { + _log.w('Failed to download cover: ${result['error']}'); + return null; + } + return result['file_path'] as String? ?? path; + } catch (error) { + _log.e('Failed to download cover for embedding: $error'); + return null; + } + } + + void _evictCover(String key) { + _coverCache.remove(key)?.then((path) { + if (path != null) File(path).delete().catchError((_) => File(path)); + }); + } + + void clearCoverCache() { + for (final key in _coverCache.keys.toList()) { + _evictCover(key); + } + } +} diff --git a/lib/services/download_metadata_resolver.dart b/lib/services/download_metadata_resolver.dart new file mode 100644 index 00000000..17116c77 --- /dev/null +++ b/lib/services/download_metadata_resolver.dart @@ -0,0 +1,354 @@ +import 'package:spotiflac_android/models/settings.dart'; +import 'package:spotiflac_android/models/track.dart'; +import 'package:spotiflac_android/providers/extension_provider.dart'; +import 'package:spotiflac_android/services/download_album_metadata.dart'; +import 'package:spotiflac_android/services/download_track_metadata.dart'; +import 'package:spotiflac_android/services/platform_bridge.dart'; +import 'package:spotiflac_android/utils/logger.dart'; +import 'package:spotiflac_android/utils/string_utils.dart'; + +class DownloadMetadataLookup { + final Track track; + final String? deezerTrackId; + + const DownloadMetadataLookup({required this.track, this.deezerTrackId}); +} + +class DownloadExtendedMetadata { + final String? genre; + final String? label; + final String? copyright; + + const DownloadExtendedMetadata({this.genre, this.label, this.copyright}); +} + +/// Resolves download tags and identifiers from explicit provider snapshots. +/// Callers retain stage ordering, cancellation and queue state ownership. +class DownloadMetadataResolver { + static final _log = AppLogger('DownloadMetadata'); + static final _isrcPattern = RegExp(r'^[A-Z]{2}[A-Z0-9]{3}\d{7}$'); + static final _featuredArtistPattern = RegExp( + r'\s*[,;]\s*|\s+(?:feat\.?|ft\.?|featuring|with|x)\s+', + caseSensitive: false, + ); + final Future> Function(String, String, String) + loadMetadata; + + DownloadMetadataResolver({ + this.loadMetadata = PlatformBridge.getProviderMetadata, + }); + + static bool isValidIsrc(String value) => + _isrcPattern.hasMatch(value.toUpperCase()); + + Future prepareSourceTrack(Track track) async { + if (!track.id.startsWith('deezer:')) return track; + final id = track.id.substring('deezer:'.length); + final enriched = await enrichIncompleteDownloadTrack( + track, + 'deezer', + id, + loadMetadata: loadMetadata, + ); + return identical(enriched, track) ? track : enriched.copyWith(deezerId: id); + } + + Future resolveAlbumCredit( + Track track, + AppSettings settings, + ExtensionState extensionState, + ) async { + if (!settings.embedMetadata || + normalizeOptionalString(track.albumId) == null) { + return track; + } + final source = + normalizeOptionalString(track.source)?.toLowerCase() ?? + (track.id.contains(':') ? track.id.split(':').first.toLowerCase() : ''); + if (source.isEmpty) return track; + final providers = extensionState.extensions.where( + (extension) => extension.enabled && extension.hasMetadataProvider, + ); + final provider = + providers + .where((extension) => extension.id.toLowerCase() == source) + .firstOrNull ?? + providers + .where( + (extension) => + extension.replacesBuiltInProviders.contains(source), + ) + .firstOrNull; + // Album IDs belong to the source release, even when supplemental + // cross-provider enrichment is disabled. Never use an unrelated fallback. + return provider == null + ? track + : resolveDownloadAlbumArtist( + track, + provider.id, + loadMetadata: loadMetadata, + ); + } + + static String? albumArtistForMetadata(Track track, AppSettings settings) { + final artist = normalizeOptionalString(track.albumArtist); + if (artist == null || !settings.filterContributingArtistsInAlbumArtist) { + return artist; + } + final match = _featuredArtistPattern.firstMatch(artist); + return match != null && match.start > 0 + ? normalizeOptionalString(artist.substring(0, match.start)) + : artist; + } + + static bool _providerFlag( + ExtensionState state, + String? source, + String? service, + bool Function(Extension) matches, + ) { + final candidates = { + for (final value in [source, service]) + if (normalizeOptionalString(value) case final String candidate) + candidate.toLowerCase(), + }; + return state.extensions.any( + (extension) => + extension.enabled && + candidates.contains(extension.id.toLowerCase()) && + matches(extension), + ); + } + + static bool shouldSkipLyrics( + ExtensionState state, + String? source, + String? service, + ) => _providerFlag( + state, + source, + service, + (extension) => extension.skipLyrics, + ); + + static bool shouldSkipMetadataEnrichment( + ExtensionState state, + String? source, + String? service, + ) => _providerFlag( + state, + source, + service, + (extension) => extension.skipMetadataEnrichment, + ); + + static String? knownDeezerTrackId(Track track) => + normalizeOptionalString(track.deezerId) ?? + (track.id.startsWith('deezer:') + ? normalizeOptionalString(track.id.substring('deezer:'.length)) + : null) ?? + normalizeOptionalString(track.availability?.deezerId); + + Future _searchByIsrc( + String? isrc, { + required String lookupContext, + required String itemId, + }) async { + final normalized = normalizeOptionalString(isrc); + if (normalized == null || !isValidIsrc(normalized)) return null; + try { + _log.d('No Deezer ID, searching by $lookupContext: $normalized'); + final result = await PlatformBridge.searchDeezerByISRC( + normalized, + itemId: itemId, + ); + if (result['success'] == true && result['track_id'] != null) { + return result['track_id'].toString(); + } + } catch (error) { + _log.w('Failed to search Deezer by $lookupContext: $error'); + } + return null; + } + + Future resolveDeezerIdFromKnownOrIsrc( + Track track, + String itemId, { + required String lookupContext, + }) async { + final known = knownDeezerTrackId(track); + if (known != null || !isValidIsrc(track.isrc ?? '')) return known; + return _searchByIsrc( + track.isrc, + lookupContext: lookupContext, + itemId: itemId, + ); + } + + Future resolveDeezerIdViaProviderIfNeeded( + Track track, + String? deezerTrackId, + String itemId, { + required ExtensionState extensionState, + }) async { + if (deezerTrackId != null || + isValidIsrc(track.isrc ?? '') || + !(track.id.startsWith('tidal:') || track.id.startsWith('qobuz:'))) { + return DownloadMetadataLookup(track: track, deezerTrackId: deezerTrackId); + } + try { + final colon = track.id.indexOf(':'); + final provider = track.id.substring(0, colon); + final effective = resolveEffectiveMetadataProvider( + provider, + extensionState, + ); + final response = await loadMetadata( + effective.isEmpty ? provider : effective, + 'track', + track.id.substring(colon + 1), + ); + final data = response['track']; + if (data is Map) { + final isrc = normalizeOptionalString(data['isrc'] as String?); + if (isrc != null && isValidIsrc(isrc)) { + track = _copyResolvedMetadata(track, data); + deezerTrackId = await _searchByIsrc( + isrc, + lookupContext: '${effective.isEmpty ? provider : effective} ISRC', + itemId: itemId, + ); + if (deezerTrackId != null) { + track = copyTrackWithResolvedMetadata( + track, + deezerId: deezerTrackId, + ); + } + } + } + } catch (error) { + _log.w('Failed to resolve ISRC from provider: $error'); + } + return DownloadMetadataLookup( + track: track, + deezerTrackId: deezerTrackId ?? knownDeezerTrackId(track), + ); + } + + Future resolveSpotifyTrackViaDeezer( + Track track, + ) async { + final original = track; + String? deezerTrackId; + try { + final spotifyId = track.id.startsWith('spotify:track:') + ? track.id.split(':').last + : track.id; + final response = await PlatformBridge.convertSpotifyToDeezer( + 'track', + spotifyId, + ); + final data = response['track']; + if (data is Map) { + final rawId = data['spotify_id'] as String?; + deezerTrackId = rawId != null && rawId.startsWith('deezer:') + ? rawId.split(':')[1] + : response['id']?.toString(); + final isrc = normalizeOptionalString(data['isrc'] as String?); + final needsEnrich = + (track.releaseDate == null && + normalizeOptionalString(data['release_date'] as String?) != + null) || + (!isValidIsrc(track.isrc ?? '') && isrc != null) || + [ + (track.trackNumber, data['track_number'] as int?), + (track.totalTracks, data['total_tracks'] as int?), + (track.discNumber, data['disc_number'] as int?), + (track.totalDiscs, data['total_discs'] as int?), + ].any((pair) => (pair.$1 ?? 0) <= 0 && (pair.$2 ?? 0) > 0) || + ((track.composer == null || track.composer!.isEmpty) && + normalizeOptionalString(data['composer'] as String?) != null) || + deezerTrackId != null; + if (needsEnrich) { + track = _copyResolvedMetadata(track, data, deezerId: deezerTrackId); + } + } else if (response['id'] != null) { + deezerTrackId = response['id'].toString(); + track = copyTrackWithResolvedMetadata(track, deezerId: deezerTrackId); + } + } catch (error) { + _log.w('Failed to convert Spotify to Deezer via SongLink: $error'); + track = original; + deezerTrackId = null; + } + return DownloadMetadataLookup( + track: track, + deezerTrackId: deezerTrackId ?? knownDeezerTrackId(track), + ); + } + + Future loadExtendedMetadataForDeezerId( + String? id, + ) async { + if (id == null || id.isEmpty) return null; + try { + final data = await PlatformBridge.getDeezerExtendedMetadata(id); + return DownloadExtendedMetadata( + genre: normalizeOptionalString(data?['genre']), + label: normalizeOptionalString(data?['label']), + copyright: normalizeOptionalString(data?['copyright']), + ); + } catch (error) { + _log.w('Failed to fetch extended metadata from Deezer: $error'); + return const DownloadExtendedMetadata(); + } + } + + Track _copyResolvedMetadata( + Track track, + Map data, { + String? deezerId, + }) => copyTrackWithResolvedMetadata( + track, + resolvedIsrc: data['isrc'] as String?, + releaseDate: data['release_date'] as String?, + trackNumber: data['track_number'] as int?, + totalTracks: data['total_tracks'] as int?, + discNumber: data['disc_number'] as int?, + totalDiscs: data['total_discs'] as int?, + composer: data['composer'] as String?, + deezerId: deezerId, + ); +} + +/// Fills resolved identifiers and missing metadata without dropping other tags. +Track copyTrackWithResolvedMetadata( + Track track, { + String? resolvedIsrc, + int? trackNumber, + int? totalTracks, + int? discNumber, + int? totalDiscs, + String? releaseDate, + String? deezerId, + String? composer, +}) { + final normalizedIsrc = normalizeOptionalString(resolvedIsrc); + return track.copyWith( + coverUrl: normalizeCoverReference(track.coverUrl), + isrc: + normalizedIsrc != null && + DownloadMetadataResolver.isValidIsrc(normalizedIsrc) + ? normalizedIsrc + : track.isrc, + trackNumber: (track.trackNumber ?? 0) > 0 ? track.trackNumber : trackNumber, + discNumber: (track.discNumber ?? 0) > 0 ? track.discNumber : discNumber, + totalDiscs: (track.totalDiscs ?? 0) > 0 ? track.totalDiscs : totalDiscs, + releaseDate: track.releaseDate ?? normalizeOptionalString(releaseDate), + deezerId: deezerId ?? track.deezerId, + totalTracks: (track.totalTracks ?? 0) > 0 ? track.totalTracks : totalTracks, + composer: (track.composer?.isNotEmpty ?? false) + ? track.composer + : normalizeOptionalString(composer), + ); +} diff --git a/test/download_metadata_embedding_test.dart b/test/download_metadata_embedding_test.dart new file mode 100644 index 00000000..0a4bc6a0 --- /dev/null +++ b/test/download_metadata_embedding_test.dart @@ -0,0 +1,597 @@ +import 'dart:async'; +import 'dart:convert'; +import 'dart:io'; + +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:spotiflac_android/models/settings.dart'; +import 'package:spotiflac_android/models/track.dart'; +import 'package:spotiflac_android/providers/extension_provider.dart'; +import 'package:spotiflac_android/services/download_metadata_embedding.dart'; +import 'package:spotiflac_android/services/ffmpeg_service.dart'; +import 'package:spotiflac_android/utils/artist_utils.dart'; + +class EmbeddingHarness { + final events = []; + final nativeWrites = >[]; + final fallbackWrites = >[]; + late final DownloadMetadataEmbedding service; + + EmbeddingHarness({ + bool nativeSuccess = true, + bool gainWriteSuccess = true, + ReplayGainResult? gain, + Future Function(String, int)? loadCover, + Future Function(Track)? loadLyrics, + }) { + service = DownloadMetadataEmbedding( + onReplayGain: (track, path, result) => events.add('aggregate'), + loadCover: loadCover, + loadLyrics: + loadLyrics ?? + (_) async { + events.add('lyrics'); + return '[00:01.00]Line'; + }, + editMetadata: (path, metadata) async { + events.add('native'); + nativeWrites.add(Map.of(metadata)); + return { + 'success': nativeSuccess, + 'method': nativeSuccess ? 'rust' : 'ffmpeg', + }; + }, + writeWithFFmpeg: (path, format, cover, metadata, artistMode) async { + events.add('ffmpeg'); + fallbackWrites.add(Map.of(metadata)); + return path; + }, + scanReplayGain: (_) async { + events.add('scan'); + return gain; + }, + scanAndApplyReplayGain: (_) async { + events.add('scanAndApply'); + return gain; + }, + writeReplayGain: (_, _, _) async { + events.add('writeGain'); + return gainWriteSuccess; + }, + ); + } +} + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + const channel = MethodChannel('com.zarz.spotiflac/backend'); + final messenger = + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger; + late Directory directory; + final ac4Writes = >[]; + final splitRewrites = >[]; + var handledAc4 = false; + const track = Track( + id: 'example:recording', + source: 'example', + name: 'Song', + artistName: 'Artist, Guest', + albumName: 'Album', + albumArtist: 'Artist, Guest', + duration: 180, + trackNumber: 2, + totalTracks: 12, + discNumber: 1, + totalDiscs: 2, + releaseDate: '2026-01-02', + isrc: 'USAAA2400001', + composer: 'Composer', + genre: 'Genre', + label: 'Label', + copyright: 'Copyright', + comment: 'Comment', + explicit: true, + albumType: 'Compilation', + upc: '012345678901', + ); + const gain = ReplayGainResult( + trackGain: '-5.00 dB', + trackPeak: '0.900000', + integratedLufs: -13, + truePeakLinear: 0.9, + ); + + setUp(() async { + directory = await Directory.systemTemp.createTemp('download-embedding-'); + ac4Writes.clear(); + splitRewrites.clear(); + handledAc4 = false; + messenger.setMockMethodCallHandler(channel, (call) async { + final args = call.arguments as Map; + switch (call.method) { + case 'acquireDownloadDirectory': + return 'test-scope'; + case 'releaseDownloadDirectory': + return null; + case 'writeAC4Metadata': + ac4Writes.add( + Map.from( + jsonDecode(args['metadata_json'] as String) as Map, + ), + ); + return jsonEncode({'handled': handledAc4}); + case 'rewriteSplitArtistTags': + splitRewrites.add(args); + return jsonEncode({'success': true}); + } + fail('Unexpected backend call: ${call.method}'); + }); + }); + + tearDown(() async { + messenger.setMockMethodCallHandler(channel, null); + await directory.delete(recursive: true); + }); + + for (final format in ['flac', 'm4a', 'mp3', 'opus']) { + test( + '$format native tags retain writer-specific keys and full credits', + () async { + final harness = EmbeddingHarness(); + await harness.service.embed( + '${directory.path}/song.$format', + track, + settings: const AppSettings( + embedLyrics: false, + artistTagMode: artistTagModeSplitVorbis, + ), + extensionState: const ExtensionState(), + format: format, + genre: 'Override', + label: 'Override label', + copyright: 'Override copyright', + comment: 'Override comment', + ); + expect(harness.nativeWrites.single, { + 'title': 'Song', + 'artist': 'Artist, Guest', + 'album': 'Album', + 'album_artist': 'Artist, Guest', + 'date': '2026-01-02', + 'isrc': 'USAAA2400001', + 'composer': 'Composer', + 'genre': 'Override', + 'label': 'Override label', + 'copyright': 'Override copyright', + 'comment': 'Override comment', + 'track_number': '2', + 'track_total': '12', + 'disc_number': '1', + 'disc_total': '2', + 'explicit': '1', + 'album_type': 'Compilation', + 'upc': '012345678901', + 'compilation': '1', + 'artist_tag_mode': artistTagModeSplitVorbis, + if (format == 'flac' || format == 'm4a') 'lyrics': '', + if (format == 'flac') 'unsyncedlyrics': '', + }); + expect(harness.fallbackWrites, isEmpty); + expect(splitRewrites, isEmpty); + }, + ); + + test( + '$format fallback preserves totals, legacy tags and lyric clearing', + () async { + final harness = EmbeddingHarness(nativeSuccess: false); + await harness.service.embed( + '${directory.path}/song.$format', + track, + settings: const AppSettings(embedLyrics: false), + extensionState: const ExtensionState(), + format: format, + ); + expect(harness.fallbackWrites.single, { + 'TITLE': 'Song', + 'ARTIST': 'Artist, Guest', + 'ALBUM': 'Album', + 'ALBUMARTIST': 'Artist, Guest', + 'DATE': '2026-01-02', + 'ISRC': 'USAAA2400001', + 'COMPOSER': 'Composer', + 'GENRE': 'Genre', + 'ORGANIZATION': 'Label', + 'COPYRIGHT': 'Copyright', + 'COMMENT': 'Comment', + 'TRACKNUMBER': '2/12', + 'DISCNUMBER': '1/2', + 'ITUNESADVISORY': '1', + 'RELEASETYPE': 'compilation', + 'BARCODE': '012345678901', + 'COMPILATION': '1', + if (format == 'flac' || format == 'mp3') ...{ + 'TRACK': '2/12', + 'DISC': '1/2', + 'YEAR': '2026', + }, + if (format == 'flac' || format == 'm4a') 'LYRICS': '', + if (format == 'flac') 'UNSYNCEDLYRICS': '', + }); + expect(harness.events, ['native', 'ffmpeg']); + }, + ); + } + + test( + 'AC-4 resolves primary credits and stops after the native MP4 writer', + () async { + handledAc4 = true; + final harness = EmbeddingHarness(); + final result = await harness.service.embed( + '${directory.path}/song.m4a', + track, + settings: const AppSettings(artistTagMode: artistTagModePrimary), + extensionState: const ExtensionState(), + format: 'm4a', + lyricsLrc: '[00:02.00]Prepared line', + ); + expect(result, '[00:02.00]Prepared line'); + expect(ac4Writes.single, { + 'title': 'Song', + 'artist': 'Artist', + 'album': 'Album', + 'albumArtist': 'Artist', + 'date': '2026-01-02', + 'genre': 'Genre', + 'composer': 'Composer', + 'trackNumber': '2', + 'totalTracks': '12', + 'discNumber': '1', + 'totalDiscs': '2', + 'isrc': 'USAAA2400001', + 'label': 'Label', + 'copyright': 'Copyright', + 'comment': 'Comment', + 'lyrics': result, + }); + expect(harness.events, isEmpty); + }, + ); + + for (final mode in ['embed', 'external', 'both']) { + test( + 'lyrics mode $mode reuses a supplied LRC and honors sidecar writes', + () async { + final harness = EmbeddingHarness(); + final path = '${directory.path}/song.flac'; + final result = await harness.service.embed( + path, + track, + settings: AppSettings(lyricsMode: mode), + extensionState: const ExtensionState(), + format: 'flac', + lyricsLrc: '[00:01.00]Prepared line', + ); + expect(result, '[00:01.00]Prepared line'); + expect(harness.events, ['native']); + expect( + harness.nativeWrites.single['lyrics'], + mode == 'external' ? '' : result, + ); + final sidecar = File('${directory.path}/song.lrc'); + expect(await sidecar.exists(), mode != 'embed'); + if (await sidecar.exists()) { + expect(await sidecar.readAsString(), result); + } + }, + ); + } + + test('SAF caller receives lyrics without writing a local sidecar', () async { + final harness = EmbeddingHarness(); + final result = await harness.service.embed( + '${directory.path}/song.flac', + track, + settings: const AppSettings(lyricsMode: 'both'), + extensionState: const ExtensionState(), + format: 'flac', + writeExternalLrc: false, + ); + expect(result, '[00:01.00]Line'); + expect(harness.events, ['lyrics', 'native']); + expect(await File('${directory.path}/song.lrc').exists(), isFalse); + }); + + test( + 'manifest skipLyrics suppresses both fetching and a supplied LRC', + () async { + final harness = EmbeddingHarness(); + final state = ExtensionState( + extensions: [ + Extension( + id: 'example-downloader', + name: 'example-downloader', + displayName: 'Example Downloader', + version: '1.0.0', + description: '', + enabled: true, + status: 'ready', + skipLyrics: true, + ), + ], + ); + final result = await harness.service.embed( + '${directory.path}/song.flac', + track, + settings: const AppSettings(lyricsMode: 'both'), + extensionState: state, + format: 'flac', + downloadService: ' EXAMPLE-DOWNLOADER ', + lyricsLrc: '[00:01.00]Prepared line', + ); + expect(result, isNull); + expect(harness.events, ['native']); + expect(harness.nativeWrites.single['lyrics'], ''); + expect(await File('${directory.path}/song.lrc').exists(), isFalse); + }, + ); + + for (final invalidLyrics in [ + '[instrumental:true]', + '[ar:Artist]\n[ti:Song]', + ]) { + test('non-display lyrics are discarded: $invalidLyrics', () async { + final harness = EmbeddingHarness(loadLyrics: (_) async => invalidLyrics); + final result = await harness.service.embed( + '${directory.path}/song.mp3', + track, + settings: const AppSettings(), + extensionState: const ExtensionState(), + format: 'mp3', + ); + expect(result, isNull); + expect(harness.nativeWrites.single.containsKey('lyrics'), isFalse); + }); + } + + test( + 'cover download overlaps lyrics and coalesces concurrent album tracks', + () async { + final coverStarted = Completer(); + final coverReady = Completer(); + final lyricsStarted = Completer(); + var downloads = 0; + final coverFile = File('${directory.path}/cover.jpg'); + await coverFile.writeAsBytes([1, 2, 3]); + final harness = EmbeddingHarness( + loadCover: (url, dimension) { + downloads++; + expect(url, 'https://example.test/cover'); + if (!coverStarted.isCompleted) coverStarted.complete(); + return coverReady.future; + }, + loadLyrics: (_) async { + expect(coverStarted.isCompleted, isTrue); + if (!lyricsStarted.isCompleted) lyricsStarted.complete(); + return '[00:01.00]Line'; + }, + ); + final withCover = track.copyWith(coverUrl: 'https://example.test/cover'); + final first = harness.service.embed( + '${directory.path}/first.flac', + withCover, + settings: const AppSettings(), + extensionState: const ExtensionState(), + format: 'flac', + ); + final second = harness.service.embed( + '${directory.path}/second.flac', + withCover, + settings: const AppSettings(), + extensionState: const ExtensionState(), + format: 'flac', + ); + await lyricsStarted.future; + expect(downloads, 1); + expect(harness.nativeWrites, isEmpty); + coverReady.complete(coverFile.path); + expect(await Future.wait([first, second]), [ + '[00:01.00]Line', + '[00:01.00]Line', + ]); + expect(harness.nativeWrites.map((fields) => fields['cover_path']), [ + coverFile.path, + coverFile.path, + ]); + harness.service.clearCoverCache(); + await Future.delayed(Duration.zero); + expect(await coverFile.exists(), isFalse); + }, + ); + + test( + 'failed covers retry and cache dimensions select separate artwork', + () async { + final requests = []; + final harness = EmbeddingHarness( + loadCover: (_, dimension) async { + requests.add(dimension); + if (requests.length == 1) throw StateError('cover unavailable'); + if (requests.length == 2) return null; + final path = '${directory.path}/cover-$dimension.jpg'; + await File(path).writeAsBytes([1]); + return path; + }, + ); + for (final dimension in [100, 100, 100, 100, 200]) { + await harness.service.embed( + '${directory.path}/song.flac', + track.copyWith(coverUrl: 'https://example.test/cover'), + settings: AppSettings( + embedLyrics: false, + embeddedCoverMaxDimension: dimension, + ), + extensionState: const ExtensionState(), + format: 'flac', + ); + } + expect(requests, [100, 100, 100, 200]); + expect(harness.nativeWrites.first.containsKey('cover_path'), isFalse); + expect( + harness.nativeWrites.last['cover_path'], + '${directory.path}/cover-200.jpg', + ); + harness.service.clearCoverCache(); + await Future.delayed(Duration.zero); + }, + ); + + test( + 'cover LRU touch keeps reused release and deletes the least recent file', + () async { + final paths = {}; + final harness = EmbeddingHarness( + loadCover: (url, _) async { + final path = '${directory.path}/cover-${paths.length}.jpg'; + await File(path).writeAsBytes([1]); + paths[url] = path; + return path; + }, + ); + for (final id in [0, 1, 2, 3, 4, 5, 6, 7, 0, 8]) { + await harness.service.embed( + '${directory.path}/song.flac', + track.copyWith(coverUrl: 'https://example.test/cover-$id'), + settings: const AppSettings(embedLyrics: false), + extensionState: const ExtensionState(), + format: 'flac', + ); + } + expect(paths.length, 9); + expect( + await File(paths['https://example.test/cover-0']!).exists(), + isTrue, + ); + expect( + await File(paths['https://example.test/cover-1']!).exists(), + isFalse, + ); + harness.service.clearCoverCache(); + await Future.delayed(Duration.zero); + for (final path in paths.values) { + expect(await File(path).exists(), isFalse); + } + }, + ); + + test( + 'synchronously failed artwork is contained while lyrics are pending', + () async { + var requests = 0; + final lyrics = Completer(); + final startedLyrics = Completer(); + final harness = EmbeddingHarness( + loadCover: (_, _) { + requests++; + throw StateError('cover unavailable'); + }, + loadLyrics: (_) { + if (!startedLyrics.isCompleted) startedLyrics.complete(); + return lyrics.future; + }, + ); + final selected = track.copyWith( + coverUrl: 'https://example.test/failed-cover', + ); + final first = harness.service.embed( + '${directory.path}/first.flac', + selected, + settings: const AppSettings(), + extensionState: const ExtensionState(), + format: 'flac', + ); + await startedLyrics.future; + await Future.delayed(Duration.zero); + expect(harness.nativeWrites, isEmpty); + lyrics.complete('[00:01.00]Line'); + expect(await first, '[00:01.00]Line'); + await harness.service.embed( + '${directory.path}/second.flac', + selected, + settings: const AppSettings(), + extensionState: const ExtensionState(), + format: 'flac', + ); + expect(requests, 2); + expect( + harness.nativeWrites.every( + (fields) => !fields.containsKey('cover_path'), + ), + isTrue, + ); + }, + ); + + for (final format in ['flac', 'm4a', 'mp3', 'opus']) { + test('$format gain aggregation preserves writer ordering', () async { + final harness = EmbeddingHarness(gain: gain); + await harness.service.embed( + '${directory.path}/song.$format', + track, + settings: const AppSettings(embedLyrics: false, embedReplayGain: true), + extensionState: const ExtensionState(), + format: format, + ); + expect(harness.events, switch (format) { + 'flac' => ['native', 'scanAndApply', 'aggregate'], + 'm4a' => ['scan', 'aggregate', 'ffmpeg', 'native'], + 'mp3' => ['scan', 'aggregate', 'ffmpeg'], + _ => ['scan', 'native', 'writeGain', 'aggregate'], + }); + if (format == 'm4a' || format == 'mp3') { + expect( + harness.fallbackWrites.single['REPLAYGAIN_TRACK_GAIN'], + gain.trackGain, + ); + } + if (format == 'opus') { + expect( + harness.nativeWrites.single.containsKey('replaygain_track_gain'), + isFalse, + ); + } + }); + } + + test('failed Opus R128 save does not aggregate unpersisted gain', () async { + final harness = EmbeddingHarness(gain: gain, gainWriteSuccess: false); + await harness.service.embed( + '${directory.path}/song.opus', + track, + settings: const AppSettings(embedLyrics: false, embedReplayGain: true), + extensionState: const ExtensionState(), + format: 'opus', + ); + expect(harness.events, ['scan', 'native', 'writeGain']); + }); + + test( + 'metadata disabled still writes and aggregates enabled ReplayGain', + () async { + final harness = EmbeddingHarness(gain: gain); + final result = await harness.service.embed( + '${directory.path}/song.flac', + track, + settings: const AppSettings( + embedMetadata: false, + embedReplayGain: true, + ), + extensionState: const ExtensionState(), + format: 'flac', + ); + expect(result, isNull); + expect(harness.events, ['scanAndApply', 'aggregate']); + }, + ); +} diff --git a/test/download_metadata_resolver_test.dart b/test/download_metadata_resolver_test.dart new file mode 100644 index 00000000..4772fd36 --- /dev/null +++ b/test/download_metadata_resolver_test.dart @@ -0,0 +1,423 @@ +import 'dart:convert'; + +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:spotiflac_android/models/settings.dart'; +import 'package:spotiflac_android/models/track.dart'; +import 'package:spotiflac_android/providers/extension_provider.dart'; +import 'package:spotiflac_android/services/download_metadata_resolver.dart'; + +Extension provider( + String id, { + bool enabled = true, + bool skipLyrics = false, + bool skipMetadataEnrichment = false, + List replaces = const [], +}) => Extension( + id: id, + name: id, + displayName: 'Example Provider', + version: '1.0.0', + description: '', + enabled: enabled, + status: 'ready', + hasMetadataProvider: true, + skipLyrics: skipLyrics, + skipMetadataEnrichment: skipMetadataEnrichment, + capabilities: {'replacesBuiltInProviders': replaces}, +); + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + const channel = MethodChannel('com.zarz.spotiflac/backend'); + final messenger = + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger; + const track = Track( + id: 'example:recording', + source: 'example', + name: 'Song (Remix)', + artistName: 'Artist, Guest', + albumName: 'Album', + albumId: 'album-1', + albumArtist: 'Artist, Guest', + duration: 180, + previewUrl: 'https://example.test/preview', + ); + + tearDown(() => messenger.setMockMethodCallHandler(channel, null)); + + test('manifest flags use enabled source or service with normalized case', () { + final state = ExtensionState( + extensions: [ + provider('Example', skipLyrics: true), + provider('downloader', skipMetadataEnrichment: true), + provider( + 'disabled', + enabled: false, + skipLyrics: true, + skipMetadataEnrichment: true, + ), + ], + ); + expect( + DownloadMetadataResolver.shouldSkipLyrics(state, ' example ', null), + isTrue, + ); + expect( + DownloadMetadataResolver.shouldSkipMetadataEnrichment( + state, + null, + ' DOWNLOADER ', + ), + isTrue, + ); + expect( + DownloadMetadataResolver.shouldSkipLyrics(state, 'disabled', ''), + isFalse, + ); + expect( + DownloadMetadataResolver.shouldSkipMetadataEnrichment( + state, + 'disabled', + 'unknown', + ), + isFalse, + ); + expect( + DownloadMetadataResolver.shouldSkipLyrics(state, null, ' '), + isFalse, + ); + }); + + test( + 'album credits only use the source or its declared replacement', + () async { + final calls = <(String, String, String)>[]; + final resolver = DownloadMetadataResolver( + loadMetadata: (provider, kind, id) async { + calls.add((provider, kind, id)); + return { + 'album_info': {'id': id, 'artists': 'Artist'}, + }; + }, + ); + final unrelated = ExtensionState(extensions: [provider('unrelated')]); + expect( + await resolver.resolveAlbumCredit( + track, + const AppSettings(), + unrelated, + ), + same(track), + ); + expect( + await resolver.resolveAlbumCredit( + track, + const AppSettings(embedMetadata: false), + ExtensionState(extensions: [provider('example')]), + ), + same(track), + ); + expect(calls, isEmpty); + final direct = await resolver.resolveAlbumCredit( + track, + const AppSettings(), + ExtensionState( + extensions: [provider('Example', skipMetadataEnrichment: true)], + ), + ); + expect(direct.albumArtist, 'Artist'); + expect(direct.artistName, track.artistName); + final replaced = await resolver.resolveAlbumCredit( + track, + const AppSettings(), + ExtensionState( + extensions: [ + provider('replacement', replaces: ['example']), + ], + ), + ); + expect(replaced.albumArtist, 'Artist'); + expect(calls, [ + ('Example', 'album', 'album-1'), + ('replacement', 'album', 'album-1'), + ]); + }, + ); + + test( + 'album filtering keeps joint credits and applies existing guest separators', + () { + const settings = AppSettings( + filterContributingArtistsInAlbumArtist: true, + ); + expect( + DownloadMetadataResolver.albumArtistForMetadata( + track, + const AppSettings(), + ), + 'Artist, Guest', + ); + expect( + DownloadMetadataResolver.albumArtistForMetadata(track, settings), + 'Artist', + ); + expect( + DownloadMetadataResolver.albumArtistForMetadata( + track.copyWith(albumArtist: 'Band & Partner'), + settings, + ), + 'Band & Partner', + ); + expect( + DownloadMetadataResolver.albumArtistForMetadata( + track.copyWith(albumArtist: ' Artist with Guest '), + settings, + ), + 'Artist', + ); + }, + ); + + test( + 'legacy identifiers retain explicit, source and availability precedence', + () { + final legacy = track.copyWith( + id: 'deezer: source-id ', + deezerId: ' explicit-id ', + availability: const ServiceAvailability(deezerId: 'available-id'), + ); + expect( + DownloadMetadataResolver.knownDeezerTrackId(legacy), + 'explicit-id', + ); + expect( + DownloadMetadataResolver.knownDeezerTrackId( + legacy.copyWith(deezerId: ' '), + ), + 'source-id', + ); + expect( + DownloadMetadataResolver.knownDeezerTrackId( + legacy.copyWith(id: 'example:recording', deezerId: ' '), + ), + 'available-id', + ); + }, + ); + + test( + 'source preparation enriches before naming and preserves selected recording', + () async { + final resolver = DownloadMetadataResolver( + loadMetadata: (provider, kind, id) async { + expect((provider, kind, id), ('deezer', 'track', 'source-id')); + return { + 'track': { + 'name': 'Other recording', + 'album_artist': 'Artist', + 'composer': 'Composer', + 'track_number': 2, + 'total_tracks': 8, + 'isrc': 'USAAA2400001', + }, + }; + }, + ); + expect(await resolver.prepareSourceTrack(track), same(track)); + final prepared = await resolver.prepareSourceTrack( + track.copyWith(id: 'deezer:source-id'), + ); + expect(prepared.name, track.name); + expect(prepared.albumArtist, 'Artist'); + expect(prepared.deezerId, 'source-id'); + expect(prepared.trackNumber, 2); + }, + ); + + test( + 'legacy provider lookup resolves through an enabled metadata replacement', + () async { + final selected = track.copyWith( + id: 'tidal:recording', + isrc: 'invalid', + trackNumber: 4, + totalTracks: 8, + ); + final resolver = DownloadMetadataResolver( + loadMetadata: (provider, kind, id) async { + expect( + (provider, kind, id), + ('example-metadata', 'track', 'recording'), + ); + return { + 'track': { + 'isrc': 'USAAA2400001', + 'track_number': 2, + 'total_tracks': 10, + 'composer': 'Composer', + 'disc_number': 1, + }, + }; + }, + ); + messenger.setMockMethodCallHandler(channel, (call) async { + expect(call.method, 'searchDeezerByISRC'); + expect(call.arguments, { + 'isrc': 'USAAA2400001', + 'item_id': 'queue-item', + }); + return jsonEncode({'success': true, 'track_id': 'resolved-id'}); + }); + final resolved = await resolver.resolveDeezerIdViaProviderIfNeeded( + selected, + null, + 'queue-item', + extensionState: ExtensionState( + extensions: [ + provider('example-metadata', replaces: ['tidal']), + ], + ), + ); + expect(resolved.deezerTrackId, 'resolved-id'); + expect(resolved.track.toJson(), { + ...selected.toJson(), + 'isrc': 'USAAA2400001', + 'composer': 'Composer', + 'discNumber': 1, + 'deezerId': 'resolved-id', + }); + }, + ); + + test( + 'known identifiers and valid ISRC avoid supplemental provider lookups', + () async { + final resolver = DownloadMetadataResolver( + loadMetadata: (_, _, _) async => + throw StateError('unexpected provider lookup'), + ); + final known = track.copyWith(deezerId: 'known-id'); + expect( + await resolver.resolveDeezerIdFromKnownOrIsrc( + known, + 'item', + lookupContext: 'ISRC', + ), + 'known-id', + ); + final selected = track.copyWith( + id: 'qobuz:recording', + isrc: 'USAAA2400001', + ); + final resolved = await resolver.resolveDeezerIdViaProviderIfNeeded( + selected, + null, + 'item', + extensionState: const ExtensionState(), + ); + expect(resolved.track, same(selected)); + expect(resolved.deezerTrackId, isNull); + expect( + await resolver.resolveDeezerIdFromKnownOrIsrc( + track.copyWith(isrc: ' USAAA2400001 '), + 'item', + lookupContext: 'ISRC', + ), + isNull, + ); + }, + ); + + test('failed provider resolution keeps every original field', () async { + final selected = track.copyWith(id: 'qobuz:failed', isrc: 'invalid'); + final resolver = DownloadMetadataResolver( + loadMetadata: (_, _, _) async => throw StateError('unavailable'), + ); + final resolved = await resolver.resolveDeezerIdViaProviderIfNeeded( + selected, + null, + 'item', + extensionState: const ExtensionState(), + ); + expect(resolved.track, same(selected)); + expect(resolved.deezerTrackId, isNull); + }); + + test( + 'legacy conversion fills missing tags without replacing existing credits', + () async { + messenger.setMockMethodCallHandler(channel, (call) async { + expect(call.method, 'convertSpotifyToDeezer'); + return jsonEncode({ + 'track': { + 'spotify_id': 'deezer:resolved-id', + 'isrc': 'USAAA2400001', + 'release_date': '2026-01-01', + 'track_number': 3, + 'total_tracks': 12, + 'composer': 'Composer', + }, + }); + }); + final selected = track.copyWith(trackNumber: 5); + final result = await DownloadMetadataResolver() + .resolveSpotifyTrackViaDeezer(selected); + expect(result.deezerTrackId, 'resolved-id'); + expect(result.track.toJson(), { + ...selected.toJson(), + 'deezerId': 'resolved-id', + 'isrc': 'USAAA2400001', + 'releaseDate': '2026-01-01', + 'totalTracks': 12, + 'composer': 'Composer', + }); + }, + ); + + test( + 'extended metadata skips absent IDs and normalizes empty fields', + () async { + var calls = 0; + messenger.setMockMethodCallHandler(channel, (call) async { + calls++; + expect(call.method, 'getDeezerExtendedMetadata'); + return jsonEncode({'genre': ' Rock ', 'label': '', 'copyright': ' '}); + }); + final resolver = DownloadMetadataResolver(); + expect(await resolver.loadExtendedMetadataForDeezerId(null), isNull); + expect(await resolver.loadExtendedMetadataForDeezerId(''), isNull); + final metadata = await resolver.loadExtendedMetadataForDeezerId( + 'track-id', + ); + expect(calls, 1); + expect(metadata?.genre, 'Rock'); + expect(metadata?.label, isNull); + expect(metadata?.copyright, isNull); + }, + ); + + test( + 'malformed legacy conversion keeps the original track and known ID', + () async { + messenger.setMockMethodCallHandler( + channel, + (call) async => jsonEncode({ + 'track': { + 'spotify_id': 'deezer:new-id', + 'isrc': 'USAAA2400001', + 'track_number': 'invalid', + }, + }), + ); + final selected = track.copyWith( + id: 'example:malformed', + deezerId: 'known-id', + ); + final result = await DownloadMetadataResolver() + .resolveSpotifyTrackViaDeezer(selected); + expect(result.track, same(selected)); + expect(result.deezerTrackId, 'known-id'); + }, + ); +}