refactor(queue): split download_queue_provider into concern part files

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).
This commit is contained in:
zarzet
2026-07-12 21:31:38 +07:00
parent de32b8b5f6
commit 2d52f3251e
6 changed files with 3626 additions and 3585 deletions
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,912 @@
// 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');
}
}
}
}
}
@@ -0,0 +1,695 @@
// ignore_for_file: invalid_use_of_protected_member, invalid_use_of_visible_for_testing_member
part of 'download_queue_provider.dart';
/// Result of [DownloadQueueNotifier._finalizeDecryption]. [failStage] is
/// only meaningful to the inline single-item pipeline, which surfaces a
/// distinct error message per stage; the native-worker pipeline uses one
/// generic message and ignores it.
class _DecryptOutcome {
final String? path;
final String? newFileName;
final String? failStage;
const _DecryptOutcome(this.path, {this.newFileName, this.failStage});
}
extension _DownloadQueueFinalization on DownloadQueueNotifier {
/// Builds the [DownloadHistoryItem] shared by the native-worker and inline
/// completion paths. Fields whose source/derivation legitimately differs
/// between the two callers (SAF location, probed vs. raw audio metadata,
/// genre/label/copyright fallback chain) are left as parameters.
DownloadHistoryItem _historyItemFromResult({
required DownloadItem item,
required Track trackToDownload,
required Map<String, dynamic> result,
required String filePath,
required String quality,
required bool useSaf,
String? downloadTreeUri,
String? safRelativeDir,
String? safFileName,
int? bitDepth,
int? sampleRate,
int? bitrate,
String? format,
String? genre,
String? label,
String? copyright,
}) {
final backendTitle = result['title'] as String?;
final backendArtist = result['artist'] as String?;
final backendAlbum = result['album'] as String?;
final backendYear = result['release_date'] as String?;
final backendTrackNum = readPositiveInt(result['track_number']);
final backendDiscNum = readPositiveInt(result['disc_number']);
final backendTotalTracks = readPositiveInt(result['total_tracks']);
final backendTotalDiscs = readPositiveInt(result['total_discs']);
final backendISRC = result['isrc'] as String?;
final backendComposer = result['composer'] as String?;
final historyTotalTracks = _resolvePositiveMetadataInt(
trackToDownload.totalTracks,
backendTotalTracks,
);
final historyTotalDiscs = _resolvePositiveMetadataInt(
trackToDownload.totalDiscs,
backendTotalDiscs,
);
final historyTrackNumber = _resolveMetadataIndex(
sourceValue: trackToDownload.trackNumber,
backendValue: backendTrackNum,
total: historyTotalTracks,
);
final historyDiscNumber = _resolveMetadataIndex(
sourceValue: trackToDownload.discNumber,
backendValue: backendDiscNum,
total: historyTotalDiscs,
);
final historyTitle =
_resolveMetadataText(trackToDownload.name, backendTitle) ??
item.track.name;
final historyArtist =
_resolveMetadataText(trackToDownload.artistName, backendArtist) ??
item.track.artistName;
final historyAlbum =
_resolveMetadataText(trackToDownload.albumName, backendAlbum) ??
item.track.albumName;
final historyIsrc = _resolveMetadataText(trackToDownload.isrc, backendISRC);
final historyReleaseDate = _resolveMetadataText(
trackToDownload.releaseDate,
backendYear,
);
final historyComposer = _resolveMetadataText(
trackToDownload.composer,
backendComposer,
);
return DownloadHistoryItem(
id: item.id,
trackName: historyTitle,
artistName: historyArtist,
albumName: historyAlbum,
albumArtist: normalizeOptionalString(trackToDownload.albumArtist),
coverUrl: normalizeCoverReference(trackToDownload.coverUrl),
filePath: filePath,
storageMode: useSaf ? 'saf' : 'app',
downloadTreeUri: useSaf ? downloadTreeUri : null,
safRelativeDir: useSaf ? safRelativeDir : null,
safFileName: useSaf ? safFileName : null,
safRepaired: false,
service: result['service'] as String? ?? item.service,
downloadedAt: DateTime.now(),
isrc: historyIsrc,
spotifyId: trackToDownload.id,
trackNumber: historyTrackNumber,
totalTracks: historyTotalTracks,
discNumber: historyDiscNumber,
totalDiscs: historyTotalDiscs,
duration: trackToDownload.duration,
releaseDate: historyReleaseDate,
quality: quality,
bitDepth: bitDepth,
sampleRate: sampleRate,
bitrate: bitrate,
format: format,
genre: genre,
composer: historyComposer,
label: label,
copyright: copyright,
);
}
Future<String?> _copySafToTemp(String uri) async {
try {
return await PlatformBridge.copyContentUriToTemp(uri);
} catch (e) {
_log.w('Failed to copy SAF uri to temp: $e');
return null;
}
}
Future<String?> _writeTempToSaf({
required String treeUri,
required String relativeDir,
required String fileName,
required String mimeType,
required String srcPath,
}) async {
try {
return await PlatformBridge.createSafFileFromPath(
treeUri: treeUri,
relativeDir: relativeDir,
fileName: fileName,
mimeType: mimeType,
srcPath: srcPath,
);
} catch (e) {
_log.w('Failed to write temp file to SAF: $e');
return null;
}
}
Future<void> _writeLrcToSaf({
required String treeUri,
required String relativeDir,
required String baseName,
required String lrcContent,
}) async {
try {
if (lrcContent.isEmpty) return;
final tempDir = await getTemporaryDirectory();
final tempPath = '${tempDir.path}/$baseName.lrc';
await File(tempPath).writeAsString(lrcContent);
final lrcName = '$baseName.lrc';
final uri = await _writeTempToSaf(
treeUri: treeUri,
relativeDir: relativeDir,
fileName: lrcName,
mimeType: _mimeTypeForExt('.lrc'),
srcPath: tempPath,
);
if (uri != null) {
_log.d('External LRC saved to SAF: $lrcName');
} else {
_log.w('Failed to write external LRC to SAF');
}
try {
await File(tempPath).delete();
} catch (_) {}
} catch (e) {
_log.w('Failed to create external LRC in SAF: $e');
}
}
Future<void> _deleteSafFile(String uri) async {
try {
await PlatformBridge.safDelete(uri);
} catch (e) {
_log.w('Failed to delete SAF file: $e');
}
}
/// Shared "SAF roundtrip" used by every finalize step that needs to
/// transform a SAF file: copies [uri] to a local temp file, lets [op]
/// transform it (returning the local path to publish plus the file name
/// to publish it under, or null to abort), writes that file back into the
/// SAF tree, deletes the original SAF file if its URI changed, and always
/// cleans up the local temp file(s). Returns the new content:// URI, or
/// null if the temp copy, [op], or the SAF write failed.
Future<String?> _replaceSafFileVia({
required String uri,
required String treeUri,
required String relativeDir,
required Future<(String path, String fileName)?> Function(String tempPath)
op,
}) async {
final tempPath = await _copySafToTemp(uri);
if (tempPath == null) return null;
String? outPath;
try {
final produced = await op(tempPath);
if (produced == null) return null;
outPath = produced.$1;
final fileName = produced.$2;
final dotIndex = fileName.lastIndexOf('.');
final ext = dotIndex >= 0 ? fileName.substring(dotIndex) : '';
final newUri = await _writeTempToSaf(
treeUri: treeUri,
relativeDir: relativeDir,
fileName: fileName,
mimeType: _mimeTypeForExt(ext),
srcPath: outPath,
);
if (newUri == null) return null;
if (newUri != uri) {
await _deleteSafFile(uri);
}
return newUri;
} finally {
try {
await File(tempPath).delete();
} catch (_) {}
if (outPath != null && outPath != tempPath) {
try {
await File(outPath).delete();
} catch (_) {}
}
}
}
/// Shared decrypt finalize used by both the inline single-item pipeline
/// and the native-worker pipeline. Divergences captured as parameters:
/// [repairAc4] (inline repairs AC-4 containers using the still-encrypted
/// source; native-worker does not) and [onStart] (inline logs its own
/// "detected" message; native-worker logs a differently worded one).
Future<_DecryptOutcome> _finalizeDecryption({
required Map<String, dynamic> result,
required String filePath,
required String storageMode,
String? downloadTreeUri,
required String safRelativeDir,
required String baseName,
required String extFallback,
required bool repairAc4,
void Function(String strategy)? onStart,
}) async {
if (result['already_exists'] == true) {
return _DecryptOutcome(filePath);
}
final descriptor = DownloadDecryptionDescriptor.fromDownloadResult(result);
if (descriptor == null) {
return _DecryptOutcome(filePath);
}
onStart?.call(descriptor.normalizedStrategy);
if (storageMode == 'saf' && isContentUri(filePath)) {
if (downloadTreeUri == null || downloadTreeUri.isEmpty) {
return const _DecryptOutcome(
null,
failStage: DownloadQueueNotifier._decryptStageSafAccess,
);
}
String? failStage;
var opStarted = false;
String? producedFileName;
final newUri = await _replaceSafFileVia(
uri: filePath,
treeUri: downloadTreeUri,
relativeDir: safRelativeDir,
op: (tempPath) async {
opStarted = true;
final decryptedTempPath = await FFmpegService.decryptWithDescriptor(
inputPath: tempPath,
descriptor: descriptor,
deleteOriginal: false,
);
if (decryptedTempPath == null) {
failStage = DownloadQueueNotifier._decryptStageDecrypt;
return null;
}
if (repairAc4) {
try {
await PlatformBridge.ensureAC4Config(decryptedTempPath, tempPath);
} catch (e) {
_log.w('AC-4 container repair skipped: $e');
}
}
final dotIndex = decryptedTempPath.lastIndexOf('.');
final decryptedExt = dotIndex >= 0
? decryptedTempPath.substring(dotIndex).toLowerCase()
: extFallback;
const allowedExt = <String>{'.flac', '.m4a', '.mp4', '.mp3', '.opus'};
final finalExt = allowedExt.contains(decryptedExt)
? decryptedExt
: extFallback;
final newFileName = '$baseName$finalExt';
producedFileName = newFileName;
return (decryptedTempPath, newFileName);
},
);
if (newUri == null) {
return _DecryptOutcome(
null,
failStage:
failStage ??
(opStarted
? DownloadQueueNotifier._decryptStageSafWrite
: DownloadQueueNotifier._decryptStageSafAccess),
);
}
return _DecryptOutcome(newUri, newFileName: producedFileName);
}
if (repairAc4) {
final decryptedPath = await FFmpegService.decryptWithDescriptor(
inputPath: filePath,
descriptor: descriptor,
deleteOriginal: false,
);
if (decryptedPath == null) {
try {
await deleteFile(filePath);
} catch (_) {}
return const _DecryptOutcome(
null,
failStage: DownloadQueueNotifier._decryptStageDecrypt,
);
}
try {
await PlatformBridge.ensureAC4Config(decryptedPath, filePath);
} catch (e) {
_log.w('AC-4 container repair skipped: $e');
}
try {
await deleteFile(filePath);
} catch (_) {}
return _DecryptOutcome(decryptedPath);
}
final decryptedPath = await FFmpegService.decryptWithDescriptor(
inputPath: filePath,
descriptor: descriptor,
deleteOriginal: true,
);
return _DecryptOutcome(
decryptedPath,
failStage: decryptedPath == null
? DownloadQueueNotifier._decryptStageDecrypt
: null,
);
}
Future<String?> _finalizeNativeWorkerHighConversion({
required _NativeWorkerRequestContext context,
required Map<String, dynamic> result,
required AppSettings settings,
required Track track,
required String filePath,
}) async {
if (context.quality != 'HIGH') {
return filePath;
}
final lowerPath = filePath.toLowerCase();
final resultFileName = (result['file_name'] as String?)?.toLowerCase();
final looksLikeM4a =
lowerPath.endsWith('.m4a') ||
lowerPath.endsWith('.mp4') ||
(resultFileName != null &&
(resultFileName.endsWith('.m4a') ||
resultFileName.endsWith('.mp4')));
if (!looksLikeM4a) {
return filePath;
}
final tidalHighFormat = settings.tidalHighFormat;
final format = lossyFormatForSetting(tidalHighFormat);
final newExt = lossyExtensionForFormat(format);
final displayFormat = displayFormatForLossyFormat(format);
final bitrateDisplay = tidalHighFormat.contains('_')
? '${tidalHighFormat.split('_').last}kbps'
: '320kbps';
Future<void> embedConvertedMetadata(String convertedPath) async {
if (!settings.embedMetadata) return;
await _embedMetadataToFile(
convertedPath,
track,
format: metadataFormatForLossyFormat(format),
genre: result['genre'] as String?,
label: result['label'] as String?,
copyright: result['copyright'] as String?,
downloadService: context.item.service,
);
}
if (context.storageMode == 'saf' && isContentUri(filePath)) {
final treeUri = context.downloadTreeUri;
if (treeUri == null || treeUri.isEmpty) {
return null;
}
final rawFileName =
(result['file_name'] as String?) ?? context.safFileName ?? 'track';
final baseName = rawFileName.replaceFirst(RegExp(r'\.[^.]+$'), '');
final newFileName = '$baseName$newExt';
final newUri = await _replaceSafFileVia(
uri: filePath,
treeUri: treeUri,
relativeDir: context.safRelativeDir ?? '',
op: (tempPath) async {
final convertedPath = await FFmpegService.convertM4aToLossy(
tempPath,
format: format,
bitrate: tidalHighFormat,
deleteOriginal: false,
);
if (convertedPath == null) return null;
await embedConvertedMetadata(convertedPath);
return (convertedPath, newFileName);
},
);
if (newUri == null) {
return null;
}
result['file_name'] = newFileName;
result['_native_actual_quality'] = '$displayFormat $bitrateDisplay';
return newUri;
}
final convertedPath = await FFmpegService.convertM4aToLossy(
filePath,
format: format,
bitrate: tidalHighFormat,
deleteOriginal: true,
);
if (convertedPath == null) {
return null;
}
await embedConvertedMetadata(convertedPath);
result['_native_actual_quality'] = '$displayFormat $bitrateDisplay';
return convertedPath;
}
Future<String?> _finalizeNativeWorkerContainerConversion({
required _NativeWorkerRequestContext context,
required Map<String, dynamic> result,
required AppSettings settings,
required Track track,
required String filePath,
}) async {
if (context.quality == 'HIGH' || context.outputExt != '.flac') {
return filePath;
}
final resultAudioFormat = normalizeAudioFormatValue(
result['audio_codec']?.toString() ??
result['actual_audio_codec']?.toString(),
);
if (isLossyAudioFormat(resultAudioFormat)) {
_log.d(
'Native-worker output is $resultAudioFormat; preserving native container.',
);
return filePath;
}
final requiresContainerConversion =
result['requires_container_conversion'] == true ||
result['requiresContainerConversion'] == true;
final resultOutputExt = _downloadResultOutputExt(
result,
filePath: filePath,
);
final lowerPath = filePath.toLowerCase();
final resultFileName = (result['file_name'] as String?)?.toLowerCase();
final mayNeedContainerConversion =
requiresContainerConversion ||
lowerPath.endsWith('.m4a') ||
lowerPath.endsWith('.mp4') ||
resultOutputExt == '.m4a' ||
resultOutputExt == '.mp4' ||
isContentUri(filePath);
if (!mayNeedContainerConversion) {
return filePath;
}
final requestedDecryptionExt =
DownloadDecryptionDescriptor.fromDownloadResult(
result,
)?.normalizedOutputExtension;
if (!requiresContainerConversion &&
requestedDecryptionExt != null &&
requestedDecryptionExt != '.flac') {
_log.d(
'Native-worker decrypted output requested $requestedDecryptionExt; preserving native container.',
);
return filePath;
}
final looksLikeM4a =
lowerPath.endsWith('.m4a') ||
lowerPath.endsWith('.mp4') ||
resultOutputExt == '.m4a' ||
resultOutputExt == '.mp4' ||
(resultFileName != null &&
(resultFileName.endsWith('.m4a') ||
resultFileName.endsWith('.mp4')));
if (!requiresContainerConversion &&
!looksLikeM4a &&
!isContentUri(filePath)) {
return filePath;
}
Future<void> embedFlacMetadata(String flacPath) async {
if (!settings.embedMetadata) return;
await _embedMetadataToFile(
flacPath,
track,
format: 'flac',
genre: result['genre'] as String?,
label: result['label'] as String?,
copyright: result['copyright'] as String?,
downloadService: context.item.service,
writeExternalLrc: context.storageMode != 'saf',
);
}
if (context.storageMode == 'saf' && isContentUri(filePath)) {
final treeUri = context.downloadTreeUri;
if (treeUri == null || treeUri.isEmpty) {
return null;
}
var preserve = false;
String? producedFileName;
final newUri = await _replaceSafFileVia(
uri: filePath,
treeUri: treeUri,
relativeDir: context.safRelativeDir ?? '',
op: (tempPath) async {
final codec = await FFmpegService.probePrimaryAudioCodec(tempPath);
final isAlreadyNativeFlac =
codec == 'flac' && await FFmpegService.isNativeFlacFile(tempPath);
if (!FFmpegService.isLosslessAudioCodec(codec)) {
_log.d(
'Preserving native container; audio codec is ${codec ?? 'unknown'}, '
'no FLAC container conversion needed.',
);
preserve = true;
return null;
}
if (isAlreadyNativeFlac) {
_log.d(
'Native FLAC payload detected in temporary container; publishing '
'as FLAC and embedding metadata.',
);
await embedFlacMetadata(tempPath);
final rawFileName =
(result['file_name'] as String?) ??
context.safFileName ??
'track';
final baseName = rawFileName.replaceFirst(RegExp(r'\.[^.]+$'), '');
final newFileName = '$baseName.flac';
producedFileName = newFileName;
return (tempPath, newFileName);
}
final flacPath = await FFmpegService.convertM4aToFlac(tempPath);
if (flacPath == null) {
return null;
}
await embedFlacMetadata(flacPath);
final rawFileName =
(result['file_name'] as String?) ??
context.safFileName ??
'track';
final baseName = rawFileName.replaceFirst(RegExp(r'\.[^.]+$'), '');
final newFileName = '$baseName.flac';
producedFileName = newFileName;
return (flacPath, newFileName);
},
);
if (preserve) {
return filePath;
}
if (newUri == null) {
return null;
}
result['file_name'] = producedFileName;
return newUri;
}
final codec = await FFmpegService.probePrimaryAudioCodec(filePath);
final isAlreadyNativeFlac =
codec == 'flac' && await FFmpegService.isNativeFlacFile(filePath);
if (!FFmpegService.isLosslessAudioCodec(codec)) {
_log.d(
'Preserving native container; audio codec is ${codec ?? 'unknown'}, '
'no FLAC container conversion needed.',
);
return filePath;
}
if (isAlreadyNativeFlac) {
var flacPath = filePath;
if (!filePath.toLowerCase().endsWith('.flac')) {
final renamedPath = filePath.replaceAll(RegExp(r'\.[^.]+$'), '.flac');
final targetPath = renamedPath == filePath
? '$filePath.flac'
: renamedPath;
await File(filePath).rename(targetPath);
flacPath = targetPath;
}
await embedFlacMetadata(flacPath);
return flacPath;
}
final flacPath = await FFmpegService.convertM4aToFlac(filePath);
if (flacPath == null) {
return null;
}
await embedFlacMetadata(flacPath);
return flacPath;
}
/// Shared external-LRC finalize used by both the inline single-item
/// pipeline (SAF only; the local-file case is already handled during
/// metadata embedding) and the native-worker pipeline (both storage
/// modes). [resolveBaseName] and [onFetchError] are each caller's own
/// base-name fallback chain and fetch-failure log line, evaluated lazily
/// to match the original call sites exactly.
Future<void> _saveExternalLrc({
required Map<String, dynamic> result,
required AppSettings settings,
required ExtensionState extensionState,
required Track track,
required String service,
required String filePath,
required String storageMode,
String? downloadTreeUri,
required String safRelativeDir,
required Future<String> Function() resolveBaseName,
required void Function(Object e) onFetchError,
}) async {
final lyricsMode = settings.lyricsMode;
final shouldSaveExternalLrc =
settings.embedMetadata &&
settings.embedLyrics &&
!_shouldSkipLyrics(extensionState, track.source, service) &&
(lyricsMode == 'external' || lyricsMode == 'both');
if (!shouldSaveExternalLrc) {
return;
}
String? lrcContent = result['lyrics_lrc'] as String?;
if (lrcContent == null || lrcContent.isEmpty) {
try {
lrcContent = await PlatformBridge.getLyricsLRC(
track.id,
track.name,
track.artistName,
durationMs: track.duration * 1000,
);
} catch (e) {
onFetchError(e);
}
}
if (lrcContent == null || lrcContent.isEmpty) {
return;
}
if (storageMode == 'saf' && isContentUri(filePath)) {
if (downloadTreeUri == null || downloadTreeUri.isEmpty) {
return;
}
final baseName = await resolveBaseName();
await _writeLrcToSaf(
treeUri: downloadTreeUri,
relativeDir: safRelativeDir,
baseName: baseName,
lrcContent: lrcContent,
);
return;
}
try {
final lrcPath = filePath.replaceAll(RegExp(r'\.[^.]+$'), '.lrc');
final safeLrcPath = lrcPath == filePath ? '$filePath.lrc' : lrcPath;
await File(safeLrcPath).writeAsString(lrcContent);
_log.d('Native-worker external LRC saved: $safeLrcPath');
} catch (e) {
_log.w('Failed to save native-worker external LRC: $e');
}
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,564 @@
// ignore_for_file: invalid_use_of_protected_member, invalid_use_of_visible_for_testing_member
part of 'download_queue_provider.dart';
extension _DownloadQueuePaths on DownloadQueueNotifier {
Directory _defaultDocumentsOutputDir(String documentsPath) {
return Directory('$documentsPath/$_defaultOutputFolderName');
}
Directory _defaultAndroidMusicOutputDir(String storageRootPath) {
return Directory('$storageRootPath/$_defaultAndroidMusicSubpath');
}
Future<Directory> _ensureDefaultDocumentsOutputDir() async {
final dir = await getApplicationDocumentsDirectory();
final musicDir = _defaultDocumentsOutputDir(dir.path);
if (!await musicDir.exists()) {
await musicDir.create(recursive: true);
}
return musicDir;
}
Future<Directory?> _ensureDefaultAndroidMusicOutputDir() async {
final dir = await getExternalStorageDirectory();
if (dir == null) return null;
final musicDir = _defaultAndroidMusicOutputDir(
dir.parent.parent.parent.parent.path,
);
if (!await musicDir.exists()) {
await musicDir.create(recursive: true);
}
return musicDir;
}
Future<void> _initOutputDir() async {
if (state.outputDir.isEmpty) {
try {
if (Platform.isIOS) {
final musicDir = await _ensureDefaultDocumentsOutputDir();
state = state.copyWith(outputDir: musicDir.path);
} else {
final musicDir =
await _ensureDefaultAndroidMusicOutputDir() ??
await _ensureDefaultDocumentsOutputDir();
state = state.copyWith(outputDir: musicDir.path);
}
} catch (e) {
final musicDir = await _ensureDefaultDocumentsOutputDir();
state = state.copyWith(outputDir: musicDir.path);
}
}
}
Future<void> _ensureDirExists(String path, {String? label}) async {
if (_ensuredDirs.contains(path)) return;
final dir = Directory(path);
if (!await dir.exists()) {
await dir.create(recursive: true);
if (label != null) {
_log.d('Created $label: $path');
} else {
_log.d('Created folder: $path');
}
}
_ensuredDirs.add(path);
}
bool _shouldTreatAsSingleRelease(Track track) {
if (track.isSingle) {
return true;
}
final normalizedAlbumType = normalizeOptionalString(
track.albumType,
)?.toLowerCase();
if (normalizedAlbumType != null && normalizedAlbumType.isNotEmpty) {
return false;
}
final totalTracks = track.totalTracks;
if (totalTracks == 1) {
return true;
}
final normalizedAlbumName = normalizeOptionalString(
track.albumName,
)?.toLowerCase();
if (normalizedAlbumName == 'single' || normalizedAlbumName == 'singles') {
return totalTracks == null || totalTracks <= 2;
}
return false;
}
Future<String> _buildOutputDir(
Track track,
String folderOrganization, {
bool separateSingles = false,
String albumFolderStructure = 'artist_album',
bool createPlaylistFolder = false,
bool useAlbumArtistForFolders = true,
bool usePrimaryArtistOnly = false,
bool filterContributingArtistsInAlbumArtist = false,
String? playlistName,
}) async {
final baseDir = state.outputDir;
if (createPlaylistFolder &&
folderOrganization != 'playlist' &&
playlistName != null &&
playlistName.isNotEmpty) {
final playlistFolder = _sanitizeFolderName(playlistName);
if (playlistFolder.isNotEmpty) {
await _ensureDirExists(
'$baseDir${Platform.pathSeparator}$playlistFolder',
label: 'Playlist folder',
);
}
}
final relativeDir = await _buildRelativeOutputDir(
track,
folderOrganization,
separateSingles: separateSingles,
albumFolderStructure: albumFolderStructure,
createPlaylistFolder: createPlaylistFolder,
useAlbumArtistForFolders: useAlbumArtistForFolders,
usePrimaryArtistOnly: usePrimaryArtistOnly,
filterContributingArtistsInAlbumArtist:
filterContributingArtistsInAlbumArtist,
playlistName: playlistName,
);
if (relativeDir.isEmpty) {
return baseDir;
}
final fullPath =
'$baseDir${Platform.pathSeparator}'
'${relativeDir.replaceAll('/', Platform.pathSeparator)}';
await _ensureDirExists(fullPath);
return fullPath;
}
String _sanitizeFolderName(String name) {
final buffer = StringBuffer();
for (final rune in name.runes) {
if (rune < 0x20 || rune == 0x7f) {
continue;
}
final char = String.fromCharCode(rune);
if (_invalidFolderChars.hasMatch(char)) {
buffer.write(' ');
continue;
}
buffer.write(char);
}
var sanitized = buffer.toString().trim();
sanitized = sanitized.replaceAll(_trimDotsAndSpacesRegex, '');
sanitized = sanitized.replaceAll(_multiWhitespaceRegex, ' ');
sanitized = sanitized.replaceAll(_multiUnderscoreRegex, '_');
sanitized = sanitized.replaceAll(_trimUnderscoresAndSpacesRegex, '');
if (sanitized.isEmpty) {
return 'Unknown';
}
return sanitized;
}
String _truncateUtf8Bytes(String value, int maxBytes) {
if (maxBytes <= 0 || utf8.encode(value).length <= maxBytes) {
return value;
}
final buffer = StringBuffer();
var usedBytes = 0;
for (final rune in value.runes) {
final char = String.fromCharCode(rune);
final charBytes = utf8.encode(char).length;
if (usedBytes + charBytes > maxBytes) break;
buffer.write(char);
usedBytes += charBytes;
}
return buffer.toString();
}
String _trimSafeName(String value) {
var trimmed = value.trim();
trimmed = trimmed.replaceAll(_trimDotsAndSpacesRegex, '');
trimmed = trimmed.replaceAll(_trimUnderscoresAndSpacesRegex, '');
return trimmed.isEmpty ? 'Unknown' : trimmed;
}
String _sanitizeSafRelativeDir(String relativeDir) {
if (relativeDir.trim().isEmpty) return '';
final parts = relativeDir
.split('/')
.map(_sanitizeFolderName)
.map((part) {
final truncated = _truncateUtf8Bytes(
part,
_maxSafDirSegmentUtf8Bytes,
);
return _trimSafeName(truncated);
})
.where((part) => part.isNotEmpty && part != '.' && part != '..')
.toList(growable: false);
return parts.join('/');
}
Future<String> _buildSafFileName(String baseName, String outputExt) async {
final sanitized = await PlatformBridge.sanitizeFilename(baseName);
final extBytes = utf8.encode(outputExt).length;
final maxBaseBytes = max(1, _maxSafFilenameUtf8Bytes - extBytes);
final truncated = _truncateUtf8Bytes(sanitized, maxBaseBytes);
return '${_trimSafeName(truncated)}$outputExt';
}
static final _featuredArtistPattern = RegExp(
r'\s*[,;]\s*|\s+(?:feat\.?|ft\.?|featuring|with|x)\s+',
caseSensitive: false,
);
String _extractPrimaryArtist(String artist) {
final match = _featuredArtistPattern.firstMatch(artist);
if (match != null && match.start > 0) {
return artist.substring(0, match.start).trim();
}
return artist;
}
Future<String> _buildRelativeOutputDir(
Track track,
String folderOrganization, {
bool separateSingles = false,
String albumFolderStructure = 'artist_album',
bool createPlaylistFolder = false,
bool useAlbumArtistForFolders = true,
bool usePrimaryArtistOnly = false,
bool filterContributingArtistsInAlbumArtist = false,
String? playlistName,
}) async {
final playlistPrefix =
createPlaylistFolder &&
folderOrganization != 'playlist' &&
playlistName != null &&
playlistName.isNotEmpty
? _sanitizeFolderName(playlistName)
: '';
final normalizedAlbumArtist = normalizeOptionalString(track.albumArtist);
var folderArtist = useAlbumArtistForFolders
? normalizedAlbumArtist ?? track.artistName
: track.artistName;
if (useAlbumArtistForFolders &&
filterContributingArtistsInAlbumArtist &&
normalizedAlbumArtist != null) {
folderArtist = _extractPrimaryArtist(folderArtist);
}
if (usePrimaryArtistOnly) {
folderArtist = _extractPrimaryArtist(folderArtist);
}
if (separateSingles) {
final isSingle = _shouldTreatAsSingleRelease(track);
final artistName = _sanitizeFolderName(folderArtist);
if (albumFolderStructure == 'artist_album_singles') {
if (isSingle) {
return _joinRelativePath(playlistPrefix, '$artistName/Singles');
}
final albumName = _sanitizeFolderName(track.albumName);
return _joinRelativePath(playlistPrefix, '$artistName/$albumName');
}
if (albumFolderStructure == 'artist_album_flat') {
if (isSingle) {
return _joinRelativePath(playlistPrefix, artistName);
}
final albumName = _sanitizeFolderName(track.albumName);
return _joinRelativePath(playlistPrefix, '$artistName/$albumName');
}
if (isSingle) {
return _joinRelativePath(playlistPrefix, 'Singles');
}
final albumName = _sanitizeFolderName(track.albumName);
final year = _extractYear(track.releaseDate);
switch (albumFolderStructure) {
case 'album_only':
return _joinRelativePath(playlistPrefix, 'Albums/$albumName');
case 'artist_year_album':
final yearAlbum = year != null ? '[$year] $albumName' : albumName;
return _joinRelativePath(
playlistPrefix,
'Albums/$artistName/$yearAlbum',
);
case 'year_album':
final yearAlbum = year != null ? '[$year] $albumName' : albumName;
return _joinRelativePath(playlistPrefix, 'Albums/$yearAlbum');
default:
return _joinRelativePath(
playlistPrefix,
'Albums/$artistName/$albumName',
);
}
}
if (folderOrganization == 'none') {
return playlistPrefix;
}
switch (folderOrganization) {
case 'playlist':
if (playlistName != null && playlistName.isNotEmpty) {
return _sanitizeFolderName(playlistName);
}
return '';
case 'artist':
return _joinRelativePath(
playlistPrefix,
_sanitizeFolderName(folderArtist),
);
case 'album':
return _joinRelativePath(
playlistPrefix,
_sanitizeFolderName(track.albumName),
);
case 'artist_album':
final artistName = _sanitizeFolderName(folderArtist);
final albumName = _sanitizeFolderName(track.albumName);
return _joinRelativePath(playlistPrefix, '$artistName/$albumName');
default:
return playlistPrefix;
}
}
String _joinRelativePath(String prefix, String suffix) {
if (prefix.isEmpty) return suffix;
if (suffix.isEmpty) return prefix;
return '$prefix/$suffix';
}
String? _extensionPreferredOutputExt(String service) {
final normalizedService = service.trim().toLowerCase();
if (normalizedService.isEmpty) return null;
final extensionState = ref.read(extensionProvider);
for (final ext in extensionState.extensions) {
if (!ext.enabled || !ext.hasDownloadProvider) continue;
if (ext.id.toLowerCase() != normalizedService) continue;
final preferred = ext.preferredDownloadOutputExtension;
if (preferred == null) return null;
final normalized = preferred.startsWith('.')
? preferred.toLowerCase()
: '.${preferred.toLowerCase()}';
if (normalized == '.mp4') {
return '.m4a';
}
const allowed = <String>{'.flac', '.m4a', '.mp3', '.opus'};
if (allowed.contains(normalized)) {
return normalized;
}
return null;
}
return null;
}
bool _extensionPreservesNativeOutputExt(String service, String ext) {
final normalizedService = service.trim().toLowerCase();
final normalizedExt = ext.trim().toLowerCase();
if (normalizedService.isEmpty || normalizedExt.isEmpty) return false;
final extensionState = ref.read(extensionProvider);
return extensionState.extensions.any(
(ext) =>
ext.enabled &&
ext.hasDownloadProvider &&
ext.id.toLowerCase() == normalizedService &&
ext.preservedNativeOutputExtensions.contains(normalizedExt),
);
}
bool _extensionRequiresNativeContainerConversion(String service) {
final normalizedService = service.trim().toLowerCase();
if (normalizedService.isEmpty) return false;
final extensionState = ref.read(extensionProvider);
return extensionState.extensions.any(
(ext) =>
ext.enabled &&
ext.hasDownloadProvider &&
(ext.id.toLowerCase() == normalizedService ||
ext.replacesBuiltInProviders.contains(normalizedService)) &&
ext.requiresNativeContainerConversion,
);
}
bool _shouldRequestContainerConversion(String service, String outputExt) {
return outputExt.trim().toLowerCase() == '.flac' &&
_extensionRequiresNativeContainerConversion(service);
}
String _determineOutputExt(String quality, String service) {
final extensionPreferred = _extensionPreferredOutputExt(service);
if (extensionPreferred != null) {
return extensionPreferred;
}
if (_downloadProviderReplacesLegacyProvider(service, 'tidal') &&
quality == 'HIGH') {
return '.m4a';
}
final q = quality.toLowerCase();
if (q == 'alac' || q.startsWith('aac')) return '.m4a';
if (q.startsWith('opus')) return '.opus';
if (q.startsWith('mp3')) return '.mp3';
return '.flac';
}
bool _downloadProviderReplacesLegacyProvider(
String service,
String legacyProviderId,
) {
return ref
.read(extensionProvider.notifier)
.downloadProviderReplacesLegacyProvider(service, legacyProviderId);
}
String _mimeTypeForExt(String ext) {
switch (ext.toLowerCase()) {
case '.m4a':
case '.mp4':
return 'audio/mp4';
case '.mp3':
return 'audio/mpeg';
case '.opus':
return 'audio/ogg';
case '.flac':
return 'audio/flac';
case '.lrc':
return 'application/octet-stream';
default:
return 'application/octet-stream';
}
}
String? _normalizeAudioExt(Object? value) {
final raw = value?.toString().trim().toLowerCase();
if (raw == null || raw.isEmpty) return null;
final normalized = raw.startsWith('.') ? raw : '.$raw';
const allowed = {'.flac', '.m4a', '.mp4', '.mp3', '.opus', '.ogg', '.aac'};
return allowed.contains(normalized) ? normalized : null;
}
String? _downloadResultOutputExt(
Map<String, dynamic> result, {
String? filePath,
}) {
final explicit =
_normalizeAudioExt(result['actual_extension']) ??
_normalizeAudioExt(result['output_extension']) ??
_normalizeAudioExt(result['actual_container']) ??
_normalizeAudioExt(result['container']);
if (explicit != null) return explicit;
for (final candidate in <String?>[
result['file_name'] as String?,
filePath,
result['file_path'] as String?,
]) {
if (candidate == null) continue;
final lower = candidate.trim().toLowerCase();
for (final ext in const [
'.flac',
'.m4a',
'.mp4',
'.mp3',
'.opus',
'.ogg',
'.aac',
]) {
if (lower.endsWith(ext)) return ext;
}
}
// Generic safety net: when neither an explicit extension field nor a
// recognizable path suffix is available (e.g. SAF content URIs that drop
// the suffix), fall back to the actual audio codec reported by the backend
// probe. This keeps any extension that returns a non-FLAC container (Opus,
// MP3, AAC) from being mislabeled as FLAC.
final codec = normalizeAudioFormatValue(
result['audio_codec']?.toString() ??
result['actual_audio_codec']?.toString() ??
result['format']?.toString(),
);
switch (codec) {
case 'opus':
return '.opus';
case 'mp3':
return '.mp3';
case 'aac':
case 'alac':
case 'm4a':
return '.m4a';
case 'flac':
return '.flac';
}
return null;
}
Future<String?> _getSafMimeType(String uri) async {
try {
final stat = await PlatformBridge.safStat(uri);
return stat['mime_type'] as String?;
} catch (_) {
return null;
}
}
String? _extractYear(String? releaseDate) {
if (releaseDate == null || releaseDate.isEmpty) return null;
final match = _yearRegex.firstMatch(releaseDate);
return match?.group(1);
}
int _validPlaylistPosition(DownloadItem item) {
final position = item.playlistPosition;
if (position == null || position <= 0) return 0;
return position;
}
String _filenameFormatForItem(DownloadItem item, String baseFormat) {
if (!item.fromBatch) {
return baseFormat;
}
final trimmed = baseFormat.trim();
if (trimmed.isEmpty) {
return baseFormat;
}
if (_batchUniqueFilenameTokenPattern.hasMatch(trimmed)) {
return baseFormat;
}
return '$trimmed - {track:02} - {title}';
}
Map<String, dynamic> _filenameMetadataForTrack(
Track track, {
int playlistPosition = 0,
}) {
return {
'title': track.name,
'artist': track.artistName,
'album': track.albumName,
'track': track.trackNumber ?? 0,
'disc': track.discNumber ?? 0,
'year': _extractYear(track.releaseDate) ?? '',
'date': track.releaseDate ?? '',
'playlist_position': playlistPosition,
'playlistPosition': playlistPosition,
};
}
}
@@ -0,0 +1,307 @@
// ignore_for_file: invalid_use_of_protected_member, invalid_use_of_visible_for_testing_member
part of 'download_queue_provider.dart';
class _AlbumRgTrackEntry {
String filePath;
final String trackId;
final double integratedLufs;
final double truePeakLinear;
final double durationSecs;
_AlbumRgTrackEntry({
required this.filePath,
required this.trackId,
required this.integratedLufs,
required this.truePeakLinear,
required this.durationSecs,
});
}
class _AlbumRgAccumulator {
final List<_AlbumRgTrackEntry> entries = [];
}
extension _DownloadQueueReplayGain on DownloadQueueNotifier {
String _albumRgKey(Track track) {
if (track.albumId != null && track.albumId!.isNotEmpty) {
return 'id:${track.albumId}';
}
return 'name:${track.albumName}|${track.albumArtist ?? ''}';
}
/// Purge a track's stale ReplayGain accumulator entry, dropping the whole
/// album accumulator once it becomes empty.
void _purgeAlbumRgEntry(Track track) {
final key = _albumRgKey(track);
final accumulator = _albumRgData[key];
if (accumulator == null) return;
accumulator.entries.removeWhere((e) => e.trackId == track.id);
if (accumulator.entries.isEmpty) {
_albumRgData.remove(key);
}
}
/// Store a track's ReplayGain scan result for later album gain computation.
void _storeTrackReplayGainForAlbum(
Track track,
String filePath,
ReplayGainResult rg,
) {
final key = _albumRgKey(track);
_albumRgData.putIfAbsent(key, () => _AlbumRgAccumulator());
// Remove any stale entry for this track (e.g. from a previous failed
// attempt that was retried). Without this, the same track can accumulate
// multiple entries and bias the album loudness calculation.
_albumRgData[key]!.entries.removeWhere((e) => e.trackId == track.id);
_albumRgData[key]!.entries.add(
_AlbumRgTrackEntry(
filePath: filePath,
trackId: track.id,
integratedLufs: rg.integratedLufs,
truePeakLinear: rg.truePeakLinear,
durationSecs: track.duration.toDouble(),
),
);
}
/// Replace the temp path stored in the accumulator with the final output
/// path. For SAF downloads the embed happens on a temp file which is later
/// deleted — this ensures the album-gain writer targets the real file.
void _updateAlbumRgFilePath(Track track, String finalPath) {
final key = _albumRgKey(track);
final accumulator = _albumRgData[key];
if (accumulator == null) return;
for (final entry in accumulator.entries) {
if (entry.trackId == track.id) {
entry.filePath = finalPath;
break;
}
}
}
/// After a track completes, check whether all tracks from the same album
/// in the current queue are done. If so, compute album gain and write it
/// to every track's file.
Future<void> _checkAndWriteAlbumReplayGain(Track track) async {
final settings = ref.read(settingsProvider);
if (!settings.embedReplayGain) return;
final key = _albumRgKey(track);
final accumulator = _albumRgData[key];
if (accumulator == null || accumulator.entries.isEmpty) return;
// Find queue items for this album that are STILL in the queue.
// Completed tracks may have already been removed by removeItem(), so
// their absence means they finished successfully (not that they're
// still pending).
final albumItemsInQueue = state.items
.where((item) => _albumRgKey(item.track) == key)
.toList();
final pending = albumItemsInQueue.where(
(item) =>
item.status == DownloadStatus.queued ||
item.status == DownloadStatus.downloading ||
item.status == DownloadStatus.finalizing,
);
if (pending.isNotEmpty) return;
// If any item is failed/skipped, the user might retry it later.
// Don't finalize album RG with partial data — wait until all album
// tracks are either completed (and possibly removed) or retried.
final retryable = albumItemsInQueue.where(
(item) =>
item.status == DownloadStatus.failed ||
item.status == DownloadStatus.skipped,
);
if (retryable.isNotEmpty) return;
// The accumulator entries represent successfully scanned tracks. Entries
// are only added after a successful ReplayGain scan, removed on retry or
// when a non-completed item is removed from the queue, so every entry
// here corresponds to a track that completed (or is about to complete)
// its download.
final validEntries = accumulator.entries.toList();
// Single-track albums: album gain == track gain, no extra write needed.
if (validEntries.length <= 1) {
_albumRgData.remove(key);
return;
}
// Compute album gain using duration-weighted power-mean of LUFS values.
// album_loudness = 10 * log10( Σ(10^(Li/10) * di) / Σ(di) )
// This weights longer tracks more, matching "whole program" loudness.
double sumWeightedPower = 0;
double sumDuration = 0;
double maxPeak = 0;
for (final entry in validEntries) {
final weight = entry.durationSecs > 0 ? entry.durationSecs : 1.0;
sumWeightedPower += pow(10, entry.integratedLufs / 10.0) * weight;
sumDuration += weight;
if (entry.truePeakLinear > maxPeak) {
maxPeak = entry.truePeakLinear;
}
}
final albumLufs = 10.0 * _log10(sumWeightedPower / sumDuration);
const replayGainReferenceLufs = -18.0;
final albumGainDb = replayGainReferenceLufs - albumLufs;
final albumGain =
'${albumGainDb >= 0 ? "+" : ""}${albumGainDb.toStringAsFixed(2)} dB';
final albumPeak = maxPeak.toStringAsFixed(6);
_log.i(
'Album ReplayGain for "$key": gain=$albumGain, peak=$albumPeak (${validEntries.length} tracks, album LUFS=${albumLufs.toStringAsFixed(1)})',
);
for (final entry in validEntries) {
try {
await _writeAlbumReplayGain(entry.filePath, albumGain, albumPeak);
} catch (e) {
_log.w('Failed to write album ReplayGain to ${entry.filePath}: $e');
}
}
_albumRgData.remove(key);
}
/// Write album ReplayGain tags to a single file.
Future<void> _writeAlbumReplayGain(
String filePath,
String albumGain,
String albumPeak,
) async {
final lower = filePath.toLowerCase();
if (lower.endsWith('.flac') ||
lower.endsWith('.ape') ||
lower.endsWith('.wv') ||
lower.endsWith('.mpc')) {
// Native writer — only touches the provided fields, preserves the rest.
await PlatformBridge.editFileMetadata(filePath, {
'replaygain_album_gain': albumGain,
'replaygain_album_peak': albumPeak,
});
} else if (isContentUri(filePath)) {
// SAF content:// URI — FFmpeg can read it but can't write back directly.
// Get the temp output from FFmpeg, then copy it to the SAF URI.
String? tempPath;
final ok = await FFmpegService.writeAlbumReplayGainTags(
filePath,
albumGain,
albumPeak,
returnTempPath: true,
onTempReady: (path) => tempPath = path,
);
if (ok && tempPath != null) {
try {
final safOk = await PlatformBridge.writeTempToSaf(
tempPath!,
filePath,
);
if (!safOk) {
_log.w('SAF write-back failed for album RG: $filePath');
}
} finally {
try {
final tmp = File(tempPath!);
if (await tmp.exists()) await tmp.delete();
} catch (_) {}
}
} else {
_log.w('FFmpeg album ReplayGain write failed for SAF: $filePath');
}
} else {
// Local MP3 / Opus — use FFmpeg copy-with-metadata approach.
final ok = await FFmpegService.writeAlbumReplayGainTags(
filePath,
albumGain,
albumPeak,
);
if (!ok) {
_log.w('FFmpeg album ReplayGain write failed for: $filePath');
}
}
}
/// Re-check album ReplayGain for all albums that still have accumulator data.
/// Called after removing/dismissing a failed or skipped item, which may
/// unblock an album that was waiting for retryable items to be resolved.
void _retriggerAlbumRgChecks() {
if (_albumRgData.isEmpty) return;
final settings = ref.read(settingsProvider);
if (!settings.embedReplayGain) return;
// Snapshot the keys — _checkAndWriteAlbumReplayGain may mutate the map.
final keys = _albumRgData.keys.toList();
for (final key in keys) {
final acc = _albumRgData[key];
if (acc == null || acc.entries.isEmpty) continue;
// Use the first entry's trackId to find a representative track.
// _checkAndWriteAlbumReplayGain only needs it for _albumRgKey(), so any
// track from the album works.
final albumItems = state.items
.where((item) => _albumRgKey(item.track) == key)
.toList();
// If there are no items left in queue for this album but we have
// accumulator data, all items were completed and removed. Use a
// synthetic call — we need a Track to call the check, but the items
// are gone. For this case, directly check conditions inline.
if (albumItems.isEmpty) {
// All items removed → no pending/retryable. Trigger computation.
if (acc.entries.length > 1) {
_computeAndWriteAlbumRg(key, acc);
}
continue;
}
final representative = albumItems.first;
_checkAndWriteAlbumReplayGain(representative.track);
}
}
/// Compute album RG and write it — extracted from _checkAndWriteAlbumReplayGain
/// for use when no queue items remain (all completed and removed).
Future<void> _computeAndWriteAlbumRg(
String key,
_AlbumRgAccumulator accumulator,
) async {
final validEntries = accumulator.entries.toList();
if (validEntries.length <= 1) {
_albumRgData.remove(key);
return;
}
double sumWeightedPower = 0;
double sumDuration = 0;
double maxPeak = 0;
for (final entry in validEntries) {
final weight = entry.durationSecs > 0 ? entry.durationSecs : 1.0;
sumWeightedPower += pow(10, entry.integratedLufs / 10.0) * weight;
sumDuration += weight;
if (entry.truePeakLinear > maxPeak) {
maxPeak = entry.truePeakLinear;
}
}
final albumLufs = 10.0 * _log10(sumWeightedPower / sumDuration);
const replayGainReferenceLufs = -18.0;
final albumGainDb = replayGainReferenceLufs - albumLufs;
final albumGain =
'${albumGainDb >= 0 ? "+" : ""}${albumGainDb.toStringAsFixed(2)} dB';
final albumPeak = maxPeak.toStringAsFixed(6);
_log.i(
'Album ReplayGain for "$key": gain=$albumGain, peak=$albumPeak (${validEntries.length} tracks, album LUFS=${albumLufs.toStringAsFixed(1)})',
);
for (final entry in validEntries) {
try {
await _writeAlbumReplayGain(entry.filePath, albumGain, albumPeak);
} catch (e) {
_log.w('Failed to write album ReplayGain to ${entry.filePath}: $e');
}
}
_albumRgData.remove(key);
}
}