mirror of
https://github.com/zarzet/SpotiFLAC-Mobile.git
synced 2026-10-20 06:28:27 +02:00
875 lines
29 KiB
Dart
875 lines
29 KiB
Dart
part of 'download_queue_provider.dart';
|
|
|
|
class _QualityVariantFileOutcome {
|
|
final String filePath;
|
|
final String? fileName;
|
|
final Map<String, dynamic>? metadata;
|
|
|
|
const _QualityVariantFileOutcome({
|
|
required this.filePath,
|
|
this.fileName,
|
|
this.metadata,
|
|
});
|
|
}
|
|
|
|
/// AC-4 repair only applies to MP4 containers; decrypt can also emit raw
|
|
/// FLAC, which the native MP4 box parser would reject as corrupt.
|
|
bool _isMp4Container(String path) {
|
|
final lower = path.toLowerCase();
|
|
return lower.endsWith('.m4a') || lower.endsWith('.mp4');
|
|
}
|
|
|
|
extension _DownloadQueueFinalization on DownloadQueueNotifier {
|
|
DownloadFileFinalizer get _fileFinalizer => DownloadFileFinalizer(
|
|
decryptFile: FFmpegService.decryptWithDescriptor,
|
|
probeCodec: FFmpegService.probePrimaryAudioCodec,
|
|
repairAc4: (path, source) async {
|
|
await PlatformBridge.ensureAC4Config(path, source);
|
|
},
|
|
convertAudio:
|
|
({
|
|
required inputPath,
|
|
required targetFormat,
|
|
required bitrate,
|
|
required deleteOriginal,
|
|
}) => FFmpegService.convertAudioFormat(
|
|
inputPath: inputPath,
|
|
targetFormat: targetFormat,
|
|
bitrate: bitrate,
|
|
metadata: const {},
|
|
deleteOriginal: deleteOriginal,
|
|
),
|
|
replaceSafFile: _replaceSafFileVia,
|
|
warn: _log.w,
|
|
info: _log.i,
|
|
);
|
|
|
|
Future<void> _saveDownloadedMotionArtwork(
|
|
Ref ref,
|
|
DownloadItem item,
|
|
Track track,
|
|
Map<String, dynamic> result,
|
|
) async {
|
|
// Store independently of history preferences and the selected UI theme.
|
|
// A later switch to Mornye must still work without network access.
|
|
try {
|
|
final album = (
|
|
album:
|
|
_resolveMetadataText(
|
|
track.albumName,
|
|
result['album']?.toString(),
|
|
) ??
|
|
item.track.albumName,
|
|
artist:
|
|
_resolveMetadataText(
|
|
track.artistName,
|
|
result['artist']?.toString(),
|
|
) ??
|
|
item.track.artistName,
|
|
);
|
|
final store = ref.read(motionArtworkStoreProvider);
|
|
final artwork = await store.save(
|
|
album,
|
|
resolveSource: () => resolveDownloadMotionArtworkSource(
|
|
original: item.track,
|
|
downloaded: track,
|
|
result: result,
|
|
getMetadata: PlatformBridge.getProviderMetadata,
|
|
),
|
|
);
|
|
if (artwork != null && ref.mounted) {
|
|
ref.invalidate(playerMotionArtworkProvider(album));
|
|
}
|
|
} catch (error) {
|
|
_log.w('Optional motion artwork was not saved: ${error.runtimeType}');
|
|
}
|
|
}
|
|
|
|
Future<DownloadConversionOutcome> _autoConvertDownloadedFile({
|
|
required String itemId,
|
|
required String filePath,
|
|
required String? fileName,
|
|
required String currentQuality,
|
|
required AppSettings settings,
|
|
required Track track,
|
|
required Map<String, dynamic> result,
|
|
required String downloadService,
|
|
required String storageMode,
|
|
String? downloadTreeUri,
|
|
String? safRelativeDir,
|
|
}) => _fileFinalizer.autoConvert(
|
|
result: DownloadResult.fromMap(result),
|
|
filePath: filePath,
|
|
fileName: fileName,
|
|
quality: currentQuality,
|
|
enabled: settings.autoConvertDownloads,
|
|
targetFormat: settings.autoConvertFormat,
|
|
targetBitrate: settings.autoConvertBitrate,
|
|
useSaf: storageMode == 'saf',
|
|
treeUri: downloadTreeUri,
|
|
relativeDir: safRelativeDir ?? '',
|
|
onStart: () =>
|
|
updateItemStatus(itemId, DownloadStatus.finalizing, progress: 0.97),
|
|
embedMetadata: settings.embedMetadata
|
|
? (path, format) async {
|
|
await _embedMetadataToFile(
|
|
path,
|
|
track,
|
|
format: format,
|
|
genre: result['genre'] as String?,
|
|
label: result['label'] as String?,
|
|
copyright: result['copyright'] as String?,
|
|
comment: result['comment'] as String?,
|
|
lyricsLrc: result['lyrics_lrc'] as String?,
|
|
downloadService: downloadService,
|
|
writeExternalLrc: storageMode != 'saf',
|
|
);
|
|
}
|
|
: null,
|
|
);
|
|
|
|
/// 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,
|
|
required bool hasLyrics,
|
|
required int lyricsMetadataScanVersion,
|
|
required bool hasReplayGain,
|
|
required int replayGainMetadataScanVersion,
|
|
}) {
|
|
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: filePath.startsWith('network://')
|
|
? 'network'
|
|
: 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,
|
|
explicit:
|
|
trackToDownload.isExplicit ||
|
|
parseExplicitFlag(result['explicit']) == true,
|
|
hasLyrics: hasLyrics,
|
|
lyricsMetadataScanVersion: lyricsMetadataScanVersion,
|
|
hasReplayGain: hasReplayGain,
|
|
replayGainMetadataScanVersion: replayGainMetadataScanVersion,
|
|
);
|
|
}
|
|
|
|
Future<
|
|
({
|
|
bool hasLyrics,
|
|
int scanVersion,
|
|
bool hasReplayGain,
|
|
int replayGainScanVersion,
|
|
})
|
|
>
|
|
_resolveFinalLyricsAvailability({
|
|
required String filePath,
|
|
Map<String, dynamic>? probedMetadata,
|
|
bool externalLrcWritten = false,
|
|
}) async {
|
|
var metadataScanned = false;
|
|
var hasEmbeddedLyrics = false;
|
|
var hasReplayGain = false;
|
|
var replayGainScanVersion = 0;
|
|
try {
|
|
final metadata =
|
|
probedMetadata ?? await PlatformBridge.readFileMetadata(filePath);
|
|
if (replayGainMetadataWasRead(metadata)) {
|
|
hasReplayGain = metadataHasReplayGain(metadata);
|
|
replayGainScanVersion = 1;
|
|
}
|
|
if (metadata['error'] == null &&
|
|
(metadata.containsKey('lyrics') ||
|
|
metadata.containsKey('hasLyrics'))) {
|
|
metadataScanned = true;
|
|
hasEmbeddedLyrics =
|
|
metadata['hasLyrics'] == true ||
|
|
hasUsableLyricsContent(metadata['lyrics']?.toString() ?? '');
|
|
}
|
|
} catch (e) {
|
|
_log.d('Final lyrics metadata probe failed for $filePath: $e');
|
|
}
|
|
|
|
var hasSidecarLyrics = externalLrcWritten;
|
|
if (!isContentUri(filePath)) {
|
|
try {
|
|
final lrcPath = filePath.replaceAll(RegExp(r'\.[^.]+$'), '.lrc');
|
|
final safeLrcPath = lrcPath == filePath ? '$filePath.lrc' : lrcPath;
|
|
final sidecar = File(safeLrcPath);
|
|
if (await sidecar.exists()) {
|
|
hasSidecarLyrics = hasUsableLyricsContent(
|
|
await sidecar.readAsString(),
|
|
);
|
|
}
|
|
} catch (e) {
|
|
_log.d('Final sidecar lyrics probe failed for $filePath: $e');
|
|
}
|
|
}
|
|
|
|
final hasLyrics = hasEmbeddedLyrics || hasSidecarLyrics;
|
|
return (
|
|
hasLyrics: hasLyrics,
|
|
scanVersion: hasLyrics || metadataScanned ? 1 : 0,
|
|
hasReplayGain: hasReplayGain,
|
|
replayGainScanVersion: replayGainScanVersion,
|
|
);
|
|
}
|
|
|
|
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<({String uri, String fileName, bool alreadyExists})?>
|
|
_writeTempToSafIfAbsent({
|
|
required String treeUri,
|
|
required String relativeDir,
|
|
required String fileName,
|
|
required String mimeType,
|
|
required String srcPath,
|
|
}) async {
|
|
try {
|
|
final result = await PlatformBridge.createSafFileIfAbsentFromPath(
|
|
treeUri: treeUri,
|
|
relativeDir: relativeDir,
|
|
fileName: fileName,
|
|
mimeType: mimeType,
|
|
srcPath: srcPath,
|
|
);
|
|
_logSafPublishTimings(result);
|
|
final uri = (result['uri'] as String? ?? '').trim();
|
|
final publishedName = (result['file_name'] as String? ?? '').trim();
|
|
if (uri.isEmpty || publishedName.isEmpty) return null;
|
|
return (
|
|
uri: uri,
|
|
fileName: publishedName,
|
|
alreadyExists: result['already_exists'] == true,
|
|
);
|
|
} catch (e) {
|
|
_log.w('Failed to publish deferred SAF file: $e');
|
|
return null;
|
|
}
|
|
}
|
|
|
|
void _logSafPublishTimings(Map<String, dynamic> result) {
|
|
final timings = result['publish_timings_ms'];
|
|
if (timings is Map && timings.isNotEmpty) {
|
|
_log.d('SAF publish timings (ms): $timings');
|
|
}
|
|
}
|
|
|
|
Future<({String uri, String fileName})?> _writeTempToSafUnique({
|
|
required String treeUri,
|
|
required String relativeDir,
|
|
required String fileName,
|
|
required String mimeType,
|
|
required String srcPath,
|
|
String preservedSuffix = '',
|
|
}) async {
|
|
try {
|
|
final result = await PlatformBridge.createUniqueSafFileFromPath(
|
|
treeUri: treeUri,
|
|
relativeDir: relativeDir,
|
|
fileName: fileName,
|
|
mimeType: mimeType,
|
|
srcPath: srcPath,
|
|
preservedSuffix: preservedSuffix,
|
|
);
|
|
_logSafPublishTimings(result);
|
|
final uri = (result['uri'] as String? ?? '').trim();
|
|
final publishedName = (result['file_name'] as String? ?? '').trim();
|
|
if (uri.isEmpty || publishedName.isEmpty) return null;
|
|
return (uri: uri, fileName: publishedName);
|
|
} catch (e) {
|
|
_log.w('Failed to write unique temp file to SAF: $e');
|
|
return null;
|
|
}
|
|
}
|
|
|
|
Future<({String uri, String fileName})?> _writeTempToSafCollisionAware({
|
|
required String treeUri,
|
|
required String relativeDir,
|
|
required String cleanFileName,
|
|
required String variantFileName,
|
|
required String mimeType,
|
|
required String srcPath,
|
|
String preservedSuffix = '',
|
|
}) async {
|
|
try {
|
|
final result = await PlatformBridge.createCollisionAwareSafFileFromPath(
|
|
treeUri: treeUri,
|
|
relativeDir: relativeDir,
|
|
cleanFileName: cleanFileName,
|
|
variantFileName: variantFileName,
|
|
mimeType: mimeType,
|
|
srcPath: srcPath,
|
|
preservedSuffix: preservedSuffix,
|
|
);
|
|
_logSafPublishTimings(result);
|
|
final uri = (result['uri'] as String? ?? '').trim();
|
|
final publishedName = (result['file_name'] as String? ?? '').trim();
|
|
if (uri.isEmpty || publishedName.isEmpty) return null;
|
|
return (uri: uri, fileName: publishedName);
|
|
} catch (e) {
|
|
_log.w('Failed to write collision-aware temp file to SAF: $e');
|
|
return null;
|
|
}
|
|
}
|
|
|
|
Future<bool> _writeLrcToSaf({
|
|
required String treeUri,
|
|
required String relativeDir,
|
|
required String baseName,
|
|
required String lrcContent,
|
|
}) async {
|
|
try {
|
|
if (!hasUsableLyricsContent(lrcContent)) return false;
|
|
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 (_) {}
|
|
return uri != null;
|
|
} catch (e) {
|
|
_log.w('Failed to create external LRC in SAF: $e');
|
|
return false;
|
|
}
|
|
}
|
|
|
|
Future<void> _deleteSafFile(String uri) async {
|
|
try {
|
|
await PlatformBridge.safDelete(uri);
|
|
} catch (e) {
|
|
_log.w('Failed to delete SAF file: $e');
|
|
}
|
|
}
|
|
|
|
Future<String?> _replaceSafFileVia({
|
|
required String uri,
|
|
required String treeUri,
|
|
required String relativeDir,
|
|
required SafFileOperation op,
|
|
bool avoidOverwrite = false,
|
|
String preservedSuffix = '',
|
|
String? collisionCleanFileName,
|
|
String? collisionVariantFileName,
|
|
void Function(String fileName)? onPublishedFileName,
|
|
}) =>
|
|
DownloadSafFileReplacer(
|
|
copyToTemp: _copySafToTemp,
|
|
deleteSource: _deleteSafFile,
|
|
publish:
|
|
({
|
|
required treeUri,
|
|
required relativeDir,
|
|
required fileName,
|
|
required srcPath,
|
|
required avoidOverwrite,
|
|
required preservedSuffix,
|
|
collisionCleanFileName,
|
|
collisionVariantFileName,
|
|
}) async {
|
|
final dotIndex = fileName.lastIndexOf('.');
|
|
final ext = dotIndex >= 0 ? fileName.substring(dotIndex) : '';
|
|
if (collisionCleanFileName != null &&
|
|
collisionVariantFileName != null) {
|
|
return _writeTempToSafCollisionAware(
|
|
treeUri: treeUri,
|
|
relativeDir: relativeDir,
|
|
cleanFileName: collisionCleanFileName,
|
|
variantFileName: collisionVariantFileName,
|
|
mimeType: _mimeTypeForExt(ext),
|
|
srcPath: srcPath,
|
|
preservedSuffix: preservedSuffix,
|
|
);
|
|
} else if (avoidOverwrite) {
|
|
return _writeTempToSafUnique(
|
|
treeUri: treeUri,
|
|
relativeDir: relativeDir,
|
|
fileName: fileName,
|
|
mimeType: _mimeTypeForExt(ext),
|
|
srcPath: srcPath,
|
|
preservedSuffix: preservedSuffix,
|
|
);
|
|
}
|
|
final newUri = await _writeTempToSaf(
|
|
treeUri: treeUri,
|
|
relativeDir: relativeDir,
|
|
fileName: fileName,
|
|
mimeType: _mimeTypeForExt(ext),
|
|
srcPath: srcPath,
|
|
);
|
|
return newUri == null ? null : (uri: newUri, fileName: fileName);
|
|
},
|
|
).replace(
|
|
uri: uri,
|
|
treeUri: treeUri,
|
|
relativeDir: relativeDir,
|
|
op: op,
|
|
avoidOverwrite: avoidOverwrite,
|
|
preservedSuffix: preservedSuffix,
|
|
collisionCleanFileName: collisionCleanFileName,
|
|
collisionVariantFileName: collisionVariantFileName,
|
|
onPublishedFileName: onPublishedFileName,
|
|
);
|
|
|
|
Future<_QualityVariantFileOutcome> _finalizeQualityVariantFilename({
|
|
required DownloadItem item,
|
|
required Map<String, dynamic> result,
|
|
required String filePath,
|
|
required String storageMode,
|
|
String? downloadTreeUri,
|
|
String? safRelativeDir,
|
|
String? fileName,
|
|
bool collisionOnly = false,
|
|
}) async {
|
|
if (!item.preserveQualityVariant || result['already_exists'] == true) {
|
|
return _QualityVariantFileOutcome(filePath: filePath, fileName: fileName);
|
|
}
|
|
|
|
Map<String, dynamic>? metadata;
|
|
try {
|
|
metadata = await PlatformBridge.readFileMetadata(filePath);
|
|
if (metadata['error'] != null) metadata = null;
|
|
} catch (e) {
|
|
_log.d('Quality variant metadata probe failed for $filePath: $e');
|
|
}
|
|
|
|
final bitDepth = readPositiveInt(
|
|
metadata?['bit_depth'] ?? result['actual_bit_depth'],
|
|
);
|
|
final sampleRate = readPositiveInt(
|
|
metadata?['sample_rate'] ?? result['actual_sample_rate'],
|
|
);
|
|
final detectedFormat =
|
|
normalizeAudioFormatValue(
|
|
metadata?['audio_codec']?.toString() ??
|
|
metadata?['codec']?.toString() ??
|
|
metadata?['format']?.toString(),
|
|
) ??
|
|
normalizeAudioFormatValue(
|
|
result['audio_codec']?.toString() ?? result['format']?.toString(),
|
|
) ??
|
|
normalizeAudioFormatValue(
|
|
audioFormatForPath(filePath, fileName: fileName),
|
|
);
|
|
final bitrateKbps = readPositiveBitrateKbps(
|
|
metadata?['bitrate'] ??
|
|
metadata?['bit_rate'] ??
|
|
result['bitrate'] ??
|
|
result['actual_bitrate'],
|
|
);
|
|
final qualityLabel = buildQualityVariantFilenameLabel(
|
|
detectedFormat: detectedFormat,
|
|
bitDepth: bitDepth,
|
|
sampleRate: sampleRate,
|
|
bitrateKbps: bitrateKbps,
|
|
measuredQuality:
|
|
result['_native_actual_quality']?.toString() ??
|
|
result['quality']?.toString(),
|
|
);
|
|
if (qualityLabel == null) {
|
|
_log.w(
|
|
'Keeping collision-safe temporary quality label because the final '
|
|
'audio specification could not be measured: $filePath',
|
|
);
|
|
return _QualityVariantFileOutcome(
|
|
filePath: filePath,
|
|
fileName: fileName,
|
|
metadata: metadata,
|
|
);
|
|
}
|
|
|
|
if (bitDepth != null) result['actual_bit_depth'] = bitDepth;
|
|
if (sampleRate != null) result['actual_sample_rate'] = sampleRate;
|
|
if (detectedFormat != null) result['audio_codec'] = detectedFormat;
|
|
if (bitrateKbps != null) {
|
|
result['bitrate'] = bitrateKbps;
|
|
}
|
|
|
|
final stagingLabel = qualityVariantStagingLabel(item.id);
|
|
final localPathSegments = File(filePath).uri.pathSegments;
|
|
final currentFileName = storageMode == 'saf'
|
|
? (fileName ?? result['file_name']?.toString() ?? '')
|
|
: (localPathSegments.isEmpty ? '' : localPathSegments.last);
|
|
final variantFileName = applyQualityVariantFilenameLabel(
|
|
fileName: currentFileName,
|
|
stagingLabel: stagingLabel,
|
|
qualityLabel: qualityLabel,
|
|
);
|
|
final cleanFileName = removeQualityVariantStagingLabel(
|
|
fileName: currentFileName,
|
|
stagingLabel: stagingLabel,
|
|
);
|
|
final preferredFileName = variantFileName;
|
|
if (preferredFileName == currentFileName) {
|
|
return _QualityVariantFileOutcome(
|
|
filePath: filePath,
|
|
fileName: fileName,
|
|
metadata: metadata,
|
|
);
|
|
}
|
|
|
|
if (storageMode == 'saf' && isContentUri(filePath)) {
|
|
if (downloadTreeUri == null || downloadTreeUri.isEmpty) {
|
|
return _QualityVariantFileOutcome(
|
|
filePath: filePath,
|
|
fileName: fileName,
|
|
metadata: metadata,
|
|
);
|
|
}
|
|
String? publishedFileName;
|
|
final renamedUri = await _replaceSafFileVia(
|
|
uri: filePath,
|
|
treeUri: downloadTreeUri,
|
|
relativeDir: safRelativeDir ?? '',
|
|
avoidOverwrite: !collisionOnly,
|
|
preservedSuffix: qualityLabel,
|
|
collisionCleanFileName: collisionOnly ? cleanFileName : null,
|
|
collisionVariantFileName: collisionOnly ? variantFileName : null,
|
|
onPublishedFileName: (name) => publishedFileName = name,
|
|
op: (tempPath, addCleanup) async => (tempPath, preferredFileName),
|
|
);
|
|
if (renamedUri == null) {
|
|
return _QualityVariantFileOutcome(
|
|
filePath: filePath,
|
|
fileName: fileName,
|
|
metadata: metadata,
|
|
);
|
|
}
|
|
final finalName = publishedFileName ?? preferredFileName;
|
|
result['file_path'] = renamedUri;
|
|
result['file_name'] = finalName;
|
|
return _QualityVariantFileOutcome(
|
|
filePath: renamedUri,
|
|
fileName: finalName,
|
|
metadata: metadata,
|
|
);
|
|
}
|
|
|
|
final source = File(filePath);
|
|
final parent = source.parent;
|
|
final cleanTarget = File(
|
|
'${parent.path}${Platform.pathSeparator}$cleanFileName',
|
|
);
|
|
final lockTarget = collisionOnly
|
|
? cleanTarget
|
|
: File('${parent.path}${Platform.pathSeparator}$preferredFileName');
|
|
return _withQualityVariantFileLock(lockTarget.path, () async {
|
|
final selectedFileName = collisionOnly
|
|
? resolveQualityVariantFilename(
|
|
fileName: currentFileName,
|
|
stagingLabel: stagingLabel,
|
|
qualityLabel: qualityLabel,
|
|
collisionOnly: true,
|
|
cleanNameExists:
|
|
cleanTarget.path != source.path && await cleanTarget.exists(),
|
|
)
|
|
: preferredFileName;
|
|
if (selectedFileName == currentFileName) {
|
|
return _QualityVariantFileOutcome(
|
|
filePath: filePath,
|
|
fileName: fileName,
|
|
metadata: metadata,
|
|
);
|
|
}
|
|
|
|
var target = File(
|
|
'${parent.path}${Platform.pathSeparator}$selectedFileName',
|
|
);
|
|
var counter = 2;
|
|
while (await target.exists() && target.path != source.path) {
|
|
final dotIndex = selectedFileName.lastIndexOf('.');
|
|
final hasExtension = dotIndex > 0;
|
|
final stem = hasExtension
|
|
? selectedFileName.substring(0, dotIndex)
|
|
: selectedFileName;
|
|
final extension = hasExtension
|
|
? selectedFileName.substring(dotIndex)
|
|
: '';
|
|
target = File(
|
|
'${parent.path}${Platform.pathSeparator}$stem ($counter)$extension',
|
|
);
|
|
counter++;
|
|
}
|
|
try {
|
|
final renamed = await source.rename(target.path);
|
|
result['file_path'] = renamed.path;
|
|
final renamedSegments = renamed.uri.pathSegments;
|
|
result['file_name'] = renamedSegments.isEmpty
|
|
? null
|
|
: renamedSegments.last;
|
|
return _QualityVariantFileOutcome(
|
|
filePath: renamed.path,
|
|
fileName: result['file_name'] as String?,
|
|
metadata: metadata,
|
|
);
|
|
} catch (e) {
|
|
_log.w('Failed to apply measured quality filename: $e');
|
|
return _QualityVariantFileOutcome(
|
|
filePath: filePath,
|
|
fileName: fileName,
|
|
metadata: metadata,
|
|
);
|
|
}
|
|
});
|
|
}
|
|
|
|
Future<String?> _finalizeNativeWorkerContainerConversion({
|
|
required _NativeWorkerRequestContext context,
|
|
required DownloadResult result,
|
|
required AppSettings settings,
|
|
required Track track,
|
|
required String filePath,
|
|
}) =>
|
|
DownloadContainerFinalizer(
|
|
probeCodec: FFmpegService.probePrimaryAudioCodec,
|
|
isNativeFlac: FFmpegService.isNativeFlacFile,
|
|
ensureFlacExtension: FFmpegService.ensureNativeFlacExtension,
|
|
convertToFlac: FFmpegService.convertM4aToFlac,
|
|
replaceSafFile: _replaceSafFileVia,
|
|
debug: _log.d,
|
|
).finalize(
|
|
result: result,
|
|
filePath: filePath,
|
|
requestedExtension: context.outputExt,
|
|
forceConversion: _shouldRequestContainerConversion(
|
|
context.item.service,
|
|
context.outputExt,
|
|
),
|
|
useSaf: context.storageMode == 'saf',
|
|
treeUri: context.downloadTreeUri,
|
|
relativeDir: context.safRelativeDir ?? '',
|
|
fallbackFileName: context.safFileName,
|
|
embedMetadata: settings.embedMetadata
|
|
? (path) async {
|
|
await _embedMetadataToFile(
|
|
path,
|
|
track,
|
|
format: 'flac',
|
|
genre: result['genre'] as String?,
|
|
label: result['label'] as String?,
|
|
copyright: result['copyright'] as String?,
|
|
comment: result['comment'] as String?,
|
|
lyricsLrc: result['lyrics_lrc'] as String?,
|
|
downloadService: context.item.service,
|
|
writeExternalLrc: context.storageMode != 'saf',
|
|
);
|
|
}
|
|
: null,
|
|
);
|
|
|
|
/// 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<bool> _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 &&
|
|
!DownloadMetadataResolver.shouldSkipLyrics(
|
|
extensionState,
|
|
track.source,
|
|
service,
|
|
) &&
|
|
(lyricsMode == 'external' || lyricsMode == 'both');
|
|
if (!shouldSaveExternalLrc) {
|
|
return false;
|
|
}
|
|
|
|
String? lrcContent = result['lyrics_lrc'] as String?;
|
|
if (!hasUsableLyricsContent(lrcContent ?? '')) {
|
|
try {
|
|
lrcContent = await PlatformBridge.getLyricsLRC(
|
|
track.id,
|
|
track.name,
|
|
track.artistName,
|
|
durationMs: track.duration * 1000,
|
|
);
|
|
} catch (e) {
|
|
onFetchError(e);
|
|
}
|
|
}
|
|
if (!hasUsableLyricsContent(lrcContent ?? '') ||
|
|
isInstrumentalLyricsMarker(lrcContent!)) {
|
|
return false;
|
|
}
|
|
final resolvedLrc = lrcContent;
|
|
|
|
if (storageMode == 'saf' && isContentUri(filePath)) {
|
|
if (downloadTreeUri == null || downloadTreeUri.isEmpty) {
|
|
return false;
|
|
}
|
|
final baseName = await resolveBaseName();
|
|
final written = await _writeLrcToSaf(
|
|
treeUri: downloadTreeUri,
|
|
relativeDir: safRelativeDir,
|
|
baseName: baseName,
|
|
lrcContent: resolvedLrc,
|
|
);
|
|
if (written) result['lyrics_lrc'] = resolvedLrc;
|
|
return written;
|
|
}
|
|
|
|
try {
|
|
final lrcPath = filePath.replaceAll(RegExp(r'\.[^.]+$'), '.lrc');
|
|
final safeLrcPath = lrcPath == filePath ? '$filePath.lrc' : lrcPath;
|
|
await File(safeLrcPath).writeAsString(resolvedLrc);
|
|
result['lyrics_lrc'] = resolvedLrc;
|
|
_log.d('Native-worker external LRC saved: $safeLrcPath');
|
|
return true;
|
|
} catch (e) {
|
|
_log.w('Failed to save native-worker external LRC: $e');
|
|
return false;
|
|
}
|
|
}
|
|
}
|