fix(metadata): preserve Opus tags during batch re-enrichment

This commit is contained in:
zarzet
2026-09-06 17:29:54 +07:00
parent 781661798b
commit 7920554f5c
2 changed files with 231 additions and 3 deletions
+7 -3
View File
@@ -1499,10 +1499,14 @@ class FFmpegService {
Map<String, String>? metadata,
String artistTagMode = artistTagModeJoined,
bool preserveMetadata = false,
@visibleForTesting Future<FFmpegResult> Function(List<String>)? 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 = <String>[
'-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(
+224
View File
@@ -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<ProcessResult> run(String executable, List<String> arguments) async {
final result = await Process.run(executable, arguments);
expect(result.exitCode, 0, reason: result.stderr.toString());
return result;
}
Future<FFmpegResult> execute(List<String> arguments) async {
final result = await Process.run('ffmpeg', arguments);
return FFmpegResult(
success: result.exitCode == 0,
returnCode: result.exitCode,
output: '${result.stdout}${result.stderr}',
);
}
Future<String> 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: <String, String>{},
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<String, dynamic>;
final streams = (json['streams'] as List<dynamic>)
.cast<Map<String, dynamic>>();
final audio = streams.singleWhere(
(stream) => stream['codec_type'] == 'audio',
);
final tags = (audio['tags'] as Map<String, dynamic>).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<String, dynamic>)['attached_pic'] ==
1,
),
isTrue,
);
}
expect(
await audioHash(path),
beforeHash,
reason: 'Re-enrich must preserve the encoded audio',
);
});
}
}