diff --git a/lib/controllers/batch_track_actions.dart b/lib/controllers/batch_track_actions.dart index ee47b346..b2951e2d 100644 --- a/lib/controllers/batch_track_actions.dart +++ b/lib/controllers/batch_track_actions.dart @@ -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 _performBatchConversion( BatchProgressDialog.update(current: i + 1, detail: item.trackName); - String failureStage = 'metadata'; - String? coverPath; - String? safTempPath; - String? convertedSafTempPath; - try { - final metadata = { + final result = await convertLibraryTrack( + filePath: item.filePath, + metadata: { '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 [ - 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 _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 convertLibraryTrack({ + required String filePath, + required Map 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 Function()? onPersisted, + AppLogger? diagnosticsLog, + required void Function(String path, Object error) onCleanupFailed, +}) async { + String failureStage = 'metadata'; + String? coverPath; + String? safTempPath; + String? convertedSafTempPath; + + Future cleanUpEagerly(List 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 [ + 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 _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 diff --git a/lib/screens/track_metadata_convert.dart b/lib/screens/track_metadata_convert.dart index 4e7bc975..8acbe4b4 100644 --- a/lib/screens/track_metadata_convert.dart +++ b/lib/screens/track_metadata_convert.dart @@ -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 [ - 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'); - } - } } } } diff --git a/lib/screens/track_metadata_screen.dart b/lib/screens/track_metadata_screen.dart index 145dc92c..f868c830 100644 --- a/lib/screens/track_metadata_screen.dart +++ b/lib/screens/track_metadata_screen.dart @@ -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';