From 7920554f5ca5dadfbf1dd6325522536456493698 Mon Sep 17 00:00:00 2001 From: zarzet <42882290+zarzet@users.noreply.github.com> Date: Sun, 6 Sep 2026 17:29:54 +0700 Subject: [PATCH] fix(metadata): preserve Opus tags during batch re-enrichment --- lib/services/ffmpeg_service.dart | 10 +- test/ffmpeg_opus_metadata_test.dart | 224 ++++++++++++++++++++++++++++ 2 files changed, 231 insertions(+), 3 deletions(-) create mode 100644 test/ffmpeg_opus_metadata_test.dart diff --git a/lib/services/ffmpeg_service.dart b/lib/services/ffmpeg_service.dart index 63e15e52..61579172 100644 --- a/lib/services/ffmpeg_service.dart +++ b/lib/services/ffmpeg_service.dart @@ -1499,10 +1499,14 @@ class FFmpegService { Map? metadata, String artistTagMode = artistTagModeJoined, bool preserveMetadata = false, + @visibleForTesting Future Function(List)? execute, }) async { final tempDir = await getTemporaryDirectory(); final tempOutput = _nextTempEmbedPath(tempDir.path, '.opus'); - final mapMetaValue = preserveMetadata ? '0' : '-1'; + // Opus tags belong to the audio stream, not the input's global metadata. + // Copy them to output globals so selected -metadata values override them, + // then let the Opus muxer write those globals into the output stream. + final mapMetaValue = preserveMetadata ? '0:s:a:0' : '-1'; final arguments = [ '-v', 'error', @@ -1514,7 +1518,7 @@ class FFmpegService { '-map_metadata', mapMetaValue, '-map_metadata:s:a', - mapMetaValue, + '-1', '-c:a', 'copy', ]; @@ -1550,7 +1554,7 @@ class FFmpegService { ..add('-y'); _log.d('Executing FFmpeg Opus embed command'); - final result = await _executeWithArguments(arguments); + final result = await (execute ?? _executeWithArguments)(arguments); if (result.success) { final promoted = await _promoteTempOutput( diff --git a/test/ffmpeg_opus_metadata_test.dart b/test/ffmpeg_opus_metadata_test.dart new file mode 100644 index 00000000..b055f913 --- /dev/null +++ b/test/ffmpeg_opus_metadata_test.dart @@ -0,0 +1,224 @@ +import 'dart:convert'; +import 'dart:io'; + +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:spotiflac_android/services/ffmpeg_service.dart'; + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + const channel = MethodChannel('plugins.flutter.io/path_provider'); + final messenger = + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger; + var toolsAvailable = false; + late Directory directory; + + const originalTags = { + 'title': 'Example Song', + 'artist': 'Example Artist', + 'album': 'Example Album', + 'album_artist': 'Album Artist', + 'date': '2026-01-02', + 'genre': 'Rock', + 'composer': 'Example Composer', + 'lyrics': 'Existing lyrics', + 'replaygain_track_gain': '-4.00 dB', + 'custom_tag': 'Keep this tag', + }; + + setUpAll(() async { + try { + final ffmpeg = await Process.run('ffmpeg', ['-version']); + final ffprobe = await Process.run('ffprobe', ['-version']); + toolsAvailable = ffmpeg.exitCode == 0 && ffprobe.exitCode == 0; + } on ProcessException { + // The media round-trip tests run when FFmpeg tools are on PATH. + } + }); + + setUp(() async { + directory = await Directory.systemTemp.createTemp('opus-metadata-'); + messenger.setMockMethodCallHandler(channel, (_) async => directory.path); + }); + + tearDown(() async { + messenger.setMockMethodCallHandler(channel, null); + await directory.delete(recursive: true); + }); + + Future run(String executable, List arguments) async { + final result = await Process.run(executable, arguments); + expect(result.exitCode, 0, reason: result.stderr.toString()); + return result; + } + + Future execute(List arguments) async { + final result = await Process.run('ffmpeg', arguments); + return FFmpegResult( + success: result.exitCode == 0, + returnCode: result.exitCode, + output: '${result.stdout}${result.stderr}', + ); + } + + Future audioHash(String path) async { + final result = await run('ffmpeg', [ + '-v', + 'error', + '-i', + path, + '-map', + '0:a:0', + '-c:a', + 'copy', + '-f', + 'hash', + '-hash', + 'sha256', + '-', + ]); + final hash = result.stdout.toString().trim(); + expect(hash, matches(r'^SHA256=[a-f0-9]{64}$')); + return hash; + } + + for (final scenario in [ + ( + name: 'ISRC only', + metadata: {'isrc': 'USABC2600001'}, + cover: false, + preserve: true, + ), + ( + name: 'selected fields', + metadata: { + 'title': 'Updated Song', + 'artist': 'Updated Artist', + 'album': 'Updated Album', + 'genre': 'Jazz', + }, + cover: false, + preserve: true, + ), + ( + name: 'cover only', + metadata: {}, + cover: true, + preserve: true, + ), + ( + name: 'full replacement', + metadata: {'title': 'New Song', 'artist': 'New Artist'}, + cover: false, + preserve: false, + ), + ]) { + test('Opus ${scenario.name} survives a metadata reread', () async { + if (!toolsAvailable) { + markTestSkipped('Requires ffmpeg and ffprobe on PATH'); + return; + } + final cache = await Directory('${directory.path}/cache').create(); + final path = '${cache.path}/Example "Song" - Example Artist.opus'; + await run('ffmpeg', [ + '-v', + 'error', + '-f', + 'lavfi', + '-i', + 'sine=frequency=440:duration=0.1', + '-c:a', + 'libopus', + for (final tag in originalTags.entries) ...[ + '-metadata', + '${tag.key}=${tag.value}', + ], + path, + ]); + final beforeHash = await audioHash(path); + + String? coverPath; + if (scenario.cover) { + coverPath = '${directory.path}/cover.png'; + await File(coverPath).writeAsBytes( + base64Decode( + 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+aB1sAAAAASUVORK5CYII=', + ), + ); + } + expect( + await FFmpegService.embedMetadataToOpus( + opusPath: path, + coverPath: coverPath, + metadata: scenario.metadata, + preserveMetadata: scenario.preserve, + execute: execute, + ), + path, + ); + + final probe = await run('ffprobe', [ + '-v', + 'error', + '-show_streams', + '-of', + 'json', + path, + ]); + final json = jsonDecode(probe.stdout as String) as Map; + final streams = (json['streams'] as List) + .cast>(); + final audio = streams.singleWhere( + (stream) => stream['codec_type'] == 'audio', + ); + final tags = (audio['tags'] as Map).map( + (key, value) => MapEntry(key.toLowerCase(), value.toString()), + ); + expect( + tags, + containsPair( + 'title', + scenario.metadata['title'] ?? originalTags['title'], + ), + ); + expect( + tags, + containsPair( + 'artist', + scenario.metadata['artist'] ?? originalTags['artist'], + ), + ); + for (final tag in originalTags.entries) { + if (scenario.preserve || scenario.metadata.containsKey(tag.key)) { + expect( + tags[tag.key], + scenario.metadata[tag.key] ?? tag.value, + reason: tag.key, + ); + } else { + expect(tags, isNot(contains(tag.key))); + } + } + for (final tag in scenario.metadata.entries) { + expect(tags[tag.key], tag.value); + } + if (scenario.cover) { + expect( + streams.any( + (stream) => + stream['codec_type'] == 'video' && + (stream['disposition'] + as Map)['attached_pic'] == + 1, + ), + isTrue, + ); + } + expect( + await audioHash(path), + beforeHash, + reason: 'Re-enrich must preserve the encoded audio', + ); + }); + } +}