mirror of
https://github.com/zarzet/SpotiFLAC-Mobile.git
synced 2026-07-28 23:08:59 +02:00
refactor(reenrich): share the FFmpeg re-enrich apply flow
local_album and the queue batch action carried byte-identical ~110-line copies
of the FFmpeg re-enrich apply+SAF-writeback+temp-cleanup flow (differing only in
helper names). Extract one applyFfmpegReEnrichResult() so the SAF temp-file
cleanup — the class of bug fixed in fb7d101 — has a single source. The
track-detail copy stays: it drives snackbars and does not preserve metadata.
This commit is contained in:
@@ -9,6 +9,7 @@ import 'package:spotiflac_android/providers/extension_provider.dart';
|
||||
import 'package:spotiflac_android/providers/settings_provider.dart';
|
||||
import 'package:spotiflac_android/utils/adaptive_layout.dart';
|
||||
import 'package:spotiflac_android/utils/confirm_and_delete_tracks.dart';
|
||||
import 'package:spotiflac_android/utils/ffmpeg_reenrich.dart';
|
||||
import 'package:spotiflac_android/utils/file_access.dart';
|
||||
import 'package:spotiflac_android/utils/image_cache_utils.dart';
|
||||
import 'package:spotiflac_android/utils/lyrics_metadata_helper.dart';
|
||||
@@ -16,7 +17,6 @@ import 'package:spotiflac_android/utils/nav_bar_inset.dart';
|
||||
import 'package:spotiflac_android/services/library_database.dart';
|
||||
import 'package:spotiflac_android/services/batch_track_actions.dart';
|
||||
import 'package:spotiflac_android/models/unified_library_item.dart';
|
||||
import 'package:spotiflac_android/services/ffmpeg_service.dart';
|
||||
import 'package:spotiflac_android/services/local_track_redownload_service.dart';
|
||||
import 'package:spotiflac_android/widgets/batch_progress_dialog.dart';
|
||||
import 'package:spotiflac_android/widgets/re_enrich_field_dialog.dart';
|
||||
@@ -425,140 +425,6 @@ class _LocalAlbumScreenState extends ConsumerState<LocalAlbumScreen>
|
||||
);
|
||||
}
|
||||
|
||||
bool _hasValue(String? value) => value != null && value.trim().isNotEmpty;
|
||||
|
||||
Future<void> _safeDeleteFile(String path) async {
|
||||
try {
|
||||
final file = File(path);
|
||||
if (await file.exists()) {
|
||||
await file.delete();
|
||||
}
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
Future<void> _cleanupTempFileAndParent(String path) async {
|
||||
await _safeDeleteFile(path);
|
||||
try {
|
||||
final parent = File(path).parent;
|
||||
if (await parent.exists()) {
|
||||
await parent.delete();
|
||||
}
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
Future<bool> _applyFfmpegReEnrichResult(
|
||||
LocalLibraryItem item,
|
||||
Map<String, dynamic> result,
|
||||
) async {
|
||||
final tempPath = result['temp_path'] as String?;
|
||||
final safUri = result['saf_uri'] as String?;
|
||||
final ffmpegTarget = _hasValue(tempPath) ? tempPath! : item.filePath;
|
||||
final downloadedCoverPath = result['cover_path'] as String?;
|
||||
String? effectiveCoverPath = downloadedCoverPath;
|
||||
String? extractedCoverPath;
|
||||
|
||||
if (!_hasValue(effectiveCoverPath)) {
|
||||
try {
|
||||
final tempDir = await Directory.systemTemp.createTemp(
|
||||
'reenrich_cover_',
|
||||
);
|
||||
final coverOutput = '${tempDir.path}${Platform.pathSeparator}cover.jpg';
|
||||
final extracted = await PlatformBridge.extractCoverToFile(
|
||||
ffmpegTarget,
|
||||
coverOutput,
|
||||
);
|
||||
if (extracted['error'] == null) {
|
||||
effectiveCoverPath = coverOutput;
|
||||
extractedCoverPath = coverOutput;
|
||||
} else {
|
||||
try {
|
||||
await tempDir.delete(recursive: true);
|
||||
} catch (_) {}
|
||||
}
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
final metadata = (result['metadata'] as Map<String, dynamic>?)?.map(
|
||||
(k, v) => MapEntry(k, v.toString()),
|
||||
);
|
||||
|
||||
final format = item.format?.toLowerCase();
|
||||
final lowerPath = item.filePath.toLowerCase();
|
||||
final isMp3 = format == 'mp3' || lowerPath.endsWith('.mp3');
|
||||
final isM4A =
|
||||
format == 'm4a' ||
|
||||
format == 'aac' ||
|
||||
lowerPath.endsWith('.m4a') ||
|
||||
lowerPath.endsWith('.aac');
|
||||
final isOpus =
|
||||
format == 'opus' ||
|
||||
format == 'ogg' ||
|
||||
lowerPath.endsWith('.opus') ||
|
||||
lowerPath.endsWith('.ogg');
|
||||
|
||||
final artistTagMode = ref.read(settingsProvider).artistTagMode;
|
||||
String? ffmpegResult;
|
||||
if (isMp3) {
|
||||
ffmpegResult = await FFmpegService.embedMetadataToMp3(
|
||||
mp3Path: ffmpegTarget,
|
||||
coverPath: effectiveCoverPath,
|
||||
metadata: metadata,
|
||||
preserveMetadata: true,
|
||||
);
|
||||
} else if (isM4A) {
|
||||
ffmpegResult = await FFmpegService.embedMetadataToM4a(
|
||||
m4aPath: ffmpegTarget,
|
||||
coverPath: effectiveCoverPath,
|
||||
metadata: metadata,
|
||||
preserveMetadata: true,
|
||||
);
|
||||
} else if (isOpus) {
|
||||
ffmpegResult = await FFmpegService.embedMetadataToOpus(
|
||||
opusPath: ffmpegTarget,
|
||||
coverPath: effectiveCoverPath,
|
||||
metadata: metadata,
|
||||
artistTagMode: artistTagMode,
|
||||
preserveMetadata: true,
|
||||
);
|
||||
}
|
||||
|
||||
if (ffmpegResult != null && _hasValue(tempPath) && _hasValue(safUri)) {
|
||||
final ok = await PlatformBridge.writeTempToSaf(ffmpegResult, safUri!);
|
||||
if (!ok) {
|
||||
if (_hasValue(downloadedCoverPath)) {
|
||||
await _safeDeleteFile(downloadedCoverPath!);
|
||||
}
|
||||
if (_hasValue(extractedCoverPath)) {
|
||||
await _cleanupTempFileAndParent(extractedCoverPath!);
|
||||
}
|
||||
await _safeDeleteFile(tempPath!);
|
||||
return false;
|
||||
}
|
||||
await writeReEnrichSafSidecarLrc(safUri: safUri, reEnrichResult: result);
|
||||
}
|
||||
|
||||
if (_hasValue(downloadedCoverPath)) {
|
||||
await _safeDeleteFile(downloadedCoverPath!);
|
||||
}
|
||||
if (_hasValue(extractedCoverPath)) {
|
||||
await _cleanupTempFileAndParent(extractedCoverPath!);
|
||||
}
|
||||
if (_hasValue(tempPath)) {
|
||||
await _safeDeleteFile(tempPath!);
|
||||
}
|
||||
|
||||
if (ffmpegResult != null) {
|
||||
// Filesystem .lrc sidecar. SAF sidecar is written only after
|
||||
// writeTempToSaf succeeds.
|
||||
await writeReEnrichSidecarLrc(
|
||||
audioFilePath: item.filePath,
|
||||
reEnrichResult: result,
|
||||
);
|
||||
}
|
||||
|
||||
return ffmpegResult != null;
|
||||
}
|
||||
|
||||
Future<bool> _reEnrichLocalTrack(
|
||||
LocalLibraryItem item, {
|
||||
List<String>? updateFields,
|
||||
@@ -603,7 +469,11 @@ class _LocalAlbumScreenState extends ConsumerState<LocalAlbumScreen>
|
||||
return true;
|
||||
}
|
||||
if (method == 'ffmpeg') {
|
||||
return _applyFfmpegReEnrichResult(item, result);
|
||||
return applyFfmpegReEnrichResult(
|
||||
item: item,
|
||||
result: result,
|
||||
artistTagMode: ref.read(settingsProvider).artistTagMode,
|
||||
);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -5,13 +5,13 @@ import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:share_plus/share_plus.dart';
|
||||
import 'package:spotiflac_android/services/ffmpeg_service.dart';
|
||||
import 'package:spotiflac_android/services/platform_bridge.dart';
|
||||
import 'package:spotiflac_android/l10n/l10n.dart';
|
||||
import 'package:spotiflac_android/utils/adaptive_layout.dart';
|
||||
import 'package:spotiflac_android/utils/app_bar_layout.dart';
|
||||
import 'package:spotiflac_android/utils/nav_bar_inset.dart';
|
||||
import 'package:spotiflac_android/widgets/settings_group.dart';
|
||||
import 'package:spotiflac_android/utils/ffmpeg_reenrich.dart';
|
||||
import 'package:spotiflac_android/utils/file_access.dart';
|
||||
import 'package:spotiflac_android/utils/lyrics_metadata_helper.dart';
|
||||
import 'package:spotiflac_android/models/download_item.dart';
|
||||
|
||||
@@ -1,140 +1,6 @@
|
||||
part of 'queue_tab.dart';
|
||||
|
||||
extension _QueueTabBatchActions on _QueueTabState {
|
||||
Future<void> _safeDeleteTempFile(String path) async {
|
||||
try {
|
||||
final file = File(path);
|
||||
if (await file.exists()) {
|
||||
await file.delete();
|
||||
}
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
Future<void> _cleanupTempFileAndParentDir(String path) async {
|
||||
await _safeDeleteTempFile(path);
|
||||
try {
|
||||
final parent = File(path).parent;
|
||||
if (await parent.exists()) {
|
||||
await parent.delete();
|
||||
}
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
Future<bool> _applyQueueFfmpegReEnrichResult(
|
||||
LocalLibraryItem item,
|
||||
Map<String, dynamic> result,
|
||||
) async {
|
||||
final tempPath = result['temp_path'] as String?;
|
||||
final safUri = result['saf_uri'] as String?;
|
||||
final ffmpegTarget = _hasTextValue(tempPath) ? tempPath! : item.filePath;
|
||||
final downloadedCoverPath = result['cover_path'] as String?;
|
||||
String? effectiveCoverPath = downloadedCoverPath;
|
||||
String? extractedCoverPath;
|
||||
|
||||
if (!_hasTextValue(effectiveCoverPath)) {
|
||||
try {
|
||||
final tempDir = await Directory.systemTemp.createTemp(
|
||||
'reenrich_cover_',
|
||||
);
|
||||
final coverOutput = '${tempDir.path}${Platform.pathSeparator}cover.jpg';
|
||||
final extracted = await PlatformBridge.extractCoverToFile(
|
||||
ffmpegTarget,
|
||||
coverOutput,
|
||||
);
|
||||
if (extracted['error'] == null) {
|
||||
effectiveCoverPath = coverOutput;
|
||||
extractedCoverPath = coverOutput;
|
||||
} else {
|
||||
try {
|
||||
await tempDir.delete(recursive: true);
|
||||
} catch (_) {}
|
||||
}
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
final metadata = (result['metadata'] as Map<String, dynamic>?)?.map(
|
||||
(k, v) => MapEntry(k, v.toString()),
|
||||
);
|
||||
|
||||
final format = item.format?.toLowerCase();
|
||||
final lowerPath = item.filePath.toLowerCase();
|
||||
final isMp3 = format == 'mp3' || lowerPath.endsWith('.mp3');
|
||||
final isM4A =
|
||||
format == 'm4a' ||
|
||||
format == 'aac' ||
|
||||
lowerPath.endsWith('.m4a') ||
|
||||
lowerPath.endsWith('.aac');
|
||||
final isOpus =
|
||||
format == 'opus' ||
|
||||
format == 'ogg' ||
|
||||
lowerPath.endsWith('.opus') ||
|
||||
lowerPath.endsWith('.ogg');
|
||||
|
||||
final artistTagMode = ref.read(settingsProvider).artistTagMode;
|
||||
String? ffmpegResult;
|
||||
if (isMp3) {
|
||||
ffmpegResult = await FFmpegService.embedMetadataToMp3(
|
||||
mp3Path: ffmpegTarget,
|
||||
coverPath: effectiveCoverPath,
|
||||
metadata: metadata,
|
||||
preserveMetadata: true,
|
||||
);
|
||||
} else if (isM4A) {
|
||||
ffmpegResult = await FFmpegService.embedMetadataToM4a(
|
||||
m4aPath: ffmpegTarget,
|
||||
coverPath: effectiveCoverPath,
|
||||
metadata: metadata,
|
||||
preserveMetadata: true,
|
||||
);
|
||||
} else if (isOpus) {
|
||||
ffmpegResult = await FFmpegService.embedMetadataToOpus(
|
||||
opusPath: ffmpegTarget,
|
||||
coverPath: effectiveCoverPath,
|
||||
metadata: metadata,
|
||||
artistTagMode: artistTagMode,
|
||||
preserveMetadata: true,
|
||||
);
|
||||
}
|
||||
|
||||
if (ffmpegResult != null &&
|
||||
_hasTextValue(tempPath) &&
|
||||
_hasTextValue(safUri)) {
|
||||
final ok = await PlatformBridge.writeTempToSaf(ffmpegResult, safUri!);
|
||||
if (!ok) {
|
||||
if (_hasTextValue(downloadedCoverPath)) {
|
||||
await _safeDeleteTempFile(downloadedCoverPath!);
|
||||
}
|
||||
if (_hasTextValue(extractedCoverPath)) {
|
||||
await _cleanupTempFileAndParentDir(extractedCoverPath!);
|
||||
}
|
||||
await _safeDeleteTempFile(tempPath!);
|
||||
return false;
|
||||
}
|
||||
await writeReEnrichSafSidecarLrc(safUri: safUri, reEnrichResult: result);
|
||||
}
|
||||
|
||||
if (_hasTextValue(downloadedCoverPath)) {
|
||||
await _safeDeleteTempFile(downloadedCoverPath!);
|
||||
}
|
||||
if (_hasTextValue(extractedCoverPath)) {
|
||||
await _cleanupTempFileAndParentDir(extractedCoverPath!);
|
||||
}
|
||||
if (_hasTextValue(tempPath)) {
|
||||
await _safeDeleteTempFile(tempPath!);
|
||||
}
|
||||
|
||||
if (ffmpegResult != null) {
|
||||
// Filesystem .lrc sidecar. SAF sidecar is written only after
|
||||
// writeTempToSaf succeeds.
|
||||
await writeReEnrichSidecarLrc(
|
||||
audioFilePath: item.filePath,
|
||||
reEnrichResult: result,
|
||||
);
|
||||
}
|
||||
|
||||
return ffmpegResult != null;
|
||||
}
|
||||
|
||||
Future<bool> _reEnrichQueueLocalTrack(
|
||||
LocalLibraryItem item, {
|
||||
List<String>? updateFields,
|
||||
@@ -179,7 +45,11 @@ extension _QueueTabBatchActions on _QueueTabState {
|
||||
return true;
|
||||
}
|
||||
if (method == 'ffmpeg') {
|
||||
return _applyQueueFfmpegReEnrichResult(item, result);
|
||||
return applyFfmpegReEnrichResult(
|
||||
item: item,
|
||||
result: result,
|
||||
artistTagMode: artistTagMode,
|
||||
);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -952,8 +952,6 @@ extension _QueueTabFilterWidgets on _QueueTabState {
|
||||
);
|
||||
}
|
||||
|
||||
bool _hasTextValue(String? value) => value != null && value.trim().isNotEmpty;
|
||||
|
||||
List<UnifiedLibraryItem> _selectedItemsFromAll(
|
||||
List<UnifiedLibraryItem> allItems,
|
||||
) {
|
||||
|
||||
@@ -0,0 +1,147 @@
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:spotiflac_android/services/ffmpeg_service.dart';
|
||||
import 'package:spotiflac_android/services/library_database.dart';
|
||||
import 'package:spotiflac_android/services/platform_bridge.dart';
|
||||
import 'package:spotiflac_android/utils/lyrics_metadata_helper.dart';
|
||||
|
||||
bool _hasValue(String? value) => value != null && value.trim().isNotEmpty;
|
||||
|
||||
Future<void> _safeDeleteFile(String path) async {
|
||||
try {
|
||||
final file = File(path);
|
||||
if (await file.exists()) {
|
||||
await file.delete();
|
||||
}
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
Future<void> _cleanupTempFileAndParent(String path) async {
|
||||
await _safeDeleteFile(path);
|
||||
try {
|
||||
final parent = File(path).parent;
|
||||
if (await parent.exists()) {
|
||||
await parent.delete();
|
||||
}
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
/// Embeds [result]'s metadata/cover into [item] via FFmpeg while preserving
|
||||
/// existing tags, writes the output back through SAF when the source was staged
|
||||
/// to a temp file, drops the filesystem .lrc sidecar, and cleans up every temp
|
||||
/// artifact (including on the SAF-write failure path). Returns true when the
|
||||
/// FFmpeg step produced an output.
|
||||
///
|
||||
/// Shared by the local-album and queue batch re-enrich flows. The track-detail
|
||||
/// screen keeps its own copy because it drives snackbars and does not preserve
|
||||
/// existing metadata.
|
||||
Future<bool> applyFfmpegReEnrichResult({
|
||||
required LocalLibraryItem item,
|
||||
required Map<String, dynamic> result,
|
||||
required String artistTagMode,
|
||||
}) async {
|
||||
final tempPath = result['temp_path'] as String?;
|
||||
final safUri = result['saf_uri'] as String?;
|
||||
final ffmpegTarget = _hasValue(tempPath) ? tempPath! : item.filePath;
|
||||
final downloadedCoverPath = result['cover_path'] as String?;
|
||||
String? effectiveCoverPath = downloadedCoverPath;
|
||||
String? extractedCoverPath;
|
||||
|
||||
if (!_hasValue(effectiveCoverPath)) {
|
||||
try {
|
||||
final tempDir = await Directory.systemTemp.createTemp('reenrich_cover_');
|
||||
final coverOutput = '${tempDir.path}${Platform.pathSeparator}cover.jpg';
|
||||
final extracted = await PlatformBridge.extractCoverToFile(
|
||||
ffmpegTarget,
|
||||
coverOutput,
|
||||
);
|
||||
if (extracted['error'] == null) {
|
||||
effectiveCoverPath = coverOutput;
|
||||
extractedCoverPath = coverOutput;
|
||||
} else {
|
||||
try {
|
||||
await tempDir.delete(recursive: true);
|
||||
} catch (_) {}
|
||||
}
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
final metadata = (result['metadata'] as Map<String, dynamic>?)?.map(
|
||||
(k, v) => MapEntry(k, v.toString()),
|
||||
);
|
||||
|
||||
final format = item.format?.toLowerCase();
|
||||
final lowerPath = item.filePath.toLowerCase();
|
||||
final isMp3 = format == 'mp3' || lowerPath.endsWith('.mp3');
|
||||
final isM4A =
|
||||
format == 'm4a' ||
|
||||
format == 'aac' ||
|
||||
lowerPath.endsWith('.m4a') ||
|
||||
lowerPath.endsWith('.aac');
|
||||
final isOpus =
|
||||
format == 'opus' ||
|
||||
format == 'ogg' ||
|
||||
lowerPath.endsWith('.opus') ||
|
||||
lowerPath.endsWith('.ogg');
|
||||
|
||||
String? ffmpegResult;
|
||||
if (isMp3) {
|
||||
ffmpegResult = await FFmpegService.embedMetadataToMp3(
|
||||
mp3Path: ffmpegTarget,
|
||||
coverPath: effectiveCoverPath,
|
||||
metadata: metadata,
|
||||
preserveMetadata: true,
|
||||
);
|
||||
} else if (isM4A) {
|
||||
ffmpegResult = await FFmpegService.embedMetadataToM4a(
|
||||
m4aPath: ffmpegTarget,
|
||||
coverPath: effectiveCoverPath,
|
||||
metadata: metadata,
|
||||
preserveMetadata: true,
|
||||
);
|
||||
} else if (isOpus) {
|
||||
ffmpegResult = await FFmpegService.embedMetadataToOpus(
|
||||
opusPath: ffmpegTarget,
|
||||
coverPath: effectiveCoverPath,
|
||||
metadata: metadata,
|
||||
artistTagMode: artistTagMode,
|
||||
preserveMetadata: true,
|
||||
);
|
||||
}
|
||||
|
||||
if (ffmpegResult != null && _hasValue(tempPath) && _hasValue(safUri)) {
|
||||
final ok = await PlatformBridge.writeTempToSaf(ffmpegResult, safUri!);
|
||||
if (!ok) {
|
||||
if (_hasValue(downloadedCoverPath)) {
|
||||
await _safeDeleteFile(downloadedCoverPath!);
|
||||
}
|
||||
if (_hasValue(extractedCoverPath)) {
|
||||
await _cleanupTempFileAndParent(extractedCoverPath!);
|
||||
}
|
||||
await _safeDeleteFile(tempPath!);
|
||||
return false;
|
||||
}
|
||||
await writeReEnrichSafSidecarLrc(safUri: safUri, reEnrichResult: result);
|
||||
}
|
||||
|
||||
if (_hasValue(downloadedCoverPath)) {
|
||||
await _safeDeleteFile(downloadedCoverPath!);
|
||||
}
|
||||
if (_hasValue(extractedCoverPath)) {
|
||||
await _cleanupTempFileAndParent(extractedCoverPath!);
|
||||
}
|
||||
if (_hasValue(tempPath)) {
|
||||
await _safeDeleteFile(tempPath!);
|
||||
}
|
||||
|
||||
if (ffmpegResult != null) {
|
||||
// Filesystem .lrc sidecar. SAF sidecar is written only after
|
||||
// writeTempToSaf succeeds.
|
||||
await writeReEnrichSidecarLrc(
|
||||
audioFilePath: item.filePath,
|
||||
reEnrichResult: result,
|
||||
);
|
||||
}
|
||||
|
||||
return ffmpegResult != null;
|
||||
}
|
||||
Reference in New Issue
Block a user