mirror of
https://github.com/zarzet/SpotiFLAC-Mobile.git
synced 2026-07-29 15:28:48 +02:00
Pure move: paths, native worker, finalization, ReplayGain, and metadata embedding move to part files as private extensions on DownloadQueueNotifier; queue orchestration, state, and public API stay in the main file (7598 -> 4022 lines).
913 lines
32 KiB
Dart
913 lines
32 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 {
|
|
String? _resolveAlbumArtistForMetadata(Track track, AppSettings settings) {
|
|
var albumArtist = normalizeOptionalString(track.albumArtist);
|
|
if (settings.filterContributingArtistsInAlbumArtist) {
|
|
albumArtist = albumArtist == null
|
|
? null
|
|
: normalizeOptionalString(_extractPrimaryArtist(albumArtist));
|
|
}
|
|
return albumArtist;
|
|
}
|
|
|
|
static final _isrcRegex = RegExp(r'^[A-Z]{2}[A-Z0-9]{3}\d{2}\d{5}$');
|
|
|
|
bool _isValidISRC(String value) {
|
|
return _isrcRegex.hasMatch(value.toUpperCase());
|
|
}
|
|
|
|
/// Returns true if any enabled extension matching [source] or [service]
|
|
/// declares `skipLyrics: true` in its manifest.
|
|
bool _shouldSkipLyrics(
|
|
ExtensionState extensionState,
|
|
String? source,
|
|
String? service,
|
|
) {
|
|
final candidates = <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()),
|
|
);
|
|
}
|
|
|
|
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;
|
|
}
|
|
|
|
Track _copyTrackWithResolvedMetadata(
|
|
Track track, {
|
|
String? resolvedIsrc,
|
|
int? trackNumber,
|
|
int? totalTracks,
|
|
int? discNumber,
|
|
int? totalDiscs,
|
|
String? releaseDate,
|
|
String? deezerId,
|
|
String? composer,
|
|
}) {
|
|
final normalizedIsrc = normalizeOptionalString(resolvedIsrc);
|
|
final normalizedComposer = normalizeOptionalString(composer);
|
|
|
|
return Track(
|
|
id: track.id,
|
|
name: track.name,
|
|
artistName: track.artistName,
|
|
albumName: track.albumName,
|
|
albumArtist: track.albumArtist,
|
|
artistId: track.artistId,
|
|
albumId: track.albumId,
|
|
coverUrl: normalizeCoverReference(track.coverUrl),
|
|
duration: track.duration,
|
|
isrc: (normalizedIsrc != null && _isValidISRC(normalizedIsrc))
|
|
? normalizedIsrc
|
|
: track.isrc,
|
|
trackNumber: (track.trackNumber != null && track.trackNumber! > 0)
|
|
? track.trackNumber
|
|
: trackNumber,
|
|
discNumber: (track.discNumber != null && track.discNumber! > 0)
|
|
? track.discNumber
|
|
: discNumber,
|
|
totalDiscs: (track.totalDiscs != null && track.totalDiscs! > 0)
|
|
? track.totalDiscs
|
|
: totalDiscs,
|
|
releaseDate: track.releaseDate ?? normalizeOptionalString(releaseDate),
|
|
deezerId: deezerId ?? track.deezerId,
|
|
availability: track.availability,
|
|
source: track.source,
|
|
albumType: track.albumType,
|
|
totalTracks: (track.totalTracks != null && track.totalTracks! > 0)
|
|
? track.totalTracks
|
|
: totalTracks,
|
|
composer: (track.composer != null && track.composer!.isNotEmpty)
|
|
? track.composer
|
|
: normalizedComposer,
|
|
itemType: track.itemType,
|
|
);
|
|
}
|
|
|
|
Future<_DeezerLookupPreparation> _resolveProviderTrackForDeezerLookup(
|
|
Track track,
|
|
String itemId,
|
|
) async {
|
|
try {
|
|
final colonIdx = track.id.indexOf(':');
|
|
final provider = track.id.substring(0, colonIdx);
|
|
final effectiveProvider = resolveEffectiveMetadataProvider(
|
|
provider,
|
|
ref.read(extensionProvider),
|
|
);
|
|
final providerTrackId = track.id.substring(colonIdx + 1);
|
|
|
|
_log.d(
|
|
'No ISRC, fetching from ${effectiveProvider.isEmpty ? provider : effectiveProvider} API: $providerTrackId',
|
|
);
|
|
final providerData = await PlatformBridge.getProviderMetadata(
|
|
effectiveProvider.isEmpty ? provider : effectiveProvider,
|
|
'track',
|
|
providerTrackId,
|
|
);
|
|
|
|
final trackData = providerData['track'] as Map<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);
|
|
}
|
|
|
|
/// Deezer CDN cover size pattern: /WxH-0-0-0-0.jpg
|
|
static final _deezerSizeRegex = RegExp(r'/(\d+)x(\d+)-\d+-\d+-\d+-\d+\.jpg$');
|
|
|
|
String _upgradeToMaxQualityCover(String coverUrl) {
|
|
const spotifySize300 = 'ab67616d00001e02';
|
|
const spotifySize640 = 'ab67616d0000b273';
|
|
const spotifySizeMax = 'ab67616d000082c1';
|
|
|
|
var result = coverUrl;
|
|
if (result.contains(spotifySize300)) {
|
|
result = result.replaceFirst(spotifySize300, spotifySize640);
|
|
}
|
|
if (result.contains(spotifySize640)) {
|
|
result = result.replaceFirst(spotifySize640, spotifySizeMax);
|
|
}
|
|
|
|
if (result.contains('cdn-images.dzcdn.net')) {
|
|
final upgraded = result.replaceFirst(
|
|
_deezerSizeRegex,
|
|
'/1800x1800-000000-80-0-0.jpg',
|
|
);
|
|
if (upgraded != result) {
|
|
_log.d('Cover URL upgraded (Deezer): 1800x1800');
|
|
result = upgraded;
|
|
}
|
|
}
|
|
|
|
// Tidal CDN upgrade (1280x1280 → origin)
|
|
if (result.contains('resources.tidal.com') &&
|
|
result.contains('/1280x1280.jpg')) {
|
|
result = result.replaceFirst('/1280x1280.jpg', '/origin.jpg');
|
|
_log.d('Cover URL upgraded (Tidal): origin');
|
|
}
|
|
|
|
return result;
|
|
}
|
|
|
|
bool _isUsableIndex(int? number, int? total) {
|
|
if (number == null || number <= 0) return false;
|
|
return total == null || total <= 0 || number <= total;
|
|
}
|
|
|
|
int? _resolvePositiveMetadataInt(int? sourceValue, int? backendValue) {
|
|
if (sourceValue != null && sourceValue > 0) return sourceValue;
|
|
return backendValue;
|
|
}
|
|
|
|
int? _resolveMetadataIndex({
|
|
required int? sourceValue,
|
|
required int? backendValue,
|
|
required int? total,
|
|
}) {
|
|
if (_isUsableIndex(sourceValue, total)) return sourceValue;
|
|
if (_isUsableIndex(backendValue, total)) return backendValue;
|
|
return sourceValue != null && sourceValue > 0 ? sourceValue : backendValue;
|
|
}
|
|
|
|
String? _resolveMetadataText(String? sourceValue, String? backendValue) {
|
|
return normalizeOptionalString(sourceValue) ??
|
|
normalizeOptionalString(backendValue);
|
|
}
|
|
|
|
Track _buildTrackForMetadataEmbedding(
|
|
Track baseTrack,
|
|
Map<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 resolvedTotalTracks = _resolvePositiveMetadataInt(
|
|
baseTrack.totalTracks,
|
|
backendTotalTracks,
|
|
);
|
|
final resolvedTotalDiscs = _resolvePositiveMetadataInt(
|
|
baseTrack.totalDiscs,
|
|
backendTotalDiscs,
|
|
);
|
|
final resolvedTrackNumber = _resolveMetadataIndex(
|
|
sourceValue: baseTrack.trackNumber,
|
|
backendValue: backendTrackNum,
|
|
total: resolvedTotalTracks,
|
|
);
|
|
final resolvedDiscNumber = _resolveMetadataIndex(
|
|
sourceValue: baseTrack.discNumber,
|
|
backendValue: backendDiscNum,
|
|
total: resolvedTotalDiscs,
|
|
);
|
|
|
|
final hasOverrides =
|
|
resolvedTrackNumber != baseTrack.trackNumber ||
|
|
resolvedDiscNumber != baseTrack.discNumber ||
|
|
resolvedTotalTracks != baseTrack.totalTracks ||
|
|
resolvedTotalDiscs != baseTrack.totalDiscs ||
|
|
resolvedAlbumArtist != sourceAlbumArtist ||
|
|
(sourceReleaseDate == null && backendYear != null) ||
|
|
(sourceAlbumName == null && backendAlbum != null) ||
|
|
(sourceIsrc == null && backendIsrc != null) ||
|
|
(baseCoverUrl == null && backendCoverUrl != null) ||
|
|
(sourceAlbumArtist == null &&
|
|
resolvedAlbumArtist == null &&
|
|
backendAlbumArtist != null) ||
|
|
(sourceComposer == null && backendComposer != null);
|
|
|
|
if (!hasOverrides) {
|
|
return baseTrack;
|
|
}
|
|
|
|
return Track(
|
|
id: baseTrack.id,
|
|
name: baseTrack.name,
|
|
artistName: baseTrack.artistName,
|
|
albumName: sourceAlbumName ?? backendAlbum ?? baseTrack.albumName,
|
|
albumArtist:
|
|
resolvedAlbumArtist ?? sourceAlbumArtist ?? backendAlbumArtist,
|
|
artistId: baseTrack.artistId,
|
|
albumId: baseTrack.albumId,
|
|
coverUrl: resolvedCoverUrl,
|
|
duration: baseTrack.duration,
|
|
isrc: sourceIsrc ?? backendIsrc,
|
|
trackNumber: resolvedTrackNumber,
|
|
discNumber: resolvedDiscNumber,
|
|
totalDiscs: resolvedTotalDiscs,
|
|
releaseDate: sourceReleaseDate ?? backendYear,
|
|
deezerId: baseTrack.deezerId,
|
|
availability: baseTrack.availability,
|
|
albumType: baseTrack.albumType,
|
|
totalTracks: resolvedTotalTracks,
|
|
composer: sourceComposer ?? backendComposer,
|
|
source: baseTrack.source,
|
|
);
|
|
}
|
|
|
|
/// Unified metadata, cover, lyrics, and ReplayGain embedding for all formats.
|
|
///
|
|
/// [format] must be one of `'flac'`, `'m4a'`, `'mp3'`, or `'opus'`.
|
|
/// [writeExternalLrc] only applies to FLAC and M4A (non-SAF paths handle LRC separately).
|
|
Future<void> _embedMetadataToFile(
|
|
String filePath,
|
|
Track track, {
|
|
required String format,
|
|
String? genre,
|
|
String? label,
|
|
String? copyright,
|
|
String? downloadService,
|
|
bool writeExternalLrc = true,
|
|
}) async {
|
|
final settings = ref.read(settingsProvider);
|
|
if (!settings.embedMetadata) {
|
|
_log.d(
|
|
'Metadata embedding disabled, skipping $format metadata/cover embed',
|
|
);
|
|
return;
|
|
}
|
|
|
|
final isFlac = format == 'flac';
|
|
final isM4a = format == 'm4a';
|
|
final isMp3 = format == 'mp3';
|
|
|
|
String? coverPath;
|
|
var coverUrl = normalizeRemoteHttpUrl(track.coverUrl);
|
|
if (coverUrl != null && coverUrl.isNotEmpty) {
|
|
try {
|
|
if (settings.maxQualityCover) {
|
|
coverUrl = _upgradeToMaxQualityCover(coverUrl);
|
|
_log.d('Cover URL upgraded to max quality for $format: $coverUrl');
|
|
}
|
|
|
|
final tempDir = await getTemporaryDirectory();
|
|
final uniqueId =
|
|
'${DateTime.now().millisecondsSinceEpoch}_${Random().nextInt(10000)}';
|
|
coverPath = '${tempDir.path}/cover_${format}_$uniqueId.jpg';
|
|
|
|
final httpClient = HttpClient();
|
|
final request = await httpClient.getUrl(Uri.parse(coverUrl));
|
|
final response = await request.close();
|
|
if (response.statusCode == 200) {
|
|
final file = File(coverPath);
|
|
final sink = file.openWrite();
|
|
await response.pipe(sink);
|
|
await sink.close();
|
|
_log.d('Cover downloaded for $format: $coverPath');
|
|
} else {
|
|
_log.w(
|
|
'Failed to download cover for $format: HTTP ${response.statusCode}',
|
|
);
|
|
coverPath = null;
|
|
}
|
|
httpClient.close();
|
|
} catch (e) {
|
|
_log.e('Failed to download cover for $format: $e');
|
|
coverPath = null;
|
|
}
|
|
}
|
|
|
|
try {
|
|
final metadata = <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 (genre != null && genre.isNotEmpty) metadata['GENRE'] = genre;
|
|
if (label != null && label.isNotEmpty) metadata['ORGANIZATION'] = label;
|
|
if (copyright != null && copyright.isNotEmpty) {
|
|
metadata['COPYRIGHT'] = copyright;
|
|
}
|
|
if (track.composer != null && track.composer!.isNotEmpty) {
|
|
metadata['COMPOSER'] = track.composer!;
|
|
}
|
|
|
|
final lyricsMode = settings.lyricsMode;
|
|
final extensionState = ref.read(extensionProvider);
|
|
final skipLyrics = _shouldSkipLyrics(
|
|
extensionState,
|
|
track.source,
|
|
downloadService,
|
|
);
|
|
final shouldEmbedLyrics =
|
|
settings.embedLyrics &&
|
|
!skipLyrics &&
|
|
(lyricsMode == 'embed' || lyricsMode == 'both');
|
|
final shouldSaveExternalLyrics =
|
|
settings.embedLyrics &&
|
|
!skipLyrics &&
|
|
(lyricsMode == 'external' || lyricsMode == 'both');
|
|
String? lrcContent;
|
|
|
|
if (shouldEmbedLyrics || shouldSaveExternalLyrics) {
|
|
try {
|
|
final fetchedLrc = await PlatformBridge.getLyricsLRC(
|
|
track.id,
|
|
track.name,
|
|
track.artistName,
|
|
filePath: '',
|
|
durationMs: track.duration * 1000,
|
|
);
|
|
if (fetchedLrc.isNotEmpty && fetchedLrc != '[instrumental:true]') {
|
|
lrcContent = fetchedLrc;
|
|
_log.d('Lyrics fetched for $format (${fetchedLrc.length} chars)');
|
|
} else if (fetchedLrc == '[instrumental:true]') {
|
|
_log.d('Track is instrumental, skipping lyrics handling');
|
|
}
|
|
} catch (e) {
|
|
_log.w('Failed to fetch lyrics for $format: $e');
|
|
}
|
|
}
|
|
|
|
if (shouldEmbedLyrics && lrcContent != null) {
|
|
metadata['LYRICS'] = lrcContent;
|
|
if (isFlac || isMp3) metadata['UNSYNCEDLYRICS'] = lrcContent;
|
|
} else if ((isFlac || isM4a) && !shouldEmbedLyrics) {
|
|
metadata['LYRICS'] = '';
|
|
if (isFlac) {
|
|
metadata['UNSYNCEDLYRICS'] = '';
|
|
}
|
|
}
|
|
|
|
if (writeExternalLrc && shouldSaveExternalLyrics && lrcContent != null) {
|
|
try {
|
|
final lrcPath = filePath.replaceAll(RegExp(r'\.[^.]+$'), '.lrc');
|
|
final safeLrcPath = lrcPath == filePath ? '$filePath.lrc' : lrcPath;
|
|
await File(safeLrcPath).writeAsString(lrcContent);
|
|
_log.d('External LRC file saved: $safeLrcPath');
|
|
} catch (e) {
|
|
_log.w('Failed to save external LRC file for $format: $e');
|
|
}
|
|
}
|
|
|
|
ReplayGainResult? scannedReplayGain;
|
|
|
|
if (settings.embedReplayGain && !isFlac) {
|
|
try {
|
|
final rgResult = await FFmpegService.scanReplayGain(filePath);
|
|
if (rgResult != null) {
|
|
scannedReplayGain = rgResult;
|
|
metadata['REPLAYGAIN_TRACK_GAIN'] = rgResult.trackGain;
|
|
metadata['REPLAYGAIN_TRACK_PEAK'] = rgResult.trackPeak;
|
|
if (format == 'opus') {
|
|
final r128 = FFmpegService.replayGainDbToR128(rgResult.trackGain);
|
|
if (r128 != null) metadata['R128_TRACK_GAIN'] = r128;
|
|
}
|
|
_log.d(
|
|
'ReplayGain for $format: gain=${rgResult.trackGain}, peak=${rgResult.trackPeak}',
|
|
);
|
|
_storeTrackReplayGainForAlbum(track, filePath, rgResult);
|
|
}
|
|
} catch (e) {
|
|
_log.w('Failed to scan ReplayGain for $format: $e');
|
|
}
|
|
}
|
|
|
|
final validCover = coverPath != null && await File(coverPath).exists()
|
|
? coverPath
|
|
: null;
|
|
|
|
// AC-4 is passthrough-only: the FFmpeg mov muxer would re-wrap it as
|
|
// QuickTime and break the ISO MP4 from decryption. writeAC4Metadata is a
|
|
// no-op for non-AC-4 files, so other m4a downloads fall through to FFmpeg.
|
|
if (isM4a) {
|
|
try {
|
|
final ac4Meta = <String, String>{
|
|
'title': track.name,
|
|
'artist': track.artistName,
|
|
'album': track.albumName,
|
|
'albumArtist': ?albumArtist,
|
|
if (track.releaseDate != null) 'date': track.releaseDate!,
|
|
if (genre != null && genre.isNotEmpty) 'genre': genre,
|
|
if (track.composer != null && track.composer!.isNotEmpty)
|
|
'composer': track.composer!,
|
|
if (track.trackNumber != null && track.trackNumber! > 0)
|
|
'trackNumber': track.trackNumber!.toString(),
|
|
if (track.totalTracks != null && track.totalTracks! > 0)
|
|
'totalTracks': track.totalTracks!.toString(),
|
|
if (track.discNumber != null && track.discNumber! > 0)
|
|
'discNumber': track.discNumber!.toString(),
|
|
if (track.totalDiscs != null && track.totalDiscs! > 0)
|
|
'totalDiscs': track.totalDiscs!.toString(),
|
|
if (track.isrc != null) 'isrc': track.isrc!,
|
|
if (label != null && label.isNotEmpty) 'label': label,
|
|
if (copyright != null && copyright.isNotEmpty)
|
|
'copyright': copyright,
|
|
if (shouldEmbedLyrics) 'lyrics': ?lrcContent,
|
|
};
|
|
final ac4Result = await PlatformBridge.writeAC4Metadata(
|
|
filePath,
|
|
ac4Meta,
|
|
validCover ?? '',
|
|
);
|
|
if (ac4Result['handled'] == true) {
|
|
_log.d('AC-4 metadata embedded natively for $format');
|
|
return;
|
|
}
|
|
} catch (e) {
|
|
_log.w('AC-4 metadata path failed, falling back to FFmpeg: $e');
|
|
}
|
|
}
|
|
|
|
String? ffmpegResult;
|
|
if (isFlac) {
|
|
ffmpegResult = await FFmpegService.embedMetadata(
|
|
flacPath: filePath,
|
|
coverPath: validCover,
|
|
metadata: metadata,
|
|
artistTagMode: settings.artistTagMode,
|
|
);
|
|
} else if (isM4a) {
|
|
ffmpegResult = await FFmpegService.embedMetadataToM4a(
|
|
m4aPath: filePath,
|
|
coverPath: validCover,
|
|
metadata: metadata,
|
|
);
|
|
} else if (isMp3) {
|
|
ffmpegResult = await FFmpegService.embedMetadataToMp3(
|
|
mp3Path: filePath,
|
|
coverPath: validCover,
|
|
metadata: metadata,
|
|
);
|
|
} else {
|
|
ffmpegResult = await FFmpegService.embedMetadataToOpus(
|
|
opusPath: filePath,
|
|
coverPath: validCover,
|
|
metadata: metadata,
|
|
artistTagMode: settings.artistTagMode,
|
|
);
|
|
}
|
|
|
|
if (ffmpegResult != null) {
|
|
_log.d('Metadata embedded to $format via FFmpeg');
|
|
} else {
|
|
_log.w('FFmpeg $format metadata embed failed');
|
|
}
|
|
|
|
if (isM4a && settings.embedReplayGain && scannedReplayGain != null) {
|
|
try {
|
|
await PlatformBridge.editFileMetadata(filePath, {
|
|
'replaygain_track_gain': scannedReplayGain.trackGain,
|
|
'replaygain_track_peak': scannedReplayGain.trackPeak,
|
|
});
|
|
_log.d(
|
|
'ReplayGain compatibility tags written for $format: gain=${scannedReplayGain.trackGain}, peak=${scannedReplayGain.trackPeak}',
|
|
);
|
|
} catch (e) {
|
|
_log.w('Failed to write native ReplayGain tags for $format: $e');
|
|
}
|
|
}
|
|
|
|
if (isFlac) {
|
|
if (settings.artistTagMode == artistTagModeSplitVorbis) {
|
|
try {
|
|
await PlatformBridge.rewriteSplitArtistTags(
|
|
filePath,
|
|
track.artistName,
|
|
albumArtist ?? '',
|
|
);
|
|
_log.d('Split artist tags rewritten via native FLAC writer');
|
|
} catch (e) {
|
|
_log.w('Failed to rewrite split artist tags: $e');
|
|
}
|
|
}
|
|
|
|
if (settings.embedReplayGain) {
|
|
try {
|
|
final rgResult = await FFmpegService.scanReplayGain(filePath);
|
|
if (rgResult != null) {
|
|
await PlatformBridge.editFileMetadata(filePath, {
|
|
'replaygain_track_gain': rgResult.trackGain,
|
|
'replaygain_track_peak': rgResult.trackPeak,
|
|
});
|
|
_log.d(
|
|
'ReplayGain for $format: gain=${rgResult.trackGain}, peak=${rgResult.trackPeak}',
|
|
);
|
|
_storeTrackReplayGainForAlbum(track, filePath, rgResult);
|
|
}
|
|
} catch (e) {
|
|
_log.w('Failed to embed ReplayGain via native writer: $e');
|
|
}
|
|
}
|
|
}
|
|
} catch (e) {
|
|
_log.e('Failed to embed metadata to $format: $e');
|
|
} finally {
|
|
if (coverPath != null) {
|
|
try {
|
|
final coverFile = File(coverPath);
|
|
if (await coverFile.exists()) await coverFile.delete();
|
|
} catch (e) {
|
|
_log.w('Failed to cleanup $format cover file: $e');
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|