mirror of
https://github.com/zarzet/SpotiFLAC-Mobile.git
synced 2026-09-15 06:15:28 +02:00
225 lines
6.0 KiB
Dart
225 lines
6.0 KiB
Dart
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',
|
|
);
|
|
});
|
|
}
|
|
}
|