refactor(downloads): separate metadata resolution and embedding

This commit is contained in:
zarzet committed 2026-10-06 13:09:45 +07:00
1 parent b8787168d1
commit 7b9405aaf6
9 files changed
+1958 -1007

No files matched your search

+18 -9
View File
@@ -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<DownloadQueueState> {
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<String, Future<String?>> _embedCoverCache = {};
final _metadataResolver = DownloadMetadataResolver();
late final _metadataEmbedding = DownloadMetadataEmbedding(
onReplayGain: _storeTrackReplayGainForAlbum,
);
late final ProgressStreamPoller<Map<String, dynamic>> _progressPoller =
ProgressStreamPoller<Map<String, dynamic>>(
streamProvider: PlatformBridge.downloadProgressStream,
@@ -631,7 +633,10 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
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<DownloadQueueState> {
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<DownloadQueueState> {
stoppedWhilePaused && _networkPausedByWifiOnly;
_stopProgressPolling();
_clearEmbedCoverCache();
_metadataEmbedding.clearCoverCache();
if (!keepConnectivityMonitoring) {
_stopConnectivityMonitoring();
}
@@ -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<Track> _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<Track> _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 = <String>{};
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 = <String>{};
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<String?> _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<String, dynamic>?;
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<String, dynamic>) {
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<String?> _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<String?> _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<String?>? 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 = <String, String>{
'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 = <String, String>{
'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 = <String, String>{
'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<String?> _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<String?> _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,
);
}
@@ -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;
@@ -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,
);
@@ -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<DownloadExtendedMetadata?>.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<bool> _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<bool> _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;
@@ -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<String, Future<String?>> _coverCache = {};
final void Function(Track, String, ReplayGainResult) onReplayGain;
final Future<String?> Function(String, int) _loadCover;
final Future<String> Function(Track) _loadLyrics;
final Future<Map<String, dynamic>> Function(String, Map<String, String>)
_editMetadata;
final Future<ReplayGainResult?> Function(String) _scanReplayGain;
final Future<ReplayGainResult?> Function(String) _scanAndApplyReplayGain;
final Future<bool> Function(String, String, String) _writeReplayGain;
final Future<String?> Function(
String,
String,
String?,
Map<String, String>,
String,
)
_writeWithFFmpeg;
DownloadMetadataEmbedding({
required this.onReplayGain,
Future<String?> Function(String, int)? loadCover,
Future<String> Function(Track)? loadLyrics,
Future<Map<String, dynamic>> Function(String, Map<String, String>)?
editMetadata,
Future<ReplayGainResult?> Function(String)? scanReplayGain,
Future<ReplayGainResult?> Function(String)? scanAndApplyReplayGain,
Future<bool> Function(String, String, String)? writeReplayGain,
Future<String?> Function(
String,
String,
String?,
Map<String, String>,
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<String> _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<String?> 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<String, String> _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<String, String> _ffmpegFields(
Map<String, String> 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<String, String> _ac4Fields(
Map<String, String> 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<String?> _embedWithFFmpeg(
String path,
String format,
String? cover,
Map<String, String> 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<String?> _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<String?>.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<String?> _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);
}
}
}
@@ -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<Map<String, dynamic>> Function(String, String, String)
loadMetadata;
DownloadMetadataResolver({
this.loadMetadata = PlatformBridge.getProviderMetadata,
});
static bool isValidIsrc(String value) =>
_isrcPattern.hasMatch(value.toUpperCase());
Future<Track> 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<Track> 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<String?> _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<String?> 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<DownloadMetadataLookup> 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<String, dynamic>) {
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<DownloadMetadataLookup> 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<String, dynamic>) {
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<DownloadExtendedMetadata?> 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<String, dynamic> 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),
);
}
+597
View File
@@ -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 = <String>[];
final nativeWrites = <Map<String, String>>[];
final fallbackWrites = <Map<String, String>>[];
late final DownloadMetadataEmbedding service;
EmbeddingHarness({
bool nativeSuccess = true,
bool gainWriteSuccess = true,
ReplayGainResult? gain,
Future<String?> Function(String, int)? loadCover,
Future<String> 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 = <Map<String, String>>[];
final splitRewrites = <Map<Object?, Object?>>[];
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<Object?, Object?>;
switch (call.method) {
case 'acquireDownloadDirectory':
return 'test-scope';
case 'releaseDownloadDirectory':
return null;
case 'writeAC4Metadata':
ac4Writes.add(
Map<String, String>.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<void>();
final coverReady = Completer<String?>();
final lyricsStarted = Completer<void>();
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<void>.delayed(Duration.zero);
expect(await coverFile.exists(), isFalse);
},
);
test(
'failed covers retry and cache dimensions select separate artwork',
() async {
final requests = <int>[];
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<void>.delayed(Duration.zero);
},
);
test(
'cover LRU touch keeps reused release and deletes the least recent file',
() async {
final paths = <String, String>{};
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<void>.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<String>();
final startedLyrics = Completer<void>();
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<void>.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']);
},
);
}
+423
View File
@@ -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<String> 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');
},
);
}