Files
SpotiFLAC-Mobile/test/download_metadata_embedding_test.dart
T

598 lines
19 KiB
Dart

import 'dart:async';
import 'dart:convert';
import 'dart:io';
import 'package:flutter/services.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:spotiflac_android/models/settings.dart';
import 'package:spotiflac_android/models/track.dart';
import 'package:spotiflac_android/providers/extension_provider.dart';
import 'package:spotiflac_android/services/download_metadata_embedding.dart';
import 'package:spotiflac_android/services/ffmpeg_service.dart';
import 'package:spotiflac_android/utils/artist_utils.dart';
class EmbeddingHarness {
final events = <String>[];
final nativeWrites = <Map<String, String>>[];
final fallbackWrites = <Map<String, String>>[];
late final DownloadMetadataEmbedding service;
EmbeddingHarness({
bool nativeSuccess = true,
bool gainWriteSuccess = true,
ReplayGainResult? gain,
Future<String?> Function(String, int)? loadCover,
Future<String> Function(Track)? loadLyrics,
}) {
service = DownloadMetadataEmbedding(
onReplayGain: (track, path, result) => events.add('aggregate'),
loadCover: loadCover,
loadLyrics:
loadLyrics ??
(_) async {
events.add('lyrics');
return '[00:01.00]Line';
},
editMetadata: (path, metadata) async {
events.add('native');
nativeWrites.add(Map.of(metadata));
return {
'success': nativeSuccess,
'method': nativeSuccess ? 'rust' : 'ffmpeg',
};
},
writeWithFFmpeg: (path, format, cover, metadata, artistMode) async {
events.add('ffmpeg');
fallbackWrites.add(Map.of(metadata));
return path;
},
scanReplayGain: (_) async {
events.add('scan');
return gain;
},
scanAndApplyReplayGain: (_) async {
events.add('scanAndApply');
return gain;
},
writeReplayGain: (_, _, _) async {
events.add('writeGain');
return gainWriteSuccess;
},
);
}
}
void main() {
TestWidgetsFlutterBinding.ensureInitialized();
const channel = MethodChannel('com.zarz.spotiflac/backend');
final messenger =
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger;
late Directory directory;
final ac4Writes = <Map<String, String>>[];
final splitRewrites = <Map<Object?, Object?>>[];
var handledAc4 = false;
const track = Track(
id: 'example:recording',
source: 'example',
name: 'Song',
artistName: 'Artist, Guest',
albumName: 'Album',
albumArtist: 'Artist, Guest',
duration: 180,
trackNumber: 2,
totalTracks: 12,
discNumber: 1,
totalDiscs: 2,
releaseDate: '2026-01-02',
isrc: 'USAAA2400001',
composer: 'Composer',
genre: 'Genre',
label: 'Label',
copyright: 'Copyright',
comment: 'Comment',
explicit: true,
albumType: 'Compilation',
upc: '012345678901',
);
const gain = ReplayGainResult(
trackGain: '-5.00 dB',
trackPeak: '0.900000',
integratedLufs: -13,
truePeakLinear: 0.9,
);
setUp(() async {
directory = await Directory.systemTemp.createTemp('download-embedding-');
ac4Writes.clear();
splitRewrites.clear();
handledAc4 = false;
messenger.setMockMethodCallHandler(channel, (call) async {
final args = call.arguments as Map<Object?, Object?>;
switch (call.method) {
case 'acquireDownloadDirectory':
return 'test-scope';
case 'releaseDownloadDirectory':
return null;
case 'writeAC4Metadata':
ac4Writes.add(
Map<String, String>.from(
jsonDecode(args['metadata_json'] as String) as Map,
),
);
return jsonEncode({'handled': handledAc4});
case 'rewriteSplitArtistTags':
splitRewrites.add(args);
return jsonEncode({'success': true});
}
fail('Unexpected backend call: ${call.method}');
});
});
tearDown(() async {
messenger.setMockMethodCallHandler(channel, null);
await directory.delete(recursive: true);
});
for (final format in ['flac', 'm4a', 'mp3', 'opus']) {
test(
'$format native tags retain writer-specific keys and full credits',
() async {
final harness = EmbeddingHarness();
await harness.service.embed(
'${directory.path}/song.$format',
track,
settings: const AppSettings(
embedLyrics: false,
artistTagMode: artistTagModeSplitVorbis,
),
extensionState: const ExtensionState(),
format: format,
genre: 'Override',
label: 'Override label',
copyright: 'Override copyright',
comment: 'Override comment',
);
expect(harness.nativeWrites.single, {
'title': 'Song',
'artist': 'Artist, Guest',
'album': 'Album',
'album_artist': 'Artist, Guest',
'date': '2026-01-02',
'isrc': 'USAAA2400001',
'composer': 'Composer',
'genre': 'Override',
'label': 'Override label',
'copyright': 'Override copyright',
'comment': 'Override comment',
'track_number': '2',
'track_total': '12',
'disc_number': '1',
'disc_total': '2',
'explicit': '1',
'album_type': 'Compilation',
'upc': '012345678901',
'compilation': '1',
'artist_tag_mode': artistTagModeSplitVorbis,
if (format == 'flac' || format == 'm4a') 'lyrics': '',
if (format == 'flac') 'unsyncedlyrics': '',
});
expect(harness.fallbackWrites, isEmpty);
expect(splitRewrites, isEmpty);
},
);
test(
'$format fallback preserves totals, legacy tags and lyric clearing',
() async {
final harness = EmbeddingHarness(nativeSuccess: false);
await harness.service.embed(
'${directory.path}/song.$format',
track,
settings: const AppSettings(embedLyrics: false),
extensionState: const ExtensionState(),
format: format,
);
expect(harness.fallbackWrites.single, {
'TITLE': 'Song',
'ARTIST': 'Artist, Guest',
'ALBUM': 'Album',
'ALBUMARTIST': 'Artist, Guest',
'DATE': '2026-01-02',
'ISRC': 'USAAA2400001',
'COMPOSER': 'Composer',
'GENRE': 'Genre',
'ORGANIZATION': 'Label',
'COPYRIGHT': 'Copyright',
'COMMENT': 'Comment',
'TRACKNUMBER': '2/12',
'DISCNUMBER': '1/2',
'ITUNESADVISORY': '1',
'RELEASETYPE': 'compilation',
'BARCODE': '012345678901',
'COMPILATION': '1',
if (format == 'flac' || format == 'mp3') ...{
'TRACK': '2/12',
'DISC': '1/2',
'YEAR': '2026',
},
if (format == 'flac' || format == 'm4a') 'LYRICS': '',
if (format == 'flac') 'UNSYNCEDLYRICS': '',
});
expect(harness.events, ['native', 'ffmpeg']);
},
);
}
test(
'AC-4 resolves primary credits and stops after the native MP4 writer',
() async {
handledAc4 = true;
final harness = EmbeddingHarness();
final result = await harness.service.embed(
'${directory.path}/song.m4a',
track,
settings: const AppSettings(artistTagMode: artistTagModePrimary),
extensionState: const ExtensionState(),
format: 'm4a',
lyricsLrc: '[00:02.00]Prepared line',
);
expect(result, '[00:02.00]Prepared line');
expect(ac4Writes.single, {
'title': 'Song',
'artist': 'Artist',
'album': 'Album',
'albumArtist': 'Artist',
'date': '2026-01-02',
'genre': 'Genre',
'composer': 'Composer',
'trackNumber': '2',
'totalTracks': '12',
'discNumber': '1',
'totalDiscs': '2',
'isrc': 'USAAA2400001',
'label': 'Label',
'copyright': 'Copyright',
'comment': 'Comment',
'lyrics': result,
});
expect(harness.events, isEmpty);
},
);
for (final mode in ['embed', 'external', 'both']) {
test(
'lyrics mode $mode reuses a supplied LRC and honors sidecar writes',
() async {
final harness = EmbeddingHarness();
final path = '${directory.path}/song.flac';
final result = await harness.service.embed(
path,
track,
settings: AppSettings(lyricsMode: mode),
extensionState: const ExtensionState(),
format: 'flac',
lyricsLrc: '[00:01.00]Prepared line',
);
expect(result, '[00:01.00]Prepared line');
expect(harness.events, ['native']);
expect(
harness.nativeWrites.single['lyrics'],
mode == 'external' ? '' : result,
);
final sidecar = File('${directory.path}/song.lrc');
expect(await sidecar.exists(), mode != 'embed');
if (await sidecar.exists()) {
expect(await sidecar.readAsString(), result);
}
},
);
}
test('SAF caller receives lyrics without writing a local sidecar', () async {
final harness = EmbeddingHarness();
final result = await harness.service.embed(
'${directory.path}/song.flac',
track,
settings: const AppSettings(lyricsMode: 'both'),
extensionState: const ExtensionState(),
format: 'flac',
writeExternalLrc: false,
);
expect(result, '[00:01.00]Line');
expect(harness.events, ['lyrics', 'native']);
expect(await File('${directory.path}/song.lrc').exists(), isFalse);
});
test(
'manifest skipLyrics suppresses both fetching and a supplied LRC',
() async {
final harness = EmbeddingHarness();
final state = ExtensionState(
extensions: [
Extension(
id: 'example-downloader',
name: 'example-downloader',
displayName: 'Example Downloader',
version: '1.0.0',
description: '',
enabled: true,
status: 'ready',
skipLyrics: true,
),
],
);
final result = await harness.service.embed(
'${directory.path}/song.flac',
track,
settings: const AppSettings(lyricsMode: 'both'),
extensionState: state,
format: 'flac',
downloadService: ' EXAMPLE-DOWNLOADER ',
lyricsLrc: '[00:01.00]Prepared line',
);
expect(result, isNull);
expect(harness.events, ['native']);
expect(harness.nativeWrites.single['lyrics'], '');
expect(await File('${directory.path}/song.lrc').exists(), isFalse);
},
);
for (final invalidLyrics in [
'[instrumental:true]',
'[ar:Artist]\n[ti:Song]',
]) {
test('non-display lyrics are discarded: $invalidLyrics', () async {
final harness = EmbeddingHarness(loadLyrics: (_) async => invalidLyrics);
final result = await harness.service.embed(
'${directory.path}/song.mp3',
track,
settings: const AppSettings(),
extensionState: const ExtensionState(),
format: 'mp3',
);
expect(result, isNull);
expect(harness.nativeWrites.single.containsKey('lyrics'), isFalse);
});
}
test(
'cover download overlaps lyrics and coalesces concurrent album tracks',
() async {
final coverStarted = Completer<void>();
final coverReady = Completer<String?>();
final lyricsStarted = Completer<void>();
var downloads = 0;
final coverFile = File('${directory.path}/cover.jpg');
await coverFile.writeAsBytes([1, 2, 3]);
final harness = EmbeddingHarness(
loadCover: (url, dimension) {
downloads++;
expect(url, 'https://example.test/cover');
if (!coverStarted.isCompleted) coverStarted.complete();
return coverReady.future;
},
loadLyrics: (_) async {
expect(coverStarted.isCompleted, isTrue);
if (!lyricsStarted.isCompleted) lyricsStarted.complete();
return '[00:01.00]Line';
},
);
final withCover = track.copyWith(coverUrl: 'https://example.test/cover');
final first = harness.service.embed(
'${directory.path}/first.flac',
withCover,
settings: const AppSettings(),
extensionState: const ExtensionState(),
format: 'flac',
);
final second = harness.service.embed(
'${directory.path}/second.flac',
withCover,
settings: const AppSettings(),
extensionState: const ExtensionState(),
format: 'flac',
);
await lyricsStarted.future;
expect(downloads, 1);
expect(harness.nativeWrites, isEmpty);
coverReady.complete(coverFile.path);
expect(await Future.wait([first, second]), [
'[00:01.00]Line',
'[00:01.00]Line',
]);
expect(harness.nativeWrites.map((fields) => fields['cover_path']), [
coverFile.path,
coverFile.path,
]);
harness.service.clearCoverCache();
await Future<void>.delayed(Duration.zero);
expect(await coverFile.exists(), isFalse);
},
);
test(
'failed covers retry and cache dimensions select separate artwork',
() async {
final requests = <int>[];
final harness = EmbeddingHarness(
loadCover: (_, dimension) async {
requests.add(dimension);
if (requests.length == 1) throw StateError('cover unavailable');
if (requests.length == 2) return null;
final path = '${directory.path}/cover-$dimension.jpg';
await File(path).writeAsBytes([1]);
return path;
},
);
for (final dimension in [100, 100, 100, 100, 200]) {
await harness.service.embed(
'${directory.path}/song.flac',
track.copyWith(coverUrl: 'https://example.test/cover'),
settings: AppSettings(
embedLyrics: false,
embeddedCoverMaxDimension: dimension,
),
extensionState: const ExtensionState(),
format: 'flac',
);
}
expect(requests, [100, 100, 100, 200]);
expect(harness.nativeWrites.first.containsKey('cover_path'), isFalse);
expect(
harness.nativeWrites.last['cover_path'],
'${directory.path}/cover-200.jpg',
);
harness.service.clearCoverCache();
await Future<void>.delayed(Duration.zero);
},
);
test(
'cover LRU touch keeps reused release and deletes the least recent file',
() async {
final paths = <String, String>{};
final harness = EmbeddingHarness(
loadCover: (url, _) async {
final path = '${directory.path}/cover-${paths.length}.jpg';
await File(path).writeAsBytes([1]);
paths[url] = path;
return path;
},
);
for (final id in [0, 1, 2, 3, 4, 5, 6, 7, 0, 8]) {
await harness.service.embed(
'${directory.path}/song.flac',
track.copyWith(coverUrl: 'https://example.test/cover-$id'),
settings: const AppSettings(embedLyrics: false),
extensionState: const ExtensionState(),
format: 'flac',
);
}
expect(paths.length, 9);
expect(
await File(paths['https://example.test/cover-0']!).exists(),
isTrue,
);
expect(
await File(paths['https://example.test/cover-1']!).exists(),
isFalse,
);
harness.service.clearCoverCache();
await Future<void>.delayed(Duration.zero);
for (final path in paths.values) {
expect(await File(path).exists(), isFalse);
}
},
);
test(
'synchronously failed artwork is contained while lyrics are pending',
() async {
var requests = 0;
final lyrics = Completer<String>();
final startedLyrics = Completer<void>();
final harness = EmbeddingHarness(
loadCover: (_, _) {
requests++;
throw StateError('cover unavailable');
},
loadLyrics: (_) {
if (!startedLyrics.isCompleted) startedLyrics.complete();
return lyrics.future;
},
);
final selected = track.copyWith(
coverUrl: 'https://example.test/failed-cover',
);
final first = harness.service.embed(
'${directory.path}/first.flac',
selected,
settings: const AppSettings(),
extensionState: const ExtensionState(),
format: 'flac',
);
await startedLyrics.future;
await Future<void>.delayed(Duration.zero);
expect(harness.nativeWrites, isEmpty);
lyrics.complete('[00:01.00]Line');
expect(await first, '[00:01.00]Line');
await harness.service.embed(
'${directory.path}/second.flac',
selected,
settings: const AppSettings(),
extensionState: const ExtensionState(),
format: 'flac',
);
expect(requests, 2);
expect(
harness.nativeWrites.every(
(fields) => !fields.containsKey('cover_path'),
),
isTrue,
);
},
);
for (final format in ['flac', 'm4a', 'mp3', 'opus']) {
test('$format gain aggregation preserves writer ordering', () async {
final harness = EmbeddingHarness(gain: gain);
await harness.service.embed(
'${directory.path}/song.$format',
track,
settings: const AppSettings(embedLyrics: false, embedReplayGain: true),
extensionState: const ExtensionState(),
format: format,
);
expect(harness.events, switch (format) {
'flac' => ['native', 'scanAndApply', 'aggregate'],
'm4a' => ['scan', 'aggregate', 'ffmpeg', 'native'],
'mp3' => ['scan', 'aggregate', 'ffmpeg'],
_ => ['scan', 'native', 'writeGain', 'aggregate'],
});
if (format == 'm4a' || format == 'mp3') {
expect(
harness.fallbackWrites.single['REPLAYGAIN_TRACK_GAIN'],
gain.trackGain,
);
}
if (format == 'opus') {
expect(
harness.nativeWrites.single.containsKey('replaygain_track_gain'),
isFalse,
);
}
});
}
test('failed Opus R128 save does not aggregate unpersisted gain', () async {
final harness = EmbeddingHarness(gain: gain, gainWriteSuccess: false);
await harness.service.embed(
'${directory.path}/song.opus',
track,
settings: const AppSettings(embedLyrics: false, embedReplayGain: true),
extensionState: const ExtensionState(),
format: 'opus',
);
expect(harness.events, ['scan', 'native', 'writeGain']);
});
test(
'metadata disabled still writes and aggregates enabled ReplayGain',
() async {
final harness = EmbeddingHarness(gain: gain);
final result = await harness.service.embed(
'${directory.path}/song.flac',
track,
settings: const AppSettings(
embedMetadata: false,
embedReplayGain: true,
),
extensionState: const ExtensionState(),
format: 'flac',
);
expect(result, isNull);
expect(harness.events, ['scanAndApply', 'aggregate']);
},
);
}