mirror of
https://github.com/zarzet/SpotiFLAC-Mobile.git
synced 2026-10-21 15:02:21 +02:00
refactor(convert): route single-track conversion through the shared pipeline
The track metadata screen's _performConversion re-implemented the batch conversion pipeline step by step (metadata merge, lyrics, cover temp file, SAF temp copy, FFmpeg, SAF publish, history or library update, temp cleanup). Both now call convertLibraryTrack in the batch controller, which returns a LibraryTrackConversionResult that each caller maps to its own reporting: batch failure summaries and log lines, or the screen's existing snackbars. Caller differences stay as parameters: the cover temp prefix (batch_cover_ or convert_cover_), whether an empty SAF temp path fails, the history SAF file name fallback, the screen's eager temp deletes, its provider reload right after the row is written, its warnings, its cleanup log text and its lazy artistTagMode read. One deliberate difference: the screen now reads the track path, item and audio fields once before the first await instead of re-reading widget state after each await. Swiping to another track mid-conversion previously published and recorded the output against the newly shown track; it now stays on the original.
This commit is contained in:
1 parent
5b3a2b9575
commit
c1c7d421cc
3 files changed
+406
-515
No files matched your search
@@ -37,6 +37,32 @@ class _BatchConversionFailure implements Exception {
|
||||
String toString() => '$stage: $message';
|
||||
}
|
||||
|
||||
/// Outcome of [convertLibraryTrack]: success, a checked pipeline failure
|
||||
/// (no SAF copy, no converter output, no SAF destination), or a thrown error.
|
||||
class LibraryTrackConversionResult {
|
||||
const LibraryTrackConversionResult._({
|
||||
this.failureStage,
|
||||
this.error,
|
||||
this.stackTrace,
|
||||
});
|
||||
|
||||
const LibraryTrackConversionResult.success() : this._();
|
||||
|
||||
/// Pipeline stage that was running when the conversion failed.
|
||||
final String? failureStage;
|
||||
final Object? error;
|
||||
final StackTrace? stackTrace;
|
||||
|
||||
bool get succeeded => error == null;
|
||||
|
||||
/// Whether the failure is a checked pipeline outcome, not a thrown error.
|
||||
bool get isPipelineFailure => error is _BatchConversionFailure;
|
||||
|
||||
/// `stage: message` summary of the failure.
|
||||
String get failureDetail =>
|
||||
isPipelineFailure ? error.toString() : '$failureStage: $error';
|
||||
}
|
||||
|
||||
/// Shows the batch format-conversion sheet for [selectedItems] and runs the
|
||||
/// conversion when confirmed. Handles both history-backed and local-backed
|
||||
/// items, including SAF-hosted files.
|
||||
@@ -244,276 +270,51 @@ Future<void> _performBatchConversion(
|
||||
|
||||
BatchProgressDialog.update(current: i + 1, detail: item.trackName);
|
||||
|
||||
String failureStage = 'metadata';
|
||||
String? coverPath;
|
||||
String? safTempPath;
|
||||
String? convertedSafTempPath;
|
||||
try {
|
||||
final metadata = <String, String>{
|
||||
final result = await convertLibraryTrack(
|
||||
filePath: item.filePath,
|
||||
metadata: <String, String>{
|
||||
'TITLE': item.trackName,
|
||||
'ARTIST': item.artistName,
|
||||
'ALBUM': item.albumName,
|
||||
if (item.historyItem?.explicit == true ||
|
||||
item.localItem?.explicit == true)
|
||||
'ITUNESADVISORY': '1',
|
||||
};
|
||||
try {
|
||||
final result = await PlatformBridge.readFileMetadata(item.filePath);
|
||||
if (result['error'] == null) {
|
||||
mergePlatformMetadataForTagEmbed(target: metadata, source: result);
|
||||
}
|
||||
} catch (_) {}
|
||||
await ensureLyricsMetadataForConversion(
|
||||
metadata: metadata,
|
||||
sourcePath: item.filePath,
|
||||
shouldEmbedLyrics: shouldEmbedLyrics,
|
||||
trackName: item.trackName,
|
||||
artistName: item.artistName,
|
||||
spotifyId: item.historyItem?.spotifyId ?? '',
|
||||
durationMs:
|
||||
((item.historyItem?.duration ?? item.localItem?.duration) ?? 0) *
|
||||
1000,
|
||||
);
|
||||
|
||||
try {
|
||||
final tempDir = await getTemporaryDirectory();
|
||||
final coverOutput =
|
||||
'${tempDir.path}${Platform.pathSeparator}batch_cover_${DateTime.now().millisecondsSinceEpoch}.jpg';
|
||||
final coverResult = await PlatformBridge.extractCoverToFile(
|
||||
item.filePath,
|
||||
coverOutput,
|
||||
);
|
||||
if (coverResult['error'] == null) {
|
||||
coverPath = coverOutput;
|
||||
}
|
||||
} catch (_) {}
|
||||
|
||||
String workingPath = item.filePath;
|
||||
final isSaf = isContentUri(item.filePath);
|
||||
|
||||
if (isSaf) {
|
||||
failureStage = 'read SAF source';
|
||||
safTempPath = await ConversionLibraryService.copySafSourceToTemp(
|
||||
item.filePath,
|
||||
);
|
||||
if (safTempPath == null || safTempPath.isEmpty) {
|
||||
throw const _BatchConversionFailure(
|
||||
'read SAF source',
|
||||
'temporary copy was not created',
|
||||
);
|
||||
}
|
||||
workingPath = safTempPath;
|
||||
}
|
||||
|
||||
failureStage = 'FFmpeg conversion';
|
||||
final newPath = await FFmpegService.convertAudioFormat(
|
||||
inputPath: workingPath,
|
||||
targetFormat: targetFormat.toLowerCase(),
|
||||
bitrate: bitrate,
|
||||
metadata: metadata,
|
||||
coverPath: coverPath,
|
||||
artistTagMode: settings.artistTagMode,
|
||||
deleteOriginal: !isSaf && !keepOriginal,
|
||||
sourceBitDepth: item.historyItem?.bitDepth ?? item.localItem?.bitDepth,
|
||||
losslessQuality: losslessQuality,
|
||||
losslessProcessing: losslessProcessing,
|
||||
);
|
||||
|
||||
if (newPath == null) {
|
||||
throw const _BatchConversionFailure(
|
||||
'FFmpeg conversion',
|
||||
'converter returned no output',
|
||||
);
|
||||
}
|
||||
if (isSaf) convertedSafTempPath = newPath;
|
||||
|
||||
final sourceBitDepth =
|
||||
item.historyItem?.bitDepth ?? item.localItem?.bitDepth;
|
||||
final sourceSampleRate =
|
||||
item.historyItem?.sampleRate ?? item.localItem?.sampleRate;
|
||||
final isLosslessOutput = isLosslessConversionTarget(targetFormat);
|
||||
int? convertedBitDepth;
|
||||
int? convertedSampleRate;
|
||||
if (isLosslessOutput) {
|
||||
try {
|
||||
final convertedMetadata = await PlatformBridge.readFileMetadata(
|
||||
newPath,
|
||||
);
|
||||
if (convertedMetadata['error'] == null) {
|
||||
convertedBitDepth = readPositiveInt(convertedMetadata['bit_depth']);
|
||||
convertedSampleRate = readPositiveInt(
|
||||
convertedMetadata['sample_rate'],
|
||||
);
|
||||
}
|
||||
} catch (_) {}
|
||||
convertedBitDepth ??= losslessQuality.effectiveBitDepth(sourceBitDepth);
|
||||
convertedSampleRate ??= losslessQuality.effectiveSampleRate(
|
||||
sourceSampleRate,
|
||||
);
|
||||
}
|
||||
final newQuality = convertedAudioQualityLabel(
|
||||
targetFormat: targetFormat,
|
||||
bitrate: bitrate,
|
||||
labels: losslessLabels,
|
||||
losslessQuality: losslessQuality,
|
||||
actualBitDepth: convertedBitDepth,
|
||||
actualSampleRate: convertedSampleRate,
|
||||
);
|
||||
|
||||
if (isSaf && item.historyItem != null) {
|
||||
failureStage = 'publish SAF output';
|
||||
final hi = item.historyItem!;
|
||||
final treeUri = hi.downloadTreeUri;
|
||||
final relativeDir = hi.safRelativeDir ?? '';
|
||||
if (treeUri != null && treeUri.isNotEmpty) {
|
||||
final oldFileName = hi.safFileName ?? '';
|
||||
final published = await ConversionLibraryService.publishSafConversion(
|
||||
treeUri: treeUri,
|
||||
relativeDir: relativeDir,
|
||||
originalFileName: oldFileName,
|
||||
targetFormat: targetFormat,
|
||||
sourcePath: newPath,
|
||||
keepOriginal: keepOriginal,
|
||||
);
|
||||
final safUri = published?.uri;
|
||||
|
||||
if (safUri == null || safUri.isEmpty) {
|
||||
throw const _BatchConversionFailure(
|
||||
'publish SAF output',
|
||||
'destination file was not created',
|
||||
);
|
||||
}
|
||||
|
||||
if (!keepOriginal && !isSameContentUri(item.filePath, safUri)) {
|
||||
try {
|
||||
await PlatformBridge.safDelete(item.filePath);
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
await ConversionLibraryService.persistHistoryConversion(
|
||||
source: hi,
|
||||
newFilePath: safUri,
|
||||
newQuality: newQuality,
|
||||
targetFormat: targetFormat,
|
||||
bitrate: bitrate,
|
||||
bitDepth: convertedBitDepth,
|
||||
sampleRate: convertedSampleRate,
|
||||
keepOriginal: keepOriginal,
|
||||
newSafFileName: published!.fileName,
|
||||
);
|
||||
} else {
|
||||
throw const _BatchConversionFailure(
|
||||
'publish SAF output',
|
||||
'download folder permission is unavailable',
|
||||
);
|
||||
}
|
||||
try {
|
||||
await File(newPath).delete();
|
||||
} catch (_) {}
|
||||
if (safTempPath != null) {
|
||||
try {
|
||||
await File(safTempPath).delete();
|
||||
} catch (_) {}
|
||||
}
|
||||
} else if (isSaf && item.localItem != null) {
|
||||
failureStage = 'publish SAF output';
|
||||
final location = resolveSafDocumentLocation(item.filePath);
|
||||
|
||||
if (location != null) {
|
||||
final published = await ConversionLibraryService.publishSafConversion(
|
||||
treeUri: location.treeUri,
|
||||
relativeDir: location.relativeDir,
|
||||
originalFileName: location.fileName,
|
||||
targetFormat: targetFormat,
|
||||
sourcePath: newPath,
|
||||
keepOriginal: keepOriginal,
|
||||
);
|
||||
final safUri = published?.uri;
|
||||
|
||||
if (safUri == null || safUri.isEmpty) {
|
||||
throw const _BatchConversionFailure(
|
||||
'publish SAF output',
|
||||
'destination file was not created',
|
||||
);
|
||||
}
|
||||
|
||||
if (!keepOriginal && !isSameContentUri(item.filePath, safUri)) {
|
||||
try {
|
||||
await PlatformBridge.safDelete(item.filePath);
|
||||
} catch (_) {}
|
||||
}
|
||||
await LibraryDatabase.instance.replaceWithConvertedItem(
|
||||
item: item.localItem!,
|
||||
newFilePath: safUri,
|
||||
targetFormat: targetFormat,
|
||||
bitrate: bitrate,
|
||||
bitDepth: convertedBitDepth,
|
||||
sampleRate: convertedSampleRate,
|
||||
keepOriginal: keepOriginal,
|
||||
);
|
||||
} else {
|
||||
throw const _BatchConversionFailure(
|
||||
'publish SAF output',
|
||||
'source document location could not be resolved',
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
await File(newPath).delete();
|
||||
} catch (_) {}
|
||||
if (safTempPath != null) {
|
||||
try {
|
||||
await File(safTempPath).delete();
|
||||
} catch (_) {}
|
||||
}
|
||||
} else if (item.historyItem != null) {
|
||||
await ConversionLibraryService.persistHistoryConversion(
|
||||
source: item.historyItem!,
|
||||
newFilePath: newPath,
|
||||
newQuality: newQuality,
|
||||
targetFormat: targetFormat,
|
||||
bitrate: bitrate,
|
||||
bitDepth: convertedBitDepth,
|
||||
sampleRate: convertedSampleRate,
|
||||
keepOriginal: keepOriginal,
|
||||
);
|
||||
} else if (item.localItem != null) {
|
||||
await LibraryDatabase.instance.replaceWithConvertedItem(
|
||||
item: item.localItem!,
|
||||
newFilePath: newPath,
|
||||
targetFormat: targetFormat,
|
||||
bitrate: bitrate,
|
||||
bitDepth: convertedBitDepth,
|
||||
sampleRate: convertedSampleRate,
|
||||
keepOriginal: keepOriginal,
|
||||
);
|
||||
}
|
||||
},
|
||||
trackName: item.trackName,
|
||||
artistName: item.artistName,
|
||||
spotifyId: item.historyItem?.spotifyId ?? '',
|
||||
durationMs:
|
||||
((item.historyItem?.duration ?? item.localItem?.duration) ?? 0) *
|
||||
1000,
|
||||
sourceBitDepth: item.historyItem?.bitDepth ?? item.localItem?.bitDepth,
|
||||
sourceSampleRate:
|
||||
item.historyItem?.sampleRate ?? item.localItem?.sampleRate,
|
||||
historyItem: item.historyItem,
|
||||
localItem: item.localItem,
|
||||
targetFormat: targetFormat,
|
||||
bitrate: bitrate,
|
||||
losslessQuality: losslessQuality,
|
||||
losslessProcessing: losslessProcessing,
|
||||
keepOriginal: keepOriginal,
|
||||
shouldEmbedLyrics: shouldEmbedLyrics,
|
||||
readArtistTagMode: () => settings.artistTagMode,
|
||||
losslessLabels: losslessLabels,
|
||||
coverTempPrefix: 'batch_cover_',
|
||||
treatEmptySafTempPathAsFailure: true,
|
||||
onCleanupFailed: (path, error) =>
|
||||
_batchActionsLog.w('Failed to clean temporary file $path: $error'),
|
||||
);
|
||||
|
||||
if (result.succeeded) {
|
||||
successCount++;
|
||||
} catch (error, stackTrace) {
|
||||
final detail = error is _BatchConversionFailure
|
||||
? error.toString()
|
||||
: '$failureStage: $error';
|
||||
} else {
|
||||
final detail = result.failureDetail;
|
||||
failures.add('${item.trackName} — $detail');
|
||||
_batchActionsLog.e(
|
||||
'Batch conversion failed for ${item.trackName} at $failureStage',
|
||||
error,
|
||||
stackTrace,
|
||||
'Batch conversion failed for ${item.trackName} at ${result.failureStage}',
|
||||
result.error,
|
||||
result.stackTrace,
|
||||
);
|
||||
} finally {
|
||||
for (final path in <String?>[
|
||||
coverPath,
|
||||
safTempPath,
|
||||
convertedSafTempPath,
|
||||
]) {
|
||||
if (path == null || path.isEmpty) continue;
|
||||
try {
|
||||
final file = File(path);
|
||||
if (await file.exists()) await file.delete();
|
||||
} catch (error) {
|
||||
_batchActionsLog.w('Failed to clean temporary file $path: $error');
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -538,6 +339,321 @@ Future<void> _performBatchConversion(
|
||||
}
|
||||
}
|
||||
|
||||
/// Converts the track at [filePath] to [targetFormat], publishes SAF output
|
||||
/// and records the result on [historyItem] or [localItem] (with neither, the
|
||||
/// converted file is not recorded). Temporary files are removed before this
|
||||
/// returns. Shared by batch conversion and the track metadata screen.
|
||||
///
|
||||
/// [metadata] holds the caller's fallback tags and is completed from the
|
||||
/// file. [readArtistTagMode] is read when FFmpeg starts.
|
||||
/// [coverTempPrefix] names the extracted cover in the temp directory.
|
||||
/// [treatEmptySafTempPathAsFailure] also fails an empty SAF temp copy path.
|
||||
/// [historySafFileNameFallback] is the original SAF file name used when
|
||||
/// [historyItem] has none. [eagerTempCleanup] deletes the cover right after
|
||||
/// FFmpeg and the temp files before each checked failure, not only at the
|
||||
/// end. [onPersisted] runs right after the history or library row is written.
|
||||
/// [diagnosticsLog] receives metadata fallback and SAF original delete
|
||||
/// warnings. [onCleanupFailed] reports a temp file that could not be deleted.
|
||||
Future<LibraryTrackConversionResult> convertLibraryTrack({
|
||||
required String filePath,
|
||||
required Map<String, String> metadata,
|
||||
required String trackName,
|
||||
required String artistName,
|
||||
required String spotifyId,
|
||||
required int durationMs,
|
||||
required int? sourceBitDepth,
|
||||
required int? sourceSampleRate,
|
||||
required DownloadHistoryItem? historyItem,
|
||||
required LocalLibraryItem? localItem,
|
||||
required String targetFormat,
|
||||
required String bitrate,
|
||||
required LosslessConversionQuality losslessQuality,
|
||||
required LosslessConversionProcessing losslessProcessing,
|
||||
required bool keepOriginal,
|
||||
required bool shouldEmbedLyrics,
|
||||
required String Function() readArtistTagMode,
|
||||
required LosslessConversionLabels losslessLabels,
|
||||
required String coverTempPrefix,
|
||||
required bool treatEmptySafTempPathAsFailure,
|
||||
String historySafFileNameFallback = '',
|
||||
bool eagerTempCleanup = false,
|
||||
Future<void> Function()? onPersisted,
|
||||
AppLogger? diagnosticsLog,
|
||||
required void Function(String path, Object error) onCleanupFailed,
|
||||
}) async {
|
||||
String failureStage = 'metadata';
|
||||
String? coverPath;
|
||||
String? safTempPath;
|
||||
String? convertedSafTempPath;
|
||||
|
||||
Future<void> cleanUpEagerly(List<String?> paths) async {
|
||||
if (!eagerTempCleanup) return;
|
||||
for (final path in paths) {
|
||||
if (path != null) await _deleteFileQuietly(path);
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
try {
|
||||
final result = await PlatformBridge.readFileMetadata(filePath);
|
||||
if (result['error'] == null) {
|
||||
mergePlatformMetadataForTagEmbed(target: metadata, source: result);
|
||||
} else {
|
||||
diagnosticsLog?.w(
|
||||
'readFileMetadata returned error, using fallback metadata',
|
||||
);
|
||||
}
|
||||
} catch (e) {
|
||||
diagnosticsLog?.w('readFileMetadata threw, using fallback metadata: $e');
|
||||
}
|
||||
await ensureLyricsMetadataForConversion(
|
||||
metadata: metadata,
|
||||
sourcePath: filePath,
|
||||
shouldEmbedLyrics: shouldEmbedLyrics,
|
||||
trackName: trackName,
|
||||
artistName: artistName,
|
||||
spotifyId: spotifyId,
|
||||
durationMs: durationMs,
|
||||
);
|
||||
|
||||
try {
|
||||
final tempDir = await getTemporaryDirectory();
|
||||
final coverOutput =
|
||||
'${tempDir.path}${Platform.pathSeparator}$coverTempPrefix${DateTime.now().millisecondsSinceEpoch}.jpg';
|
||||
final coverResult = await PlatformBridge.extractCoverToFile(
|
||||
filePath,
|
||||
coverOutput,
|
||||
);
|
||||
if (coverResult['error'] == null) {
|
||||
coverPath = coverOutput;
|
||||
}
|
||||
} catch (_) {}
|
||||
|
||||
String workingPath = filePath;
|
||||
final isSaf = isContentUri(filePath);
|
||||
|
||||
if (isSaf) {
|
||||
failureStage = 'read SAF source';
|
||||
safTempPath = await ConversionLibraryService.copySafSourceToTemp(
|
||||
filePath,
|
||||
);
|
||||
if (safTempPath == null ||
|
||||
(treatEmptySafTempPathAsFailure && safTempPath.isEmpty)) {
|
||||
throw const _BatchConversionFailure(
|
||||
'read SAF source',
|
||||
'temporary copy was not created',
|
||||
);
|
||||
}
|
||||
workingPath = safTempPath;
|
||||
}
|
||||
|
||||
failureStage = 'FFmpeg conversion';
|
||||
final newPath = await FFmpegService.convertAudioFormat(
|
||||
inputPath: workingPath,
|
||||
targetFormat: targetFormat.toLowerCase(),
|
||||
bitrate: bitrate,
|
||||
metadata: metadata,
|
||||
coverPath: coverPath,
|
||||
artistTagMode: readArtistTagMode(),
|
||||
deleteOriginal: !isSaf && !keepOriginal,
|
||||
sourceBitDepth: sourceBitDepth,
|
||||
losslessQuality: losslessQuality,
|
||||
losslessProcessing: losslessProcessing,
|
||||
);
|
||||
await cleanUpEagerly([coverPath]);
|
||||
|
||||
if (newPath == null) {
|
||||
await cleanUpEagerly([safTempPath]);
|
||||
throw const _BatchConversionFailure(
|
||||
'FFmpeg conversion',
|
||||
'converter returned no output',
|
||||
);
|
||||
}
|
||||
if (isSaf) convertedSafTempPath = newPath;
|
||||
|
||||
final isLosslessOutput = isLosslessConversionTarget(targetFormat);
|
||||
int? convertedBitDepth;
|
||||
int? convertedSampleRate;
|
||||
if (isLosslessOutput) {
|
||||
try {
|
||||
final convertedMetadata = await PlatformBridge.readFileMetadata(
|
||||
newPath,
|
||||
);
|
||||
if (convertedMetadata['error'] == null) {
|
||||
convertedBitDepth = readPositiveInt(convertedMetadata['bit_depth']);
|
||||
convertedSampleRate = readPositiveInt(
|
||||
convertedMetadata['sample_rate'],
|
||||
);
|
||||
}
|
||||
} catch (_) {}
|
||||
convertedBitDepth ??= losslessQuality.effectiveBitDepth(sourceBitDepth);
|
||||
convertedSampleRate ??= losslessQuality.effectiveSampleRate(
|
||||
sourceSampleRate,
|
||||
);
|
||||
}
|
||||
final newQuality = convertedAudioQualityLabel(
|
||||
targetFormat: targetFormat,
|
||||
bitrate: bitrate,
|
||||
labels: losslessLabels,
|
||||
losslessQuality: losslessQuality,
|
||||
actualBitDepth: convertedBitDepth,
|
||||
actualSampleRate: convertedSampleRate,
|
||||
);
|
||||
|
||||
Future<({String uri, String fileName})> publishSafOutput({
|
||||
required String treeUri,
|
||||
required String relativeDir,
|
||||
required String originalFileName,
|
||||
}) async {
|
||||
final published = await ConversionLibraryService.publishSafConversion(
|
||||
treeUri: treeUri,
|
||||
relativeDir: relativeDir,
|
||||
originalFileName: originalFileName,
|
||||
targetFormat: targetFormat,
|
||||
sourcePath: newPath,
|
||||
keepOriginal: keepOriginal,
|
||||
);
|
||||
final safUri = published?.uri;
|
||||
|
||||
if (safUri == null || safUri.isEmpty) {
|
||||
await cleanUpEagerly([newPath, safTempPath]);
|
||||
throw const _BatchConversionFailure(
|
||||
'publish SAF output',
|
||||
'destination file was not created',
|
||||
);
|
||||
}
|
||||
|
||||
if (!keepOriginal && !isSameContentUri(filePath, safUri)) {
|
||||
final deletedOriginal = await PlatformBridge.safDelete(
|
||||
filePath,
|
||||
).catchError((_) => false);
|
||||
if (deletedOriginal != true) {
|
||||
diagnosticsLog?.w(
|
||||
'Converted SAF file created but failed deleting original URI',
|
||||
);
|
||||
}
|
||||
}
|
||||
return published!;
|
||||
}
|
||||
|
||||
if (isSaf && historyItem != null) {
|
||||
failureStage = 'publish SAF output';
|
||||
final treeUri = historyItem.downloadTreeUri;
|
||||
if (treeUri != null && treeUri.isNotEmpty) {
|
||||
final safFileName = historyItem.safFileName;
|
||||
final published = await publishSafOutput(
|
||||
treeUri: treeUri,
|
||||
relativeDir: historyItem.safRelativeDir ?? '',
|
||||
originalFileName: safFileName != null && safFileName.isNotEmpty
|
||||
? safFileName
|
||||
: historySafFileNameFallback,
|
||||
);
|
||||
await ConversionLibraryService.persistHistoryConversion(
|
||||
source: historyItem,
|
||||
newFilePath: published.uri,
|
||||
newQuality: newQuality,
|
||||
targetFormat: targetFormat,
|
||||
bitrate: bitrate,
|
||||
bitDepth: convertedBitDepth,
|
||||
sampleRate: convertedSampleRate,
|
||||
keepOriginal: keepOriginal,
|
||||
newSafFileName: published.fileName,
|
||||
);
|
||||
await onPersisted?.call();
|
||||
} else {
|
||||
await cleanUpEagerly([newPath, safTempPath]);
|
||||
throw const _BatchConversionFailure(
|
||||
'publish SAF output',
|
||||
'download folder permission is unavailable',
|
||||
);
|
||||
}
|
||||
await _deleteFileQuietly(newPath);
|
||||
if (safTempPath != null) await _deleteFileQuietly(safTempPath);
|
||||
} else if (isSaf && localItem != null) {
|
||||
failureStage = 'publish SAF output';
|
||||
final location = resolveSafDocumentLocation(filePath);
|
||||
|
||||
if (location != null) {
|
||||
final published = await publishSafOutput(
|
||||
treeUri: location.treeUri,
|
||||
relativeDir: location.relativeDir,
|
||||
originalFileName: location.fileName,
|
||||
);
|
||||
await LibraryDatabase.instance.replaceWithConvertedItem(
|
||||
item: localItem,
|
||||
newFilePath: published.uri,
|
||||
targetFormat: targetFormat,
|
||||
bitrate: bitrate,
|
||||
bitDepth: convertedBitDepth,
|
||||
sampleRate: convertedSampleRate,
|
||||
keepOriginal: keepOriginal,
|
||||
);
|
||||
await onPersisted?.call();
|
||||
} else {
|
||||
await cleanUpEagerly([newPath, safTempPath]);
|
||||
throw const _BatchConversionFailure(
|
||||
'publish SAF output',
|
||||
'source document location could not be resolved',
|
||||
);
|
||||
}
|
||||
|
||||
await _deleteFileQuietly(newPath);
|
||||
if (safTempPath != null) await _deleteFileQuietly(safTempPath);
|
||||
} else if (historyItem != null) {
|
||||
await ConversionLibraryService.persistHistoryConversion(
|
||||
source: historyItem,
|
||||
newFilePath: newPath,
|
||||
newQuality: newQuality,
|
||||
targetFormat: targetFormat,
|
||||
bitrate: bitrate,
|
||||
bitDepth: convertedBitDepth,
|
||||
sampleRate: convertedSampleRate,
|
||||
keepOriginal: keepOriginal,
|
||||
);
|
||||
await onPersisted?.call();
|
||||
} else if (localItem != null) {
|
||||
await LibraryDatabase.instance.replaceWithConvertedItem(
|
||||
item: localItem,
|
||||
newFilePath: newPath,
|
||||
targetFormat: targetFormat,
|
||||
bitrate: bitrate,
|
||||
bitDepth: convertedBitDepth,
|
||||
sampleRate: convertedSampleRate,
|
||||
keepOriginal: keepOriginal,
|
||||
);
|
||||
await onPersisted?.call();
|
||||
}
|
||||
|
||||
return const LibraryTrackConversionResult.success();
|
||||
} catch (error, stackTrace) {
|
||||
return LibraryTrackConversionResult._(
|
||||
failureStage: failureStage,
|
||||
error: error,
|
||||
stackTrace: stackTrace,
|
||||
);
|
||||
} finally {
|
||||
for (final path in <String?>[
|
||||
coverPath,
|
||||
safTempPath,
|
||||
convertedSafTempPath,
|
||||
]) {
|
||||
if (path == null || path.isEmpty) continue;
|
||||
try {
|
||||
final file = File(path);
|
||||
if (await file.exists()) await file.delete();
|
||||
} catch (error) {
|
||||
onCleanupFailed(path, error);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _deleteFileQuietly(String path) async {
|
||||
try {
|
||||
await File(path).delete();
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
/// Adds or removes ReplayGain tags for [selectedItems].
|
||||
///
|
||||
/// [onConfirmOpen] / [onConfirmClosed] let the caller hide and restore any
|
||||
|
||||
@@ -812,9 +812,6 @@ extension _TrackMetadataConvertAndCueSplit on _TrackMetadataScreenState {
|
||||
if (_isConverting) return;
|
||||
_setState(() => _isConverting = true);
|
||||
final losslessLabels = context.l10n.losslessConversionLabels;
|
||||
String? coverPath;
|
||||
String? safTempPath;
|
||||
String? convertedSafTempPath;
|
||||
|
||||
try {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
@@ -825,260 +822,51 @@ extension _TrackMetadataConvertAndCueSplit on _TrackMetadataScreenState {
|
||||
final shouldEmbedLyrics =
|
||||
settings.embedLyrics && settings.lyricsMode != 'external';
|
||||
final metadata = _buildFallbackMetadata();
|
||||
try {
|
||||
final result = await PlatformBridge.readFileMetadata(cleanFilePath);
|
||||
if (result['error'] == null) {
|
||||
mergePlatformMetadataForTagEmbed(target: metadata, source: result);
|
||||
} else {
|
||||
_log.w('readFileMetadata returned error, using fallback metadata');
|
||||
}
|
||||
} catch (e) {
|
||||
_log.w('readFileMetadata threw, using fallback metadata: $e');
|
||||
}
|
||||
await ensureLyricsMetadataForConversion(
|
||||
final localItem = _localLibraryItem;
|
||||
final result = await convertLibraryTrack(
|
||||
filePath: cleanFilePath,
|
||||
metadata: metadata,
|
||||
sourcePath: cleanFilePath,
|
||||
shouldEmbedLyrics: shouldEmbedLyrics,
|
||||
trackName: trackName,
|
||||
artistName: artistName,
|
||||
spotifyId: _spotifyId ?? '',
|
||||
durationMs: (duration ?? 0) * 1000,
|
||||
);
|
||||
|
||||
try {
|
||||
final tempDir = await getTemporaryDirectory();
|
||||
final coverOutput =
|
||||
'${tempDir.path}${Platform.pathSeparator}convert_cover_${DateTime.now().millisecondsSinceEpoch}.jpg';
|
||||
final coverResult = await PlatformBridge.extractCoverToFile(
|
||||
cleanFilePath,
|
||||
coverOutput,
|
||||
);
|
||||
if (coverResult['error'] == null) {
|
||||
coverPath = coverOutput;
|
||||
}
|
||||
} catch (_) {}
|
||||
|
||||
String workingPath = cleanFilePath;
|
||||
final isSaf = _isSafFile;
|
||||
|
||||
if (isSaf) {
|
||||
safTempPath = await ConversionLibraryService.copySafSourceToTemp(
|
||||
cleanFilePath,
|
||||
);
|
||||
if (safTempPath == null) {
|
||||
if (mounted) {
|
||||
_setState(() => _isConverting = false);
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text(context.l10n.trackConvertFailed)),
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
workingPath = safTempPath;
|
||||
}
|
||||
|
||||
final newPath = await FFmpegService.convertAudioFormat(
|
||||
inputPath: workingPath,
|
||||
targetFormat: targetFormat.toLowerCase(),
|
||||
bitrate: bitrate,
|
||||
metadata: metadata,
|
||||
coverPath: coverPath,
|
||||
artistTagMode: ref.read(settingsProvider).artistTagMode,
|
||||
deleteOriginal: !isSaf && !keepOriginal,
|
||||
sourceBitDepth: bitDepth,
|
||||
sourceSampleRate: sampleRate,
|
||||
historyItem: localItem == null ? _downloadItem : null,
|
||||
localItem: localItem,
|
||||
targetFormat: targetFormat,
|
||||
bitrate: bitrate,
|
||||
losslessQuality: losslessQuality,
|
||||
losslessProcessing: losslessProcessing,
|
||||
keepOriginal: keepOriginal,
|
||||
shouldEmbedLyrics: shouldEmbedLyrics,
|
||||
readArtistTagMode: () => ref.read(settingsProvider).artistTagMode,
|
||||
losslessLabels: losslessLabels,
|
||||
coverTempPrefix: 'convert_cover_',
|
||||
treatEmptySafTempPathAsFailure: false,
|
||||
historySafFileNameFallback: _extractFileNameFromPathOrUri(
|
||||
cleanFilePath,
|
||||
),
|
||||
eagerTempCleanup: true,
|
||||
onPersisted: () => localItem == null
|
||||
? ref.read(downloadHistoryProvider.notifier).reloadFromStorage()
|
||||
: ref.read(localLibraryProvider.notifier).reloadFromStorage(),
|
||||
diagnosticsLog: _log,
|
||||
onCleanupFailed: (path, error) =>
|
||||
_log.w('Failed to clean conversion temp file $path: $error'),
|
||||
);
|
||||
|
||||
if (coverPath != null) {
|
||||
try {
|
||||
await File(coverPath).delete();
|
||||
} catch (_) {}
|
||||
if (!result.succeeded && !result.isPipelineFailure) {
|
||||
Error.throwWithStackTrace(result.error!, result.stackTrace!);
|
||||
}
|
||||
|
||||
if (newPath == null) {
|
||||
if (safTempPath != null) {
|
||||
try {
|
||||
await File(safTempPath).delete();
|
||||
} catch (_) {}
|
||||
}
|
||||
if (mounted) {
|
||||
_setState(() => _isConverting = false);
|
||||
if (mounted) {
|
||||
_setState(() => _isConverting = false);
|
||||
if (!result.succeeded) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text(context.l10n.trackConvertFailed)),
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (isSaf) convertedSafTempPath = newPath;
|
||||
|
||||
final isLosslessOutput = isLosslessConversionTarget(targetFormat);
|
||||
int? convertedBitDepth;
|
||||
int? convertedSampleRate;
|
||||
if (isLosslessOutput) {
|
||||
try {
|
||||
final convertedMetadata = await PlatformBridge.readFileMetadata(
|
||||
newPath,
|
||||
);
|
||||
if (convertedMetadata['error'] == null) {
|
||||
convertedBitDepth = readPositiveInt(convertedMetadata['bit_depth']);
|
||||
convertedSampleRate = readPositiveInt(
|
||||
convertedMetadata['sample_rate'],
|
||||
);
|
||||
}
|
||||
} catch (_) {}
|
||||
convertedBitDepth ??= losslessQuality.effectiveBitDepth(bitDepth);
|
||||
convertedSampleRate ??= losslessQuality.effectiveSampleRate(sampleRate);
|
||||
}
|
||||
final newQuality = convertedAudioQualityLabel(
|
||||
targetFormat: targetFormat,
|
||||
bitrate: bitrate,
|
||||
labels: losslessLabels,
|
||||
losslessQuality: losslessQuality,
|
||||
actualBitDepth: convertedBitDepth,
|
||||
actualSampleRate: convertedSampleRate,
|
||||
);
|
||||
|
||||
if (isSaf) {
|
||||
String? treeUri;
|
||||
String relativeDir = '';
|
||||
String oldFileName = '';
|
||||
if (_isLocalItem) {
|
||||
final location = resolveSafDocumentLocation(cleanFilePath);
|
||||
if (location != null) {
|
||||
treeUri = location.treeUri;
|
||||
relativeDir = location.relativeDir;
|
||||
oldFileName = location.fileName;
|
||||
}
|
||||
} else {
|
||||
treeUri = _downloadItem?.downloadTreeUri;
|
||||
relativeDir = _downloadItem?.safRelativeDir ?? '';
|
||||
oldFileName =
|
||||
(_downloadItem?.safFileName != null &&
|
||||
_downloadItem!.safFileName!.isNotEmpty)
|
||||
? _downloadItem!.safFileName!
|
||||
: _extractFileNameFromPathOrUri(cleanFilePath);
|
||||
}
|
||||
if (treeUri == null || treeUri.isEmpty) {
|
||||
try {
|
||||
await File(newPath).delete();
|
||||
} catch (_) {}
|
||||
if (safTempPath != null) {
|
||||
try {
|
||||
await File(safTempPath).delete();
|
||||
} catch (_) {}
|
||||
}
|
||||
if (mounted) {
|
||||
_setState(() => _isConverting = false);
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text(context.l10n.trackConvertFailed)),
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
final published = await ConversionLibraryService.publishSafConversion(
|
||||
treeUri: treeUri,
|
||||
relativeDir: relativeDir,
|
||||
originalFileName: oldFileName,
|
||||
targetFormat: targetFormat,
|
||||
sourcePath: newPath,
|
||||
keepOriginal: keepOriginal,
|
||||
);
|
||||
final safUri = published?.uri;
|
||||
|
||||
if (safUri == null || safUri.isEmpty) {
|
||||
try {
|
||||
await File(newPath).delete();
|
||||
} catch (_) {}
|
||||
if (safTempPath != null) {
|
||||
try {
|
||||
await File(safTempPath).delete();
|
||||
} catch (_) {}
|
||||
}
|
||||
if (mounted) {
|
||||
_setState(() => _isConverting = false);
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text(context.l10n.trackConvertFailed)),
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (!keepOriginal && !isSameContentUri(cleanFilePath, safUri)) {
|
||||
final deletedOriginal = await PlatformBridge.safDelete(
|
||||
cleanFilePath,
|
||||
).catchError((_) => false);
|
||||
if (deletedOriginal != true) {
|
||||
_log.w(
|
||||
'Converted SAF file created but failed deleting original URI',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (!_isLocalItem) {
|
||||
await ConversionLibraryService.persistHistoryConversion(
|
||||
source: _downloadItem!,
|
||||
newFilePath: safUri,
|
||||
newQuality: newQuality,
|
||||
targetFormat: targetFormat,
|
||||
bitrate: bitrate,
|
||||
bitDepth: convertedBitDepth,
|
||||
sampleRate: convertedSampleRate,
|
||||
keepOriginal: keepOriginal,
|
||||
newSafFileName: published!.fileName,
|
||||
);
|
||||
await ref.read(downloadHistoryProvider.notifier).reloadFromStorage();
|
||||
} else {
|
||||
await LibraryDatabase.instance.replaceWithConvertedItem(
|
||||
item: _localLibraryItem!,
|
||||
newFilePath: safUri,
|
||||
targetFormat: targetFormat,
|
||||
bitrate: bitrate,
|
||||
bitDepth: convertedBitDepth,
|
||||
sampleRate: convertedSampleRate,
|
||||
keepOriginal: keepOriginal,
|
||||
);
|
||||
await ref.read(localLibraryProvider.notifier).reloadFromStorage();
|
||||
}
|
||||
|
||||
try {
|
||||
await File(newPath).delete();
|
||||
} catch (_) {}
|
||||
if (safTempPath != null) {
|
||||
try {
|
||||
await File(safTempPath).delete();
|
||||
} catch (_) {}
|
||||
}
|
||||
} else {
|
||||
if (!_isLocalItem) {
|
||||
await ConversionLibraryService.persistHistoryConversion(
|
||||
source: _downloadItem!,
|
||||
newFilePath: newPath,
|
||||
newQuality: newQuality,
|
||||
targetFormat: targetFormat,
|
||||
bitrate: bitrate,
|
||||
bitDepth: convertedBitDepth,
|
||||
sampleRate: convertedSampleRate,
|
||||
keepOriginal: keepOriginal,
|
||||
);
|
||||
await ref.read(downloadHistoryProvider.notifier).reloadFromStorage();
|
||||
} else {
|
||||
await LibraryDatabase.instance.replaceWithConvertedItem(
|
||||
item: _localLibraryItem!,
|
||||
newFilePath: newPath,
|
||||
targetFormat: targetFormat,
|
||||
bitrate: bitrate,
|
||||
bitDepth: convertedBitDepth,
|
||||
sampleRate: convertedSampleRate,
|
||||
keepOriginal: keepOriginal,
|
||||
);
|
||||
await ref.read(localLibraryProvider.notifier).reloadFromStorage();
|
||||
}
|
||||
}
|
||||
|
||||
if (mounted) {
|
||||
_setState(() => _isConverting = false);
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(context.l10n.trackConvertSuccess(targetFormat)),
|
||||
@@ -1097,20 +885,6 @@ extension _TrackMetadataConvertAndCueSplit on _TrackMetadataScreenState {
|
||||
),
|
||||
);
|
||||
}
|
||||
} finally {
|
||||
for (final path in <String?>[
|
||||
coverPath,
|
||||
safTempPath,
|
||||
convertedSafTempPath,
|
||||
]) {
|
||||
if (path == null || path.isEmpty) continue;
|
||||
try {
|
||||
final file = File(path);
|
||||
if (await file.exists()) await file.delete();
|
||||
} catch (error) {
|
||||
_log.w('Failed to clean conversion temp file $path: $error');
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -14,7 +14,6 @@ import 'package:spotiflac_android/widgets/expressive_button.dart';
|
||||
import 'package:spotiflac_android/widgets/re_enrich_review_sheet.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:spotiflac_android/services/conversion_library_service.dart';
|
||||
import 'package:spotiflac_android/services/deleted_library_files.dart';
|
||||
import 'package:spotiflac_android/services/file_access_check.dart';
|
||||
import 'package:spotiflac_android/services/network_storage_service.dart';
|
||||
@@ -70,6 +69,8 @@ import 'package:spotiflac_android/widgets/open_on_platform_sheet.dart';
|
||||
import 'package:spotiflac_android/widgets/metadata_barcode.dart';
|
||||
import 'package:spotiflac_android/widgets/settings_group.dart';
|
||||
import 'package:spotiflac_android/constants/music_services.dart';
|
||||
import 'package:spotiflac_android/controllers/batch_track_actions.dart'
|
||||
show convertLibraryTrack;
|
||||
import 'package:spotiflac_android/screens/collapsing_header_scroll_mixin.dart';
|
||||
import 'package:spotiflac_android/screens/track_metadata_edit_sheet.dart';
|
||||
import 'package:spotiflac_android/utils/clickable_metadata.dart';
|
||||
|
||||
Reference in new issue
Block a user