mirror of
https://github.com/zarzet/SpotiFLAC-Mobile.git
synced 2026-10-02 22:26:52 +02:00
Artist Tag Mode gains "Primary artist only": downloads and re-enrichment write only the first credited artist to Artist and Album Artist, so players list one artist. The FFmpeg MP3, M4A and Vorbis embeds and the AC-4 path now honor the mode; MP3 and M4A previously ignored it. Manual metadata edits, lyrics embedding and format conversion keep existing or typed credits joined instead of dropping names. The picker scrolls so three options fit on small and landscape screens. Closes #618.
1124 lines
41 KiB
Dart
1124 lines
41 KiB
Dart
// 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);
|
|
}
|
|
|
|
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, {
|
|
required String format,
|
|
String? genre,
|
|
String? label,
|
|
String? copyright,
|
|
String? comment,
|
|
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,
|
|
);
|
|
}
|
|
|
|
bool _isUsableIndex(int? number, int? total) {
|
|
if (number == null || number <= 0) return false;
|
|
return total == null || total <= 0 || number <= total;
|
|
}
|
|
|
|
int? _resolvePositiveMetadataInt(int? sourceValue, int? backendValue) {
|
|
if (sourceValue != null && sourceValue > 0) return sourceValue;
|
|
return backendValue;
|
|
}
|
|
|
|
int? _resolveMetadataIndex({
|
|
required int? sourceValue,
|
|
required int? backendValue,
|
|
required int? total,
|
|
}) {
|
|
if (_isUsableIndex(sourceValue, total)) return sourceValue;
|
|
if (_isUsableIndex(backendValue, total)) return backendValue;
|
|
return sourceValue != null && sourceValue > 0 ? sourceValue : backendValue;
|
|
}
|
|
|
|
String? _resolveMetadataText(String? sourceValue, String? backendValue) {
|
|
return normalizeOptionalString(sourceValue) ??
|
|
normalizeOptionalString(backendValue);
|
|
}
|
|
|
|
/// Applies backend metadata fallbacks while preserving source-only fields.
|
|
Track buildTrackForMetadataEmbedding(
|
|
Track baseTrack,
|
|
Map<String, dynamic> backendResult,
|
|
String? resolvedAlbumArtist,
|
|
) {
|
|
final backendTrackNum = readPositiveInt(backendResult['track_number']);
|
|
final backendDiscNum = readPositiveInt(backendResult['disc_number']);
|
|
final backendTotalTracks = readPositiveInt(backendResult['total_tracks']);
|
|
final backendTotalDiscs = readPositiveInt(backendResult['total_discs']);
|
|
final backendYear = normalizeOptionalString(
|
|
backendResult['release_date'] as String?,
|
|
);
|
|
final backendAlbum = normalizeOptionalString(
|
|
backendResult['album'] as String?,
|
|
);
|
|
final backendIsrc = normalizeOptionalString(backendResult['isrc'] as String?);
|
|
final backendCoverUrl = normalizeCoverReference(
|
|
backendResult['cover_url']?.toString(),
|
|
);
|
|
final baseCoverUrl = normalizeCoverReference(baseTrack.coverUrl);
|
|
final resolvedCoverUrl = baseCoverUrl ?? backendCoverUrl;
|
|
final backendAlbumArtist = normalizeOptionalString(
|
|
backendResult['album_artist'] as String?,
|
|
);
|
|
final backendComposer = normalizeOptionalString(
|
|
backendResult['composer']?.toString(),
|
|
);
|
|
final sourceAlbumName = normalizeOptionalString(baseTrack.albumName);
|
|
final sourceAlbumArtist = normalizeOptionalString(baseTrack.albumArtist);
|
|
final sourceIsrc = normalizeOptionalString(baseTrack.isrc);
|
|
final sourceReleaseDate = normalizeOptionalString(baseTrack.releaseDate);
|
|
final sourceComposer = normalizeOptionalString(baseTrack.composer);
|
|
final sourceAlbumType = normalizeOptionalString(baseTrack.albumType);
|
|
final sourceGenre = normalizeOptionalString(baseTrack.genre);
|
|
final sourceLabel = normalizeOptionalString(baseTrack.label);
|
|
final sourceCopyright = normalizeOptionalString(baseTrack.copyright);
|
|
final sourceComment = normalizeOptionalString(baseTrack.comment);
|
|
final sourceUpc = normalizeOptionalString(baseTrack.upc);
|
|
final backendGenre = normalizeOptionalString(
|
|
backendResult['genre']?.toString(),
|
|
);
|
|
final backendLabel = normalizeOptionalString(
|
|
backendResult['label']?.toString(),
|
|
);
|
|
final backendCopyright = normalizeOptionalString(
|
|
backendResult['copyright']?.toString(),
|
|
);
|
|
final backendComment = normalizeOptionalString(
|
|
backendResult['comment']?.toString(),
|
|
);
|
|
final resolvedComment = backendComment ?? sourceComment;
|
|
final backendAlbumType = normalizeOptionalString(
|
|
backendResult['album_type']?.toString(),
|
|
);
|
|
final backendUpc = normalizeOptionalString(
|
|
(backendResult['upc'] ?? backendResult['barcode'])?.toString(),
|
|
);
|
|
final backendExplicit = backendResult['explicit'] == true;
|
|
final resolvedTotalTracks = _resolvePositiveMetadataInt(
|
|
baseTrack.totalTracks,
|
|
backendTotalTracks,
|
|
);
|
|
final resolvedTotalDiscs = _resolvePositiveMetadataInt(
|
|
baseTrack.totalDiscs,
|
|
backendTotalDiscs,
|
|
);
|
|
final resolvedTrackNumber = _resolveMetadataIndex(
|
|
sourceValue: baseTrack.trackNumber,
|
|
backendValue: backendTrackNum,
|
|
total: resolvedTotalTracks,
|
|
);
|
|
final resolvedDiscNumber = _resolveMetadataIndex(
|
|
sourceValue: baseTrack.discNumber,
|
|
backendValue: backendDiscNum,
|
|
total: resolvedTotalDiscs,
|
|
);
|
|
|
|
final hasOverrides =
|
|
resolvedTrackNumber != baseTrack.trackNumber ||
|
|
resolvedDiscNumber != baseTrack.discNumber ||
|
|
resolvedTotalTracks != baseTrack.totalTracks ||
|
|
resolvedTotalDiscs != baseTrack.totalDiscs ||
|
|
resolvedAlbumArtist != sourceAlbumArtist ||
|
|
(sourceReleaseDate == null && backendYear != null) ||
|
|
(sourceAlbumName == null && backendAlbum != null) ||
|
|
(sourceIsrc == null && backendIsrc != null) ||
|
|
(baseCoverUrl == null && backendCoverUrl != null) ||
|
|
(sourceAlbumArtist == null &&
|
|
resolvedAlbumArtist == null &&
|
|
backendAlbumArtist != null) ||
|
|
(sourceComposer == null && backendComposer != null) ||
|
|
(sourceAlbumType == null && backendAlbumType != null) ||
|
|
(sourceGenre == null && backendGenre != null) ||
|
|
(sourceLabel == null && backendLabel != null) ||
|
|
(sourceCopyright == null && backendCopyright != null) ||
|
|
resolvedComment != sourceComment ||
|
|
(baseTrack.explicit != true && backendExplicit) ||
|
|
(sourceUpc == null && backendUpc != null);
|
|
|
|
if (!hasOverrides) {
|
|
return baseTrack;
|
|
}
|
|
|
|
return baseTrack.copyWith(
|
|
albumName: sourceAlbumName ?? backendAlbum ?? baseTrack.albumName,
|
|
albumArtist: resolvedAlbumArtist ?? sourceAlbumArtist ?? backendAlbumArtist,
|
|
coverUrl: resolvedCoverUrl,
|
|
isrc: sourceIsrc ?? backendIsrc,
|
|
trackNumber: resolvedTrackNumber,
|
|
discNumber: resolvedDiscNumber,
|
|
totalDiscs: resolvedTotalDiscs,
|
|
releaseDate: sourceReleaseDate ?? backendYear,
|
|
albumType: sourceAlbumType ?? backendAlbumType,
|
|
totalTracks: resolvedTotalTracks,
|
|
composer: sourceComposer ?? backendComposer,
|
|
genre: sourceGenre ?? backendGenre,
|
|
label: sourceLabel ?? backendLabel,
|
|
copyright: sourceCopyright ?? backendCopyright,
|
|
comment: resolvedComment,
|
|
explicit: baseTrack.explicit == true || backendExplicit
|
|
? true
|
|
: baseTrack.explicit,
|
|
upc: sourceUpc ?? backendUpc,
|
|
);
|
|
}
|