mirror of
https://github.com/zarzet/SpotiFLAC-Mobile.git
synced 2026-10-22 07:18:38 +02:00
refactor(metadata): separate file save persistence from editor
This commit is contained in:
1 parent
ae918028bd
commit
4d65b590e1
3 files changed
+578
-183
No files matched your search
@@ -11,9 +11,9 @@ import 'package:spotiflac_android/controllers/metadata_editor_controller.dart';
|
||||
import 'package:spotiflac_android/l10n/l10n.dart';
|
||||
import 'package:spotiflac_android/providers/extension_provider.dart';
|
||||
import 'package:spotiflac_android/providers/settings_provider.dart';
|
||||
import 'package:spotiflac_android/services/ffmpeg_service.dart';
|
||||
import 'package:spotiflac_android/services/metadata_cover_resources.dart';
|
||||
import 'package:spotiflac_android/services/metadata_autofill_service.dart';
|
||||
import 'package:spotiflac_android/services/metadata_persistence_service.dart';
|
||||
import 'package:spotiflac_android/services/platform_bridge.dart';
|
||||
import 'package:spotiflac_android/theme/mornye_theme.dart';
|
||||
import 'package:spotiflac_android/theme/mornye_icons.dart';
|
||||
@@ -78,6 +78,7 @@ class _EditMetadataSheetState extends State<EditMetadataSheet> {
|
||||
|
||||
bool _saving = false;
|
||||
final _covers = MetadataCoverResources();
|
||||
late final _persistence = MetadataPersistenceService(covers: _covers);
|
||||
final _autofill = MetadataAutofillService();
|
||||
bool _showAdvanced = false;
|
||||
bool _showAutoFill = false;
|
||||
@@ -928,193 +929,41 @@ class _EditMetadataSheetState extends State<EditMetadataSheet> {
|
||||
Future<void> _save() async {
|
||||
if (_saving || _fetching || _fetchingMusicBrainz) return;
|
||||
setState(() => _saving = true);
|
||||
final releaseCoverLease = _covers.retain();
|
||||
final metadata = _form.saveMetadata(artistTagMode: widget.artistTagMode);
|
||||
final noCoverMessage = context.l10n.trackCoverNoEmbeddedArt;
|
||||
final resizeFailedMessage = context.l10n.trackCoverResizeFailed;
|
||||
String? resizedCoverTempDir;
|
||||
String? extractedCoverTempDir;
|
||||
try {
|
||||
String? coverPathForSave = _selectedCoverPath;
|
||||
final requestedCoverDimension = _coverMaxDimension;
|
||||
if (requestedCoverDimension != null) {
|
||||
final sourceCoverPath = _selectedCoverPath ?? _currentCoverPath;
|
||||
if (!_hasValue(sourceCoverPath)) {
|
||||
throw StateError(noCoverMessage);
|
||||
}
|
||||
try {
|
||||
final resized = await _covers.resize(
|
||||
sourceCoverPath!,
|
||||
requestedCoverDimension,
|
||||
);
|
||||
resizedCoverTempDir = resized.tempDir;
|
||||
coverPathForSave = resized.path;
|
||||
} catch (_) {
|
||||
throw StateError(resizeFailedMessage);
|
||||
}
|
||||
}
|
||||
|
||||
metadata['cover_path'] = coverPathForSave ?? '';
|
||||
|
||||
final result = await PlatformBridge.editFileMetadata(
|
||||
widget.filePath,
|
||||
metadata,
|
||||
final result = await _persistence.save(
|
||||
MetadataSaveRequest(
|
||||
filePath: widget.filePath,
|
||||
metadata: _form.saveMetadata(artistTagMode: widget.artistTagMode),
|
||||
artistTagMode: widget.artistTagMode,
|
||||
selectedCoverPath: _selectedCoverPath,
|
||||
currentCoverPath: _currentCoverPath,
|
||||
coverMaxDimension: _coverMaxDimension,
|
||||
),
|
||||
);
|
||||
|
||||
if (result['error'] != null) {
|
||||
if (mounted) {
|
||||
_showSheetSnackBar(
|
||||
context.friendlyError(
|
||||
result['error'],
|
||||
fallback: context.l10n.metadataSaveFailedFfmpeg,
|
||||
),
|
||||
);
|
||||
}
|
||||
if (!mounted) return;
|
||||
if (result.failure == null) {
|
||||
Navigator.pop(context, true);
|
||||
return;
|
||||
}
|
||||
|
||||
final method = result['method'] as String?;
|
||||
|
||||
if (method == 'ffmpeg') {
|
||||
// For SAF files, Kotlin returns temp_path + saf_uri
|
||||
final tempPath = result['temp_path'] as String?;
|
||||
final safUri = result['saf_uri'] as String?;
|
||||
final ffmpegTarget = tempPath ?? widget.filePath;
|
||||
|
||||
final lower = widget.filePath.toLowerCase();
|
||||
final isMp3 = lower.endsWith('.mp3');
|
||||
final isOpus = lower.endsWith('.opus') || lower.endsWith('.ogg');
|
||||
final isM4A = lower.endsWith('.m4a') || lower.endsWith('.aac');
|
||||
|
||||
// Always include all known fields so -map_metadata 0 + explicit
|
||||
// -metadata flags can both preserve custom tags AND clear fields
|
||||
// the user emptied.
|
||||
final vorbisMap = <String, String>{
|
||||
'TITLE': metadata['title'] ?? '',
|
||||
'ARTIST': metadata['artist'] ?? '',
|
||||
'ALBUM': metadata['album'] ?? '',
|
||||
'ALBUMARTIST': metadata['album_artist'] ?? '',
|
||||
'DATE': metadata['date'] ?? '',
|
||||
'TRACKNUMBER':
|
||||
(metadata['track_number']?.isNotEmpty == true &&
|
||||
metadata['track_number'] != '0')
|
||||
? (metadata['track_total']?.isNotEmpty == true &&
|
||||
metadata['track_total'] != '0'
|
||||
? '${metadata['track_number']}/${metadata['track_total']}'
|
||||
: metadata['track_number']!)
|
||||
: '',
|
||||
'DISCNUMBER':
|
||||
(metadata['disc_number']?.isNotEmpty == true &&
|
||||
metadata['disc_number'] != '0')
|
||||
? (metadata['disc_total']?.isNotEmpty == true &&
|
||||
metadata['disc_total'] != '0'
|
||||
? '${metadata['disc_number']}/${metadata['disc_total']}'
|
||||
: metadata['disc_number']!)
|
||||
: '',
|
||||
'GENRE': metadata['genre'] ?? '',
|
||||
'ISRC': metadata['isrc'] ?? '',
|
||||
'LYRICS': metadata['lyrics'] ?? '',
|
||||
'UNSYNCEDLYRICS': metadata['lyrics'] ?? '',
|
||||
'ORGANIZATION': metadata['label'] ?? '',
|
||||
'COPYRIGHT': metadata['copyright'] ?? '',
|
||||
'COMPOSER': metadata['composer'] ?? '',
|
||||
'COMMENT': metadata['comment'] ?? '',
|
||||
'ITUNESADVISORY': metadata['explicit'] ?? '',
|
||||
'RELEASETYPE': metadata['album_type'] ?? '',
|
||||
'BARCODE': metadata['upc'] ?? '',
|
||||
'COMPILATION': metadata['compilation'] ?? '',
|
||||
};
|
||||
try {
|
||||
final existingMetadata = await PlatformBridge.readFileMetadata(
|
||||
ffmpegTarget,
|
||||
);
|
||||
// Preserve ReplayGain tags if present — these are computed once
|
||||
// during download and should survive manual metadata edits.
|
||||
final rgFields = <String, String>{
|
||||
'REPLAYGAIN_TRACK_GAIN':
|
||||
existingMetadata['replaygain_track_gain']?.toString() ?? '',
|
||||
'REPLAYGAIN_TRACK_PEAK':
|
||||
existingMetadata['replaygain_track_peak']?.toString() ?? '',
|
||||
'REPLAYGAIN_ALBUM_GAIN':
|
||||
existingMetadata['replaygain_album_gain']?.toString() ?? '',
|
||||
'REPLAYGAIN_ALBUM_PEAK':
|
||||
existingMetadata['replaygain_album_peak']?.toString() ?? '',
|
||||
};
|
||||
rgFields.forEach((key, value) {
|
||||
if (value.isNotEmpty) {
|
||||
vorbisMap[key] = value;
|
||||
}
|
||||
});
|
||||
} catch (_) {
|
||||
// Lyrics/ReplayGain preservation is best-effort.
|
||||
}
|
||||
|
||||
String? existingCoverPath = coverPathForSave ?? _currentCoverPath;
|
||||
if (existingCoverPath == null || existingCoverPath.isEmpty) {
|
||||
// Preserve current embedded cover when user does not pick a new one.
|
||||
final extractedCover = await _covers.extract(ffmpegTarget);
|
||||
existingCoverPath = extractedCover?.path;
|
||||
extractedCoverTempDir = extractedCover?.tempDir;
|
||||
}
|
||||
|
||||
String? ffmpegResult;
|
||||
if (isMp3) {
|
||||
ffmpegResult = await FFmpegService.embedMetadataToMp3(
|
||||
mp3Path: ffmpegTarget,
|
||||
coverPath: existingCoverPath,
|
||||
metadata: vorbisMap,
|
||||
preserveMetadata: true,
|
||||
);
|
||||
} else if (isM4A) {
|
||||
ffmpegResult = await FFmpegService.embedMetadataToM4a(
|
||||
m4aPath: ffmpegTarget,
|
||||
coverPath: existingCoverPath,
|
||||
metadata: vorbisMap,
|
||||
preserveMetadata: true,
|
||||
);
|
||||
} else if (isOpus) {
|
||||
ffmpegResult = await FFmpegService.embedMetadataToOpus(
|
||||
opusPath: ffmpegTarget,
|
||||
coverPath: existingCoverPath,
|
||||
metadata: vorbisMap,
|
||||
artistTagMode: widget.artistTagMode,
|
||||
preserveMetadata: true,
|
||||
);
|
||||
}
|
||||
|
||||
if (ffmpegResult == null) {
|
||||
if (mounted) {
|
||||
_showSheetSnackBar(context.l10n.metadataSaveFailedFfmpeg);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (tempPath != null && safUri != null) {
|
||||
final ok = await PlatformBridge.writeTempToSaf(ffmpegResult, safUri);
|
||||
if (!ok) {
|
||||
if (mounted) {
|
||||
_showSheetSnackBar(context.l10n.metadataSaveFailedStorage);
|
||||
}
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (mounted) {
|
||||
Navigator.pop(context, true);
|
||||
}
|
||||
} catch (e) {
|
||||
if (mounted) {
|
||||
_showSheetSnackBar(
|
||||
context.l10n.snackbarError(context.friendlyError(e)),
|
||||
);
|
||||
}
|
||||
final l10n = context.l10n;
|
||||
_showSheetSnackBar(switch (result.failure!) {
|
||||
MetadataSaveFailure.backend => context.friendlyError(
|
||||
result.error,
|
||||
fallback: l10n.metadataSaveFailedFfmpeg,
|
||||
),
|
||||
MetadataSaveFailure.ffmpeg => l10n.metadataSaveFailedFfmpeg,
|
||||
MetadataSaveFailure.storage => l10n.metadataSaveFailedStorage,
|
||||
MetadataSaveFailure.noCover => l10n.snackbarError(
|
||||
l10n.trackCoverNoEmbeddedArt,
|
||||
),
|
||||
MetadataSaveFailure.resize => l10n.snackbarError(
|
||||
l10n.trackCoverResizeFailed,
|
||||
),
|
||||
MetadataSaveFailure.unexpected => l10n.snackbarError(
|
||||
context.friendlyError(result.error),
|
||||
),
|
||||
});
|
||||
} finally {
|
||||
await _covers.release(extractedCoverTempDir);
|
||||
if (resizedCoverTempDir != null) {
|
||||
await _covers.release(resizedCoverTempDir);
|
||||
}
|
||||
await releaseCoverLease();
|
||||
if (mounted) setState(() => _saving = false);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,242 @@
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:spotiflac_android/services/ffmpeg_service.dart';
|
||||
import 'package:spotiflac_android/services/metadata_cover_resources.dart';
|
||||
import 'package:spotiflac_android/services/platform_bridge.dart';
|
||||
import 'package:spotiflac_android/utils/artist_utils.dart';
|
||||
|
||||
enum MetadataSaveFailure {
|
||||
backend,
|
||||
ffmpeg,
|
||||
storage,
|
||||
noCover,
|
||||
resize,
|
||||
unexpected,
|
||||
}
|
||||
|
||||
class MetadataSaveResult {
|
||||
final MetadataSaveFailure? failure;
|
||||
final Object? error;
|
||||
|
||||
const MetadataSaveResult.saved() : failure = null, error = null;
|
||||
const MetadataSaveResult.failed(
|
||||
MetadataSaveFailure this.failure, [
|
||||
this.error,
|
||||
]);
|
||||
}
|
||||
|
||||
class MetadataSaveRequest {
|
||||
final String filePath;
|
||||
final Map<String, String> metadata;
|
||||
final String artistTagMode;
|
||||
final String? selectedCoverPath;
|
||||
final String? currentCoverPath;
|
||||
final int? coverMaxDimension;
|
||||
|
||||
MetadataSaveRequest({
|
||||
required this.filePath,
|
||||
required Map<String, String> metadata,
|
||||
required this.artistTagMode,
|
||||
this.selectedCoverPath,
|
||||
this.currentCoverPath,
|
||||
this.coverMaxDimension,
|
||||
}) : metadata = Map.unmodifiable(metadata);
|
||||
}
|
||||
|
||||
enum MetadataSaveCodec { mp3, m4a, opus }
|
||||
|
||||
typedef MetadataEmbed =
|
||||
Future<String?> Function(
|
||||
MetadataSaveCodec codec,
|
||||
String path,
|
||||
String? coverPath,
|
||||
Map<String, String> metadata,
|
||||
String artistTagMode,
|
||||
);
|
||||
|
||||
/// Writes tags without widget state, and owns resources until every writer ends.
|
||||
class MetadataPersistenceService {
|
||||
final MetadataCoverResources covers;
|
||||
final Future<Map<String, dynamic>> Function(String, Map<String, String>) edit;
|
||||
final Future<Map<String, dynamic>> Function(String) read;
|
||||
final MetadataEmbed embed;
|
||||
final Future<bool> Function(String, String) writeBack;
|
||||
|
||||
const MetadataPersistenceService({
|
||||
required this.covers,
|
||||
this.edit = PlatformBridge.editFileMetadata,
|
||||
this.read = PlatformBridge.readFileMetadata,
|
||||
this.embed = _embed,
|
||||
this.writeBack = PlatformBridge.writeTempToSaf,
|
||||
});
|
||||
|
||||
Future<MetadataSaveResult> save(MetadataSaveRequest request) async {
|
||||
final releaseLease = covers.retain();
|
||||
String? resizedCoverDir;
|
||||
String? extractedCoverDir;
|
||||
String? stagedPath;
|
||||
try {
|
||||
var coverPath = request.selectedCoverPath;
|
||||
if (request.coverMaxDimension case final dimension?) {
|
||||
final source = coverPath ?? request.currentCoverPath;
|
||||
if (source == null || source.trim().isEmpty) {
|
||||
return const MetadataSaveResult.failed(MetadataSaveFailure.noCover);
|
||||
}
|
||||
try {
|
||||
final resized = await covers.resize(source, dimension);
|
||||
resizedCoverDir = resized.tempDir;
|
||||
coverPath = resized.path;
|
||||
} catch (error) {
|
||||
return MetadataSaveResult.failed(MetadataSaveFailure.resize, error);
|
||||
}
|
||||
}
|
||||
|
||||
final metadata = {
|
||||
...request.metadata,
|
||||
'artist_tag_mode': request.artistTagMode,
|
||||
'cover_path': coverPath ?? '',
|
||||
};
|
||||
final result = await edit(request.filePath, metadata);
|
||||
stagedPath = result['temp_path'] as String?;
|
||||
if (result['error'] case final error?) {
|
||||
return MetadataSaveResult.failed(MetadataSaveFailure.backend, error);
|
||||
}
|
||||
if (result['method'] != 'ffmpeg') {
|
||||
return const MetadataSaveResult.saved();
|
||||
}
|
||||
|
||||
final target = stagedPath ?? request.filePath;
|
||||
final tags = _fallbackTags(metadata);
|
||||
try {
|
||||
final existing = await read(target);
|
||||
for (final key in const [
|
||||
'REPLAYGAIN_TRACK_GAIN',
|
||||
'REPLAYGAIN_TRACK_PEAK',
|
||||
'REPLAYGAIN_ALBUM_GAIN',
|
||||
'REPLAYGAIN_ALBUM_PEAK',
|
||||
]) {
|
||||
final value = existing[key.toLowerCase()]?.toString() ?? '';
|
||||
if (value.isNotEmpty) tags[key] = value;
|
||||
}
|
||||
} catch (_) {
|
||||
// Computed ReplayGain tags are preserved when the reader is available.
|
||||
}
|
||||
var existingCover = coverPath ?? request.currentCoverPath;
|
||||
if (existingCover == null || existingCover.isEmpty) {
|
||||
final extracted = await covers.extract(target);
|
||||
existingCover = extracted?.path;
|
||||
extractedCoverDir = extracted?.tempDir;
|
||||
}
|
||||
|
||||
final codec = _codec(request.filePath);
|
||||
final output = codec == null
|
||||
? null
|
||||
: await embed(
|
||||
codec,
|
||||
target,
|
||||
existingCover,
|
||||
tags,
|
||||
codec == MetadataSaveCodec.opus
|
||||
? request.artistTagMode
|
||||
: artistTagModeJoined,
|
||||
);
|
||||
if (output == null) {
|
||||
return const MetadataSaveResult.failed(MetadataSaveFailure.ffmpeg);
|
||||
}
|
||||
final safUri = result['saf_uri'] as String?;
|
||||
if (stagedPath != null &&
|
||||
safUri != null &&
|
||||
!await writeBack(output, safUri)) {
|
||||
return const MetadataSaveResult.failed(MetadataSaveFailure.storage);
|
||||
}
|
||||
return const MetadataSaveResult.saved();
|
||||
} catch (error) {
|
||||
return MetadataSaveResult.failed(MetadataSaveFailure.unexpected, error);
|
||||
} finally {
|
||||
// Android also deletes on writeback; failed FFmpeg never reaches it.
|
||||
if (stagedPath != null && stagedPath != request.filePath) {
|
||||
try {
|
||||
await File(stagedPath).delete();
|
||||
} catch (_) {}
|
||||
}
|
||||
await covers.release(extractedCoverDir);
|
||||
await covers.release(resizedCoverDir);
|
||||
await releaseLease();
|
||||
}
|
||||
}
|
||||
|
||||
static MetadataSaveCodec? _codec(String path) {
|
||||
final lower = path.toLowerCase();
|
||||
if (lower.endsWith('.mp3')) return MetadataSaveCodec.mp3;
|
||||
if (lower.endsWith('.m4a') || lower.endsWith('.aac')) {
|
||||
return MetadataSaveCodec.m4a;
|
||||
}
|
||||
if (lower.endsWith('.opus') || lower.endsWith('.ogg')) {
|
||||
return MetadataSaveCodec.opus;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
static Map<String, String> _fallbackTags(Map<String, String> metadata) {
|
||||
String index(String key) {
|
||||
final number = metadata['${key}_number'] ?? '';
|
||||
final total = metadata['${key}_total'] ?? '';
|
||||
if (number.isEmpty || number == '0') return '';
|
||||
return total.isEmpty || total == '0' ? number : '$number/$total';
|
||||
}
|
||||
|
||||
// Emit empty values too: preserve custom tags while clearing edited fields.
|
||||
return {
|
||||
for (final entry in const {
|
||||
'TITLE': 'title',
|
||||
'ARTIST': 'artist',
|
||||
'ALBUM': 'album',
|
||||
'ALBUMARTIST': 'album_artist',
|
||||
'DATE': 'date',
|
||||
'GENRE': 'genre',
|
||||
'ISRC': 'isrc',
|
||||
'LYRICS': 'lyrics',
|
||||
'UNSYNCEDLYRICS': 'lyrics',
|
||||
'ORGANIZATION': 'label',
|
||||
'COPYRIGHT': 'copyright',
|
||||
'COMPOSER': 'composer',
|
||||
'COMMENT': 'comment',
|
||||
'ITUNESADVISORY': 'explicit',
|
||||
'RELEASETYPE': 'album_type',
|
||||
'BARCODE': 'upc',
|
||||
'COMPILATION': 'compilation',
|
||||
}.entries)
|
||||
entry.key: metadata[entry.value] ?? '',
|
||||
'TRACKNUMBER': index('track'),
|
||||
'DISCNUMBER': index('disc'),
|
||||
};
|
||||
}
|
||||
|
||||
static Future<String?> _embed(
|
||||
MetadataSaveCodec codec,
|
||||
String path,
|
||||
String? coverPath,
|
||||
Map<String, String> metadata,
|
||||
String artistTagMode,
|
||||
) => switch (codec) {
|
||||
MetadataSaveCodec.mp3 => FFmpegService.embedMetadataToMp3(
|
||||
mp3Path: path,
|
||||
coverPath: coverPath,
|
||||
metadata: metadata,
|
||||
preserveMetadata: true,
|
||||
),
|
||||
MetadataSaveCodec.m4a => FFmpegService.embedMetadataToM4a(
|
||||
m4aPath: path,
|
||||
coverPath: coverPath,
|
||||
metadata: metadata,
|
||||
preserveMetadata: true,
|
||||
),
|
||||
MetadataSaveCodec.opus => FFmpegService.embedMetadataToOpus(
|
||||
opusPath: path,
|
||||
coverPath: coverPath,
|
||||
metadata: metadata,
|
||||
artistTagMode: artistTagMode,
|
||||
preserveMetadata: true,
|
||||
),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,304 @@
|
||||
import 'dart:async';
|
||||
import 'dart:io';
|
||||
import 'dart:typed_data';
|
||||
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:spotiflac_android/services/metadata_cover_resources.dart';
|
||||
import 'package:spotiflac_android/services/metadata_persistence_service.dart';
|
||||
import 'package:spotiflac_android/utils/artist_utils.dart';
|
||||
|
||||
class _Covers extends MetadataCoverResources {
|
||||
bool failResize = false;
|
||||
bool extractCover = false;
|
||||
MetadataCoverPreview? resized;
|
||||
MetadataCoverPreview? extracted;
|
||||
|
||||
Future<MetadataCoverPreview> create() =>
|
||||
copyPicked(extension: 'png', bytes: Uint8List.fromList([1, 2, 3]));
|
||||
|
||||
@override
|
||||
Future<MetadataCoverPreview> resize(String source, int maxDimension) async {
|
||||
expect(await File(source).exists(), isTrue);
|
||||
expect(maxDimension, 500);
|
||||
if (failResize) throw StateError('Resize failed');
|
||||
return resized = await create();
|
||||
}
|
||||
|
||||
@override
|
||||
Future<MetadataCoverPreview?> extract(String filePath) async =>
|
||||
extractCover ? extracted = await create() : null;
|
||||
}
|
||||
|
||||
void main() {
|
||||
late _Covers covers;
|
||||
setUp(() => covers = _Covers());
|
||||
tearDown(() => covers.dispose());
|
||||
|
||||
MetadataSaveRequest request({
|
||||
String path = 'song.mp3',
|
||||
Map<String, String> tags = const {},
|
||||
String? selectedCover,
|
||||
String? currentCover,
|
||||
int? dimension,
|
||||
}) => MetadataSaveRequest(
|
||||
filePath: path,
|
||||
metadata: tags,
|
||||
artistTagMode: artistTagModeSplitVorbis,
|
||||
selectedCoverPath: selectedCover,
|
||||
currentCoverPath: currentCover,
|
||||
coverMaxDimension: dimension,
|
||||
);
|
||||
|
||||
test(
|
||||
'native writer keeps a snapshot and artwork alive after editor closes',
|
||||
() async {
|
||||
final cover = await covers.create();
|
||||
final entered = Completer<void>();
|
||||
final completed = Completer<Map<String, dynamic>>();
|
||||
final tags = {'title': 'Song', 'lyrics': ''};
|
||||
final input = request(tags: tags, selectedCover: cover.path);
|
||||
tags['title'] = 'Changed';
|
||||
final service = MetadataPersistenceService(
|
||||
covers: covers,
|
||||
edit: (path, metadata) async {
|
||||
expect(path, 'song.mp3');
|
||||
expect(metadata, {
|
||||
'title': 'Song',
|
||||
'lyrics': '',
|
||||
'artist_tag_mode': artistTagModeSplitVorbis,
|
||||
'cover_path': cover.path,
|
||||
});
|
||||
entered.complete();
|
||||
return completed.future;
|
||||
},
|
||||
read: (_) async => throw StateError('Native edit needs no fallback'),
|
||||
);
|
||||
final pending = service.save(input);
|
||||
await entered.future;
|
||||
await covers.dispose();
|
||||
expect(await File(cover.path).exists(), isTrue);
|
||||
completed.complete({'success': true, 'method': 'native'});
|
||||
expect((await pending).failure, isNull);
|
||||
expect(await Directory(cover.tempDir).exists(), isFalse);
|
||||
},
|
||||
);
|
||||
|
||||
for (final extension in ['mp3', 'm4a', 'aac', 'opus', 'OGG']) {
|
||||
test(
|
||||
'fallback preserves edited/empty tags and artist mode for $extension',
|
||||
() async {
|
||||
final cover = await covers.create();
|
||||
final isOpus = extension == 'opus' || extension == 'OGG';
|
||||
final service = MetadataPersistenceService(
|
||||
covers: covers,
|
||||
edit: (_, metadata) async {
|
||||
expect(metadata['cover_path'], '');
|
||||
return {'method': 'ffmpeg'};
|
||||
},
|
||||
read: (path) async {
|
||||
expect(path, 'song.$extension');
|
||||
return {
|
||||
'replaygain_track_gain': '-6.20 dB',
|
||||
'replaygain_track_peak': '0.000000',
|
||||
'replaygain_album_gain': '0.00 dB',
|
||||
'replaygain_album_peak': '1.234567',
|
||||
};
|
||||
},
|
||||
embed: (codec, path, coverPath, metadata, mode) async {
|
||||
expect(
|
||||
codec,
|
||||
isOpus
|
||||
? MetadataSaveCodec.opus
|
||||
: extension == 'mp3'
|
||||
? MetadataSaveCodec.mp3
|
||||
: MetadataSaveCodec.m4a,
|
||||
);
|
||||
expect(
|
||||
mode,
|
||||
isOpus ? artistTagModeSplitVorbis : artistTagModeJoined,
|
||||
);
|
||||
expect(coverPath, cover.path);
|
||||
expect(metadata['ARTIST'], 'Artist One; Artist Two');
|
||||
expect(metadata['LYRICS'], 'Existing lyrics');
|
||||
expect(metadata['UNSYNCEDLYRICS'], 'Existing lyrics');
|
||||
expect(metadata['TITLE'], '');
|
||||
expect(metadata['ALBUMARTIST'], '');
|
||||
expect(metadata['TRACKNUMBER'], '3/12');
|
||||
expect(metadata['DISCNUMBER'], '');
|
||||
expect(metadata['REPLAYGAIN_TRACK_GAIN'], '-6.20 dB');
|
||||
expect(metadata['REPLAYGAIN_TRACK_PEAK'], '0.000000');
|
||||
expect(metadata['REPLAYGAIN_ALBUM_GAIN'], '0.00 dB');
|
||||
expect(metadata['REPLAYGAIN_ALBUM_PEAK'], '1.234567');
|
||||
return path;
|
||||
},
|
||||
writeBack: (_, _) async =>
|
||||
throw StateError('Local edit needs no SAF write'),
|
||||
);
|
||||
expect(
|
||||
(await service.save(
|
||||
request(
|
||||
path: 'song.$extension',
|
||||
currentCover: cover.path,
|
||||
tags: {
|
||||
'artist': 'Artist One; Artist Two',
|
||||
'lyrics': 'Existing lyrics',
|
||||
'track_number': '3',
|
||||
'track_total': '12',
|
||||
'disc_number': '0',
|
||||
'disc_total': '2',
|
||||
},
|
||||
),
|
||||
)).failure,
|
||||
isNull,
|
||||
);
|
||||
expect(await File(cover.path).exists(), isTrue);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
test(
|
||||
'fallback clears lyrics and handles missing/zero track and disc totals',
|
||||
() async {
|
||||
for (final pair in [
|
||||
('', '12', ''),
|
||||
('0', '12', ''),
|
||||
('3', '', '3'),
|
||||
('3', '0', '3'),
|
||||
]) {
|
||||
final service = MetadataPersistenceService(
|
||||
covers: covers,
|
||||
edit: (_, _) async => {'method': 'ffmpeg'},
|
||||
read: (_) async => throw StateError('Reader unavailable'),
|
||||
embed: (_, path, _, metadata, _) async {
|
||||
expect(metadata['LYRICS'], '');
|
||||
expect(metadata['UNSYNCEDLYRICS'], '');
|
||||
expect(metadata['TRACKNUMBER'], pair.$3);
|
||||
expect(metadata['DISCNUMBER'], pair.$3);
|
||||
expect(metadata.keys, isNot(contains('REPLAYGAIN_TRACK_GAIN')));
|
||||
return path;
|
||||
},
|
||||
);
|
||||
expect(
|
||||
(await service.save(
|
||||
request(
|
||||
tags: {
|
||||
'track_number': pair.$1,
|
||||
'track_total': pair.$2,
|
||||
'disc_number': pair.$1,
|
||||
'disc_total': pair.$2,
|
||||
},
|
||||
),
|
||||
)).failure,
|
||||
isNull,
|
||||
);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
test(
|
||||
'resized artwork replaces the cover and is released after saving',
|
||||
() async {
|
||||
final original = await covers.create();
|
||||
final service = MetadataPersistenceService(
|
||||
covers: covers,
|
||||
edit: (_, metadata) async {
|
||||
expect(metadata['cover_path'], covers.resized!.path);
|
||||
expect(await File(covers.resized!.path).exists(), isTrue);
|
||||
return {'method': 'native'};
|
||||
},
|
||||
);
|
||||
expect(
|
||||
(await service.save(
|
||||
request(currentCover: original.path, dimension: 500),
|
||||
)).failure,
|
||||
isNull,
|
||||
);
|
||||
expect(await File(original.path).exists(), isTrue);
|
||||
expect(await Directory(covers.resized!.tempDir).exists(), isFalse);
|
||||
},
|
||||
);
|
||||
|
||||
test(
|
||||
'missing artwork and resize failures return typed failures before editing',
|
||||
() async {
|
||||
final service = MetadataPersistenceService(
|
||||
covers: covers,
|
||||
edit: (_, _) async => throw StateError('Must not edit'),
|
||||
);
|
||||
expect(
|
||||
(await service.save(request(dimension: 500))).failure,
|
||||
MetadataSaveFailure.noCover,
|
||||
);
|
||||
covers.failResize = true;
|
||||
final original = await covers.create();
|
||||
expect(
|
||||
(await service.save(
|
||||
request(selectedCover: original.path, dimension: 500),
|
||||
)).failure,
|
||||
MetadataSaveFailure.resize,
|
||||
);
|
||||
expect(await File(original.path).exists(), isTrue);
|
||||
},
|
||||
);
|
||||
|
||||
for (final failure in [
|
||||
null,
|
||||
MetadataSaveFailure.ffmpeg,
|
||||
MetadataSaveFailure.storage,
|
||||
MetadataSaveFailure.unexpected,
|
||||
MetadataSaveFailure.backend,
|
||||
]) {
|
||||
test(
|
||||
'SAF staged file and extracted artwork are cleaned on $failure',
|
||||
() async {
|
||||
final directory = await Directory.systemTemp.createTemp(
|
||||
'metadata-save-',
|
||||
);
|
||||
addTearDown(() => directory.delete(recursive: true));
|
||||
final staged = await File(
|
||||
'${directory.path}/staged.mp3',
|
||||
).writeAsBytes([1, 2, 3]);
|
||||
var writes = 0;
|
||||
covers.extractCover = true;
|
||||
final service = MetadataPersistenceService(
|
||||
covers: covers,
|
||||
edit: (_, _) async => {
|
||||
'method': 'ffmpeg',
|
||||
'temp_path': staged.path,
|
||||
'saf_uri': 'content://library/song.mp3',
|
||||
if (failure == MetadataSaveFailure.backend)
|
||||
'error': 'Backend failed',
|
||||
},
|
||||
read: (_) async => {},
|
||||
embed: (_, path, coverPath, _, _) async {
|
||||
expect(path, staged.path);
|
||||
expect(coverPath, covers.extracted!.path);
|
||||
if (failure == MetadataSaveFailure.unexpected) {
|
||||
throw StateError('Writer failed');
|
||||
}
|
||||
return failure == MetadataSaveFailure.ffmpeg ? null : path;
|
||||
},
|
||||
writeBack: (path, uri) async {
|
||||
writes++;
|
||||
expect(path, staged.path);
|
||||
expect(uri, 'content://library/song.mp3');
|
||||
expect(await staged.exists(), isTrue);
|
||||
return failure != MetadataSaveFailure.storage;
|
||||
},
|
||||
);
|
||||
final result = await service.save(
|
||||
request(path: 'content://library/song.mp3'),
|
||||
);
|
||||
expect(result.failure, failure);
|
||||
expect(
|
||||
writes,
|
||||
failure == null || failure == MetadataSaveFailure.storage ? 1 : 0,
|
||||
);
|
||||
expect(await staged.exists(), isFalse);
|
||||
if (covers.extracted case final cover?) {
|
||||
expect(await Directory(cover.tempDir).exists(), isFalse);
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user