mirror of
https://github.com/zarzet/SpotiFLAC-Mobile.git
synced 2026-10-21 15:02:21 +02:00
refactor(downloads): separate metadata resolution and embedding
This commit is contained in:
1 parent
b8787168d1
commit
7b9405aaf6
9 files changed
+1958
-1007
No files matched your search
@@ -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),
|
||||
);
|
||||
}
|
||||
@@ -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']);
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -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');
|
||||
},
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user