diff --git a/lib/screens/track_metadata_edit_sheet.dart b/lib/screens/track_metadata_edit_sheet.dart index 4ec98b0a..c9d7be9a 100644 --- a/lib/screens/track_metadata_edit_sheet.dart +++ b/lib/screens/track_metadata_edit_sheet.dart @@ -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 { 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 { Future _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 = { - '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 = { - '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); } } diff --git a/lib/services/metadata_persistence_service.dart b/lib/services/metadata_persistence_service.dart new file mode 100644 index 00000000..5a091cdf --- /dev/null +++ b/lib/services/metadata_persistence_service.dart @@ -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 metadata; + final String artistTagMode; + final String? selectedCoverPath; + final String? currentCoverPath; + final int? coverMaxDimension; + + MetadataSaveRequest({ + required this.filePath, + required Map metadata, + required this.artistTagMode, + this.selectedCoverPath, + this.currentCoverPath, + this.coverMaxDimension, + }) : metadata = Map.unmodifiable(metadata); +} + +enum MetadataSaveCodec { mp3, m4a, opus } + +typedef MetadataEmbed = + Future Function( + MetadataSaveCodec codec, + String path, + String? coverPath, + Map metadata, + String artistTagMode, + ); + +/// Writes tags without widget state, and owns resources until every writer ends. +class MetadataPersistenceService { + final MetadataCoverResources covers; + final Future> Function(String, Map) edit; + final Future> Function(String) read; + final MetadataEmbed embed; + final Future 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 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 _fallbackTags(Map 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 _embed( + MetadataSaveCodec codec, + String path, + String? coverPath, + Map 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, + ), + }; +} diff --git a/test/metadata_persistence_service_test.dart b/test/metadata_persistence_service_test.dart new file mode 100644 index 00000000..700d1362 --- /dev/null +++ b/test/metadata_persistence_service_test.dart @@ -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 create() => + copyPicked(extension: 'png', bytes: Uint8List.fromList([1, 2, 3])); + + @override + Future 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 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 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(); + final completed = Completer>(); + 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); + } + }, + ); + } +}