mirror of
https://github.com/zarzet/SpotiFLAC-Mobile.git
synced 2026-07-29 15:28:48 +02:00
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:
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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user