Restructure step 4: local library and media identity
ci / core (push) Successful in 29s
ci / server (push) Successful in 1m8s
ci / updater-windows (push) Failing after 6m2s

- L1 (core, offline): release names with kinds (episode, movie, extra:
  OP/ED/Opening/Ending/NC/PV/OVA…), folder hints (season and name from
  parentheticals, ranges), one show across folders, gaps and next with
  skipped numbers, junk ignored, stable keys.
- L2 (app, device-owned cache): AniList match with synonyms and
  sequel walking, episode mapping across cours (AnimeFlow runs when
  signed in), art and episode titles, "Wrong show?" pins per show or
  folder, episode offsets; lazy, spaced requests, works offline.
- Files tab as a library: poster cards, show pages with banner, gap
  prompt, extras, movies, Wrong show? and Episode numbers.
- media.ref produced for local files, Jellyfin (provider ids) and links,
  carried in swaps and Up next; receivers name media from it.
- Hosting relays each Jellyfin item through its own server's login.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
foxyzandClaude Opus 5.5 committed 2026-10-15 23:34:39 +03:00
1 parent 4d00b7455e
commit bf9fc193b3
44 files changed
+4350 -891

No files matched your search

+224 -160
View File
@@ -1,5 +1,3 @@
// restructure: rewrite in step 4 (Library + Media identity): series
// grouping on the page follows L1/L2.
import 'dart:async';
import 'dart:io';
import 'dart:typed_data';
@@ -12,21 +10,54 @@ import 'package:starsync/src/files/scanner.dart';
import 'package:starsync/src/files/thumbnails.dart';
import 'package:starsync/src/files/ui/file_tiles.dart';
import 'package:starsync/src/files/ui/files_page.dart';
import 'package:starsync/src/files/ui/library_view.dart';
import 'package:starsync/src/library/library_store.dart';
import 'package:starsync/src/library/show_matcher.dart';
import 'package:starsync/src/schedule/anilist.dart';
import 'test_graph.dart';
/// This file's composition root (see test_graph.dart).
final _graph = testGraph();
// The Files page over a real (tiny) folder tree: the scan runs in-line
// instead of in an isolate and thumbnails come from a fake loader, so
// nothing here decodes video.
// The Files tab as a library over a real (tiny) generated tree with the
// names of a real anime folder: the scan runs in-line instead of in an
// isolate, thumbnails come from a fake loader and AniList is a fake, so
// nothing here decodes video or goes online.
final _tmp = Platform.environment['WT_TEST_TMP'] ?? Directory.systemTemp.path;
late Directory tmp;
late String anime;
late FolderStore store;
late List<String> thumbRequests;
late int scans;
late ShowMatcher matcher;
late FakeAniList aniList;
const b1 = '[Judas] Bleach 001-055 [BD 1080p][HEVC x265 10bit]';
const b2 = '[Judas] Bleach 056-111 [BD 1080p][HEVC x265 10bit]';
const b3 = '[Judas] Bleach 112-139 [BD 1080p][HEVC x265 10bit][Dual-Audio][Eng-Subs]';
const sao = '[Judas] Sword Art Online (Season 3 - Alicization) [BD 1080p][HEVC x265 10bit][Eng-Subs]';
const kimetsu =
'[Erai-raws] Kimetsu no Yaiba - Mugenjou Hen - Movie 01 [1080p CR WEBRip HEVC AAC][MultiSub][8E47462C].mkv';
String bleach(int n) => '$anime/${n < 112 ? b2 : b3}/[Judas] Bleach - ${n.toString().padLeft(3, '0')}.mkv';
const _tybw = AniListEntry(
id: 41467, romaji: 'BLEACH: Sennen Kessen-hen', english: 'Bleach: Thousand-Year Blood War', format: 'TV', episodes: 13);
class FakeAniList implements AniListLookup {
final searches = <String>[];
@override
Future<List<AniListEntry>> searchAnime(String query) async {
searches.add(query);
return query.toLowerCase().contains('thousand') ? const [_tybw] : const [];
}
@override
Future<AniListEntry?> anime(int id) async => null;
}
void touch(String path) => File(path)
..createSync(recursive: true)
@@ -56,6 +87,7 @@ Future<void> pumpPage(
Future<String?> Function(String path)? onPlay,
ThumbnailLoader? thumbnails,
Future<String?> Function()? pickFolder,
String? nowPlayingPath,
}) async {
tester.view.physicalSize = size;
tester.view.devicePixelRatio = 1;
@@ -69,45 +101,62 @@ Future<void> pumpPage(
thumbnails: thumbnails ?? fakeThumb,
scan: inlineScan,
store: store,
matcher: matcher,
pickFolder: pickFolder,
nowPlayingPath: nowPlayingPath,
),
),
)));
await frames(tester);
}
Future<void> openFolder(WidgetTester tester, String name) async {
Future<void> openPlace(WidgetTester tester, String name) async {
await tester.tap(find.text(name).first);
await frames(tester);
}
/// Series start collapsed: a click on the header lists the episodes.
Future<void> openSeries(WidgetTester tester, [String title = 'Show']) async {
await tester.tap(find.descendant(of: find.byType(SeriesHeader), matching: find.text(title)));
/// Scrolls the main list until [f] is on screen.
Future<void> reveal(WidgetTester tester, Finder f) async {
await tester.scrollUntilVisible(f, 150,
scrollable: find.descendant(of: find.byType(CustomScrollView), matching: find.byType(Scrollable)).first);
await frames(tester, 3);
}
/// A show's card in the library opens its page.
Future<void> openShow(WidgetTester tester, String title) async {
final card = find.descendant(of: find.byType(PosterCard), matching: find.text(title));
await reveal(tester, card);
await tester.tap(card);
await frames(tester);
}
void main() {
setUp(() async {
tmp = Directory.systemTemp.createTempSync('files_page_');
anime = '${tmp.path}/anime';
for (final f in [
'[Grp] Show - 01.mkv',
'[Grp] Show - 02.mkv',
'[Grp] Show - 03.mkv',
'[Grp] Show - NCOP1.mkv',
'[Grp] Show - 01.srt',
'Some Movie (2019).mkv',
'notes.txt',
'extras/cover.jpg',
]) {
touch('$anime/$f');
tmp = Directory(_tmp).createTempSync('files_page_');
anime = '${tmp.path}/Binux';
for (final j in ['App', 'Audio', 'Video']) {
touch('$anime/$b1/$j#D4aIyEEtZEWR9gBorzBWiMh_.lnk');
}
for (final n in [56, 57, 58, 59, 60, 61, 62, 63, 64, 65, 108, 109, 110, 111, 112, 113]) {
touch(bleach(n));
}
for (var n = 1; n <= 3; n++) {
touch('$anime/$sao/[Judas] Sword Art Online S3 - 0$n.mkv');
}
for (final x in ['Opening 1', 'Ending 1']) {
touch('$anime/$sao/[Judas] Sword Art Online S3 - $x.mkv');
}
touch('$anime/$sao/[Judas] Sword Art Online S3 - 01.srt');
touch('$anime/[Judas] Re.Zero - S04E17.mkv');
touch('$anime/$kimetsu');
touch('$anime/notes.txt');
// An in-memory box: the store loads and writes through it, with no
// file I/O left pending in the widget tests' fake-async zone.
await Hive.openBox(filesBoxName, bytes: Uint8List(0));
store = FolderStore();
await store.ensureLoaded();
aniList = FakeAniList();
matcher = ShowMatcher(store: MemoryLibraryStore(), aniList: aniList, delay: (_) async {});
thumbRequests = [];
scans = 0;
resetFilesPageSession();
@@ -118,102 +167,145 @@ void main() {
tmp.deleteSync(recursive: true);
});
testWidgets('sidebar names folders by their last segment, header follows', (tester) async {
store.addFolder('$anime/');
await pumpPage(tester, pickFolder: () async => '${tmp.path}/movies');
expect(find.text('Folders'), findsOneWidget);
expect(find.text('anime'), findsOneWidget);
expect(find.text('Recent'), findsNWidgets(2)); // sidebar + header
testWidgets('the Library first: empty until a folder is added, then its shows', (tester) async {
await pumpPage(tester, pickFolder: () async => anime);
expect(find.text('Library'), findsNWidgets(2)); // sidebar + header
expect(find.text('Your library is empty'), findsOneWidget);
await tester.tap(find.text('Add folder').first);
await frames(tester);
expect(store.folders.value, [anime]);
expect(find.text('Binux'), findsNWidgets(2)); // sidebar + header
expect(scans, 1);
// Back to the Library: the same scan, now grouped into shows.
await openPlace(tester, 'Library');
expect(scans, 1);
expect(find.text('3 shows'), findsOneWidget); // sidebar
expect(find.byType(PosterCard), findsNWidgets(4));
expect(find.text('Shows'), findsOneWidget);
expect(find.text('Movies'), findsOneWidget);
await openPlace(tester, 'Recent');
expect(find.text('Nothing played yet'), findsOneWidget);
expect(scans, 0, reason: 'folders are scanned when opened');
await openFolder(tester, 'anime');
expect(find.text('anime'), findsNWidgets(2)); // sidebar + header
expect(find.text('$anime/'), findsOneWidget); // full path under it
expect(find.text('Recent'), findsOneWidget);
expect(find.text('5 videos'), findsOneWidget); // sidebar subtitle
expect(scans, 1);
// Back and forth: the scan is kept.
await openFolder(tester, 'Recent');
await openFolder(tester, 'anime');
expect(scans, 1);
// Add folder: picked, stored, opened.
Directory('${tmp.path}/movies').createSync();
await tester.tap(find.text('Add folder'));
await tester.tap(find.byTooltip('Remove folder'));
await frames(tester);
expect(store.folders.value, ['$anime/', '${tmp.path}/movies']);
expect(find.text('movies'), findsNWidgets(2));
expect(find.text('No videos here'), findsOneWidget);
await tester.tap(find.byTooltip('Remove folder').last);
await frames(tester);
expect(store.folders.value, ['$anime/']);
expect(find.text('Recent'), findsNWidgets(2));
expect(store.folders.value, isEmpty);
});
testWidgets('episodes group into series, the rest is a card grid', (tester) async {
testWidgets('shows, a movie, counts and what is hidden', (tester) async {
store.addFolder(anime);
await pumpPage(tester);
await openFolder(tester, 'anime');
expect(find.byType(SeriesHeader), findsOneWidget);
expect(find.text('Show'), findsOneWidget);
expect(find.text('3 episodes · 1 extra · Grp'), findsOneWidget);
// Collapsed until clicked; open for the rest of the session.
expect(find.text('E01'), findsNothing);
await openSeries(tester);
for (final l in ['E01', 'E02', 'E03', 'NCOP1']) {
expect(find.text(l), findsOneWidget);
for (final t in ['Bleach', 'Re.Zero', 'Sword Art Online']) {
expect(find.descendant(of: find.byType(PosterCard), matching: find.text(t)), findsOneWidget);
}
expect(find.text('[Grp] Show - 02.mkv'), findsOneWidget);
expect(find.byType(VideoCard), findsOneWidget);
expect(find.text('Some Movie (2019)'), findsOneWidget);
expect(find.text('Other videos'), findsOneWidget);
// Durations come from the thumbnail.
expect(find.text('24:00 · 1 KB'), findsNWidgets(4));
expect(find.text('24:00'), findsOneWidget); // card badge
expect(find.text('16 episodes'), findsOneWidget); // Bleach
expect(find.text('Kimetsu no Yaiba - Mugenjou Hen'), findsOneWidget);
expect(find.text('Movie 1 · 1 KB'), findsOneWidget);
expect(find.text('23 videos in 3 shows · 4 other files hidden · 1 subtitle file'), findsOneWidget);
expect(find.byType(VideoCard), findsNothing, reason: 'nothing is left over');
await openFolder(tester, 'Recent');
await openFolder(tester, 'anime');
expect(find.text('E01'), findsOneWidget);
await openSeries(tester);
expect(find.text('E01'), findsNothing);
expect(find.byType(VideoCard), findsOneWidget);
});
testWidgets('hidden files are counted and some can be shown', (tester) async {
store.addFolder(anime);
await pumpPage(tester);
await openFolder(tester, 'anime');
expect(find.text('5 videos in 1 series · 2 other files hidden · 1 subtitle file'),
findsOneWidget);
expect(find.text('notes.txt'), findsNothing);
// The folder view says which files were left out.
await openPlace(tester, 'Binux');
await tester.tap(find.text('Show some'));
await frames(tester);
expect(find.text('notes.txt'), findsOneWidget);
expect(find.text('cover.jpg'), findsOneWidget);
expect(find.text('not a video'), findsNWidgets(2));
await tester.tap(find.text('Hide them'));
await frames(tester);
expect(find.text('notes.txt'), findsNothing);
expect(find.text('App#D4aIyEEtZEWR9gBorzBWiMh_.lnk'), findsOneWidget);
});
testWidgets('rescan picks up new files', (tester) async {
testWidgets('a show page: season with its name, episodes, extras', (tester) async {
store.addFolder(anime);
await pumpPage(tester);
await openFolder(tester, 'anime');
await openSeries(tester);
touch('$anime/[Grp] Show - 04.mkv');
expect(find.text('E04'), findsNothing);
await tester.tap(find.text('Rescan'));
await openShow(tester, 'Sword Art Online');
expect(find.text('Season 3 · Alicization'), findsOneWidget);
for (final e in ['Episode 1', 'Episode 2', 'Episode 3']) {
expect(find.text(e), findsOneWidget);
}
expect(find.text('Extras'), findsOneWidget);
expect(find.text('OP1'), findsOneWidget);
expect(find.text('ED1'), findsOneWidget);
expect(find.text('Play: S3 E1'), findsOneWidget);
expect(find.text('Not matched on AniList'), findsOneWidget);
// The open show stays open for the session; Back returns.
await openPlace(tester, 'Recent');
await openPlace(tester, 'Library');
expect(find.text('Season 3 · Alicization'), findsOneWidget);
await tester.tap(find.text('Library').last);
await frames(tester);
expect(scans, 2);
expect(find.text('E04'), findsOneWidget);
expect(find.text('4 episodes · 1 extra · Grp'), findsOneWidget);
expect(find.byType(PosterCard), findsNWidgets(4));
});
testWidgets('a play goes to onPlay and into Recent', (tester) async {
testWidgets('next episode knows what is missing: start at 56, then skip to 108', (tester) async {
store.addFolder(anime);
final played = <String>[];
await pumpPage(tester, onPlay: (p) async {
played.add(p);
return null;
});
await openShow(tester, 'Bleach');
expect(find.text('1–55 aren\'t in your folders — start at 56?'), findsOneWidget);
await tester.tap(find.text('Start at 56'));
await frames(tester);
expect(played, [bleach(56)]);
await reveal(tester, find.text('66–107 aren\'t in your folders')); // in the list
// Watched up to 65: the next one is 108, and the page says why.
await reveal(tester, find.text('Episode 65'));
await tester.tap(find.text('Episode 65'));
await frames(tester);
expect(store.recent.value.first, bleach(65));
await tester.drag(find.byType(CustomScrollView), const Offset(0, 3000));
await frames(tester, 3);
expect(find.text('66–107 aren\'t in your folders — skip to 108?'), findsOneWidget);
await tester.tap(find.text('Skip to 108'));
await frames(tester);
expect(played.last, bleach(108));
expect(find.text('Next: 109'), findsOneWidget);
// The card says it too.
await tester.tap(find.text('Library').last);
await frames(tester);
expect(find.text('Next: 109'), findsOneWidget);
// Recent names the show and episode.
await openPlace(tester, 'Recent');
expect(find.text('Bleach · E108'), findsOneWidget);
});
testWidgets('Wrong show? pins the right AniList entry and the names follow', (tester) async {
store.addFolder(anime);
await pumpPage(tester);
await openShow(tester, 'Bleach');
await tester.tap(find.text('Wrong show?'));
await frames(tester);
await tester.enterText(find.byType(TextField), 'Bleach Thousand-Year Blood War');
await tester.testTextInput.receiveAction(TextInputAction.done);
await frames(tester);
expect(aniList.searches.last, 'Bleach Thousand-Year Blood War');
await tester.tap(find.text('Bleach: Thousand-Year Blood War'));
await frames(tester);
expect(matcher.showPin('bleach'), 41467);
expect(find.text('Bleach: Thousand-Year Blood War'), findsOneWidget); // the page's title
expect(find.text('pinned'), findsOneWidget);
expect(find.textContaining('files say "Bleach"'), findsOneWidget);
await tester.tap(find.text('Library').last);
await frames(tester);
expect(find.descendant(of: find.byType(PosterCard), matching: find.text('Bleach: Thousand-Year Blood War')),
findsOneWidget);
});
testWidgets('episode numbers: the first file is episode N', (tester) async {
store.addFolder(anime);
await pumpPage(tester);
await openShow(tester, 'Sword Art Online');
await tester.tap(find.text('Episode numbers…'));
await frames(tester);
await tester.enterText(find.byKey(const ValueKey('offset:3')), '25');
await tester.tap(find.text('Save'));
await frames(tester);
expect(matcher.offset('sword art online', 3), 24);
expect(find.text('Episode 25'), findsOneWidget);
});
testWidgets('a play goes to onPlay and into Recent; failures are shown, not kept', (tester) async {
store.addFolder(anime);
final played = <String>[];
String? reply;
@@ -222,66 +314,39 @@ void main() {
return reply;
});
expect(find.textContaining('Keep the app open while it plays'), findsOneWidget);
await openFolder(tester, 'anime');
await openSeries(tester);
await tester.tap(find.text('E02'));
await frames(tester);
expect(played, ['$anime/[Grp] Show - 02.mkv']);
expect(store.recent.value, ['$anime/[Grp] Show - 02.mkv']);
reply = 'Nobody can play mkv here';
await tester.tap(find.text('Some Movie (2019)'));
await reveal(tester, find.text('Kimetsu no Yaiba - Mugenjou Hen'));
await tester.tap(find.text('Kimetsu no Yaiba - Mugenjou Hen'));
await frames(tester);
expect(played, hasLength(2));
await tester.drag(find.byType(CustomScrollView), const Offset(0, 3000));
await frames(tester);
expect(played, ['$anime/$kimetsu']);
expect(find.text('Nobody can play mkv here'), findsOneWidget);
expect(store.recent.value, hasLength(1), reason: 'failed plays are not recent');
await openFolder(tester, 'Recent');
expect(find.text('Show · E02'), findsOneWidget);
expect(find.text('[Grp] Show - 02.mkv'), findsOneWidget);
expect(store.recent.value, isEmpty);
});
testWidgets('now playing is marked', (tester) async {
testWidgets('now playing is marked on its show and row', (tester) async {
store.addFolder(anime);
tester.view.physicalSize = const Size(1280, 800);
tester.view.devicePixelRatio = 1;
addTearDown(tester.view.reset);
await tester.pumpWidget(scoped(_graph, MaterialApp(
home: Scaffold(
body: FilesPage(
relayed: false,
onPlay: (_) async => null,
nowPlayingPath: '$anime/[Grp] Show - 03.mkv',
thumbnails: fakeThumb,
scan: inlineScan,
store: store,
),
),
)));
await frames(tester);
await openFolder(tester, 'anime');
await openSeries(tester);
await pumpPage(tester, nowPlayingPath: '$anime/$sao/[Judas] Sword Art Online S3 - 02.mkv');
await openShow(tester, 'Sword Art Online');
expect(find.text('now playing'), findsOneWidget);
expect(find.byIcon(Icons.graphic_eq), findsOneWidget);
});
testWidgets('narrow: folders become chips above the list', (tester) async {
testWidgets('narrow: places become chips above the list', (tester) async {
store.addFolder('$anime/');
await pumpPage(tester, size: const Size(600, 800));
expect(find.text('Folders'), findsNothing);
expect(find.widgetWithText(InputChip, 'anime'), findsOneWidget);
expect(find.widgetWithText(ChoiceChip, 'Library'), findsOneWidget);
expect(find.widgetWithText(ChoiceChip, 'Recent'), findsOneWidget);
await tester.tap(find.widgetWithText(InputChip, 'anime'));
await frames(tester);
expect(find.text('$anime/'), findsOneWidget);
await openSeries(tester);
expect(find.text('E01'), findsOneWidget);
// The chip's x removes the folder, like the sidebar's.
expect(find.widgetWithText(InputChip, 'Binux'), findsOneWidget);
await openShow(tester, 'Re.Zero');
expect(find.text('Season 4'), findsOneWidget);
expect(find.text('16 isn\'t in your folders'), findsNothing);
expect(find.text('1–16 aren\'t in your folders — start at 17?'), findsOneWidget);
await tester.tap(find.byTooltip('Remove folder'));
await frames(tester);
expect(store.folders.value, isEmpty);
expect(find.widgetWithText(InputChip, 'anime'), findsNothing);
expect(tester.takeException(), isNull);
});
@@ -289,53 +354,52 @@ void main() {
testWidgets('no overflow at ${size.width.toInt()}x${size.height.toInt()}', (tester) async {
store.addFolder(anime);
for (var i = 0; i < 7; i++) {
touch('$anime/A Movie With A Rather Long Title Indeed Part $i (2020).mkv');
touch('$anime/clips/A Clip With A Rather Long Title Indeed Part $i.mkv');
}
store.addRecent('$anime/[Grp] Show - 01.mkv');
store.addRecent(bleach(56));
await pumpPage(tester, size: size, relayed: true);
expect(tester.takeException(), isNull);
await tester.tap(find.text('anime').first);
await tester.drag(find.byType(CustomScrollView), const Offset(0, -2000));
await frames(tester);
await openSeries(tester);
expect(find.text('E01'), findsOneWidget);
expect(tester.takeException(), isNull);
await tester.tap(find.text('Show some'));
await tester.drag(find.byType(CustomScrollView), const Offset(0, 4000));
await frames(tester);
await openShow(tester, 'Bleach');
await reveal(tester, find.text('Episode 56'));
expect(tester.takeException(), isNull);
await tester.drag(find.byType(CustomScrollView), const Offset(0, -2000));
await frames(tester);
expect(tester.takeException(), isNull);
});
}
testWidgets('thumbnails load for visible tiles only, and cancel when scrolled away',
(tester) async {
testWidgets('thumbnails load for visible rows only, and cancel when scrolled away', (tester) async {
final big = '${tmp.path}/big';
for (var i = 1; i <= 120; i++) {
touch('$big/Long Show - ${i.toString().padLeft(3, '0')}.mkv');
}
store.addFolder(big);
final gates = <String, bool Function()>{};
await pumpPage(tester, size: const Size(1100, 700),
thumbnails: (path, {cancelled}) {
await pumpPage(tester, size: const Size(1100, 700), thumbnails: (path, {cancelled}) {
thumbRequests.add(path);
gates[path] = cancelled!;
return Completer<Thumbnail?>().future; // never finishes
});
await openFolder(tester, 'big');
await openSeries(tester, 'Long Show');
expect(find.text('E01'), findsOneWidget);
await openShow(tester, 'Long Show');
expect(find.text('Episode 1'), findsOneWidget);
final first = thumbRequests.toSet();
expect(first, isNotEmpty);
expect(first.length, lessThan(30));
expect(first, contains('$big/Long Show - 001.mkv'));
expect(gates['$big/Long Show - 001.mkv']!(), isFalse);
// (Episode 1's frame is also the header's banner, which stays.)
expect(first, contains('$big/Long Show - 002.mkv'));
expect(gates['$big/Long Show - 002.mkv']!(), isFalse);
// Fly past the middle: tiles that only flash by never ask.
// Fly past the middle: rows that only flash by never ask.
await tester.drag(find.byType(CustomScrollView), const Offset(0, -6000));
await tester.pump();
await tester.drag(find.byType(CustomScrollView), const Offset(0, -6000));
await frames(tester);
expect(gates['$big/Long Show - 001.mkv']!(), isTrue);
expect(gates['$big/Long Show - 002.mkv']!(), isTrue);
expect(thumbRequests, contains('$big/Long Show - 120.mkv'));
expect(thumbRequests.toSet().length, lessThan(60));
});
+48 -40
View File
@@ -1,10 +1,14 @@
// restructure: rewrite in step 4 (Library + Media identity): groupBySeries
// becomes L1 local grouping; the scan and classify tests stay.
import 'dart:io';
import 'package:flutter_test/flutter_test.dart';
import 'package:starsync/src/files/name_parser.dart';
import 'package:starsync/src/files/scanner.dart';
import 'package:sync_relay_core/core.dart' show groupLocal, readLocalFile, EpisodeGap, LocalKind;
// The Files scan on a real (generated) tree, and the library's L1
// grouping over what it found (core's own tests cover the grouping
// rules on paths alone).
final _tmp = Platform.environment['WT_TEST_TMP'] ?? Directory.systemTemp.path;
late Directory _root;
@@ -19,11 +23,8 @@ void _file(String rel, {int size = 10}) {
}
}
VideoFile _v(String name) =>
VideoFile('/x/$name', 1, parseReleaseName(name), DateTime(2020));
void main() {
setUp(() => _root = Directory.systemTemp.createTempSync('files_scan_'));
setUp(() => _root = Directory(_tmp).createTempSync('files_scan_'));
tearDown(() => _root.deleteSync(recursive: true));
test('classifies, skips hidden and samples, pairs sidecar subtitles',
@@ -69,7 +70,7 @@ void main() {
isFalse);
final ep = scan.videos.firstWhere((v) => v.name.contains('- 02'));
expect(ep.parsed.episode, 2);
expect(readLocalFile(ep).episode, 2);
expect(ep.size, 10);
expect(ep.modified.year, DateTime.now().year);
});
@@ -115,38 +116,45 @@ void main() {
expect(kindOf('mkv'), FileKind.other);
});
group('groupBySeries', () {
test('groups across spellings, sorts by season then episode', () {
final g = groupBySeries([
_v('[A] Re:Zero S2 - 02 [1080p].mkv'),
_v('[A] Re:Zero - 10 [1080p].mkv'),
_v('[B] Re Zero - 02 [720p].mkv'),
_v('[A] Re:Zero S2 - 01 [1080p].mkv'),
_v('[A] Re:Zero - NCOP1 [1080p].mkv'),
_v('Other Show - 03.mkv'),
_v('Other Show - 01.mkv'),
_v('Movie.2020.1080p.mkv'),
_v('vid_2024_final.mp4'),
_v('[A] Lonely - OVA.mkv'),
]);
expect(g.series.map((s) => s.title), ['Other Show', 'Re:Zero']);
final re = g.series[1];
expect(re.episodes.map((v) => v.parsed.label),
['E02', 'E10', 'S02E01', 'S02E02']);
expect(re.seasons, [1, 2]);
expect(re.extras.single.parsed.extra, 'NCOP1');
expect(g.series[0].episodes.map((v) => v.parsed.episode), [1, 3]);
expect(g.loose.map((v) => v.name), [
'[A] Lonely - OVA.mkv',
'Movie.2020.1080p.mkv',
'vid_2024_final.mp4',
]);
});
test('a generated tree of the Binux names, scanned and grouped (L1)', () async {
const b1 = '[Judas] Bleach 001-055 [BD 1080p][HEVC x265 10bit]';
const b2 = '[Judas] Bleach 056-111 [BD 1080p][HEVC x265 10bit]';
const b3 = '[Judas] Bleach 112-139 [BD 1080p][HEVC x265 10bit][Dual-Audio][Eng-Subs]';
const sao = '[Judas] Sword Art Online (Season 3 - Alicization) [BD 1080p][HEVC x265 10bit][Eng-Subs]';
String n3(int n) => n.toString().padLeft(3, '0');
// The first Bleach folder holds 1-byte shortcuts only.
for (final j in ['Amine', 'App', 'Audio', 'Data', 'Document', 'Download', 'Image', 'Internal storage', 'Recent', 'Video']) {
_file('$b1/$j#D4aIyEEtZEWR9gBorzBWiMh_.lnk', size: 1);
}
for (final n in [56, 57, 58, 59, 60, 61, 62, 63, 64, 65, 108, 109, 110, 111]) {
_file('$b2/[Judas] Bleach - ${n3(n)}.mkv');
}
for (var n = 112; n <= 139; n++) {
_file('$b3/[Judas] Bleach - ${n3(n)}.mkv');
}
for (var n = 1; n <= 24; n++) {
_file('$sao/[Judas] Sword Art Online S3 - ${n.toString().padLeft(2, '0')}.mkv');
}
for (final x in ['Opening 1', 'Opening 2', 'Opening 3', 'Ending 1', 'Ending 2']) {
_file('$sao/[Judas] Sword Art Online S3 - $x.mkv');
}
_file('[Judas] Re.Zero - S04E17.mkv');
_file('[Erai-raws] Kimetsu no Yaiba - Mugenjou Hen - Movie 01 [1080p CR WEBRip HEVC AAC][MultiSub][8E47462C].mkv');
test('empty input', () {
final g = groupBySeries(const []);
expect(g.series, isEmpty);
expect(g.loose, isEmpty);
});
final scan = await scanFolder(_root.path);
expect(scan.videos, hasLength(14 + 28 + 24 + 5 + 2));
expect(scan.hiddenCount, 10, reason: 'the shortcuts are not videos');
final lib = groupLocal(scan.videos, roots: [_root.path]);
expect(lib.shows.map((s) => s.title), ['Bleach', 'Re.Zero', 'Sword Art Online']);
final bleach = lib.show('bleach')!;
expect(bleach.folders, [_p(b2), _p(b3)]);
expect(bleach.seasons.single.gaps, const [EpisodeGap(1, 55), EpisodeGap(66, 107)]);
final s = lib.show('sword art online')!;
expect([s.seasons.single.number, s.seasons.single.name], [3, 'Alicization']);
expect(s.extras.map((e) => e.label), ['OP1', 'OP2', 'OP3', 'ED1', 'ED2']);
expect(lib.show('re zero')!.seasons.single.number, 4);
expect(lib.movies.single.kind, LocalKind.movie);
expect(lib.movies.single.title, 'Kimetsu no Yaiba - Mugenjou Hen');
expect(lib.other, isEmpty);
});
}
+5 -6
View File
@@ -193,9 +193,10 @@ void main() {
expect(plain.name, 'movie.mkv');
});
test('relay credential: the account token with exactly its own device fields', () async {
test('relay credentials: each account token with exactly its own device fields', () async {
final (accounts, _) = await linkedAccounts();
final cred = relayCredentialFor(accounts, serverId: fixtureServerId)!;
final creds = relayCredentialsFor(accounts);
final cred = creds.single;
final info = accounts.clientInfo;
expect(cred.token, 'TEST_ACCESS_TOKEN');
expect(cred.serverId, fixtureServerId);
@@ -203,13 +204,11 @@ void main() {
expect([cred.client, cred.device, cred.deviceId, cred.version],
[info.client, info.device, info.deviceId, info.version]);
expect(cred.device, 'Test PC');
// Another server's item: the default account still relays its own.
expect(relayCredentialFor(accounts, serverId: 'other')!.serverId, fixtureServerId);
accounts.markExpired(fixtureServerId);
expect(relayCredentialFor(accounts, serverId: fixtureServerId), isNull);
expect(relayCredentialsFor(accounts), isEmpty);
final none = JellyfinAccounts(store: MemoryJellyfinStore());
await none.ensureLoaded(validate: false);
expect(relayCredentialFor(none), isNull);
expect(relayCredentialsFor(none), isEmpty);
});
});
+91
View File
@@ -0,0 +1,91 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:starsync/src/library/media_refs.dart';
import 'package:starsync/src/session/now_playing_info.dart';
import 'package:sync_relay_core/core.dart';
// `media.ref` producers other than the library (Jellyfin items, links)
// and what receivers make of a ref.
void main() {
group('Jellyfin', () {
final series = JellyfinItem.fromJson({
'Id': 's',
'Name': 'Frieren',
'Type': 'Series',
'ProviderIds': {'AniList': '154587', 'Tvdb': '424536', 'Imdb': 'tt22248376'},
});
final episode = JellyfinItem.fromJson({
'Id': 'e',
'Name': 'The Journey\'s End',
'Type': 'Episode',
'SeriesName': 'Frieren',
'ParentIndexNumber': 1,
'IndexNumber': 1,
'ProviderIds': {'Tvdb': '9999999'},
});
test('provider ids: the show\'s for an episode, never the episode\'s own', () {
expect(series.providerId('anilist'), 154587);
expect(jellyfinMediaRef(episode, series: series),
const MediaRef(kind: 'episode', title: 'Frieren', anilistId: 154587, tvdbId: 424536, season: 1, ep: 1));
expect(jellyfinMediaRef(episode),
const MediaRef(kind: 'episode', title: 'Frieren', season: 1, ep: 1));
});
test('movies carry their own ids; specials are extras; folders none', () {
final movie = JellyfinItem.fromJson(
{'Id': 'm', 'Name': 'Suzume', 'Type': 'Movie', 'ProviderIds': {'AniList': '142770'}});
expect(jellyfinMediaRef(movie), const MediaRef(kind: 'movie', title: 'Suzume', anilistId: 142770));
final special = JellyfinItem.fromJson(
{'Id': 'x', 'Name': 'OVA', 'Type': 'Episode', 'SeriesName': 'Frieren', 'ParentIndexNumber': 0, 'IndexNumber': 1});
expect(jellyfinMediaRef(special)!.kind, 'extra');
expect(jellyfinMediaRef(series), isNull);
});
test('from a pick\'s lines alone', () {
expect(jellyfinPickRef('The Pilot', 'Show · S2E6'), const MediaRef(kind: 'episode', title: 'Show', season: 2, ep: 6));
expect(jellyfinPickRef('Movie', '2019'), const MediaRef(kind: 'movie', title: 'Movie'));
});
});
test('links: kind and title from the name, best effort', () {
expect(linkMediaRef('[SubsPlease] Frieren - 05 (1080p).mkv'),
const MediaRef(kind: 'episode', title: 'Frieren', absolute: 5));
expect(linkMediaRef('Show.S01E03.mkv'), const MediaRef(kind: 'episode', title: 'Show', season: 1, ep: 3));
expect(linkMediaRef('A funny cat'), const MediaRef(kind: 'movie', title: 'A funny cat'));
});
group('receivers', () {
test('a ref names the show and the episode; art stays by hash', () {
final np = nowPlayingOf({
'name': '[Judas] Re.Zero - S04E17.mkv',
'relay': {'source': 'm2'},
'art': {'poster': 'p', 'banner': 'b'},
'ref': {'kind': 'episode', 'title': 'Re:ZERO Season 4', 'anilistId': 189046, 'anilistEp': 17, 'season': 4, 'ep': 17},
})!;
expect([np.title, np.episode, np.posterHash, np.bannerHash], ['Re:ZERO Season 4', 'S4 E17', 'p', 'b']);
final abs = nowPlayingOf({
'name': '[Judas] Bleach - 108.mkv',
'ref': {'kind': 'episode', 'title': 'Bleach', 'anilistId': 269, 'anilistEp': 108, 'absolute': 108},
})!;
expect([abs.title, abs.episode], ['Bleach', 'E108']);
});
test('extras keep their marker, movies have no episode line, no ref falls back to the name', () {
expect(
nowPlayingOf({
'name': '[Judas] Sword Art Online S3 - Opening 1.mkv',
'ref': {'kind': 'extra', 'title': 'Sword Art Online: Alicization', 'anilistId': 100182},
})!
.episode,
'OP1');
final movie = nowPlayingOf({
'name': 'x.mkv',
'ref': {'kind': 'movie', 'title': 'Demon Slayer: Infinity Castle'},
})!;
expect([movie.title, movie.episode], ['Demon Slayer: Infinity Castle', null]);
final plain = nowPlayingOf({'name': '[SubsPlease] Frieren - 05.mkv'})!;
expect([plain.title, plain.episode], ['Frieren', 'E5']);
});
});
}
-169
View File
@@ -1,169 +0,0 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:starsync/src/files/name_parser.dart';
void _expect(
String name,
String series, {
int? season,
int? episode,
String? group,
int? year,
String? extra,
String? label,
}) {
final p = parseReleaseName(name);
final why = '$name -> $p';
expect(p.series, series, reason: why);
expect(p.season, season, reason: why);
expect(p.episode, episode, reason: why);
expect(p.group, group, reason: why);
expect(p.year, year, reason: why);
expect(p.extra, extra, reason: why);
if (label != null) expect(p.label, label, reason: why);
}
void main() {
group('anime', () {
test('SubsPlease dash number', () {
_expect('[SubsPlease] Sousou no Frieren - 25 (1080p) [A1B2C3D4].mkv',
'Sousou no Frieren',
episode: 25, group: 'SubsPlease', label: 'E25');
});
test('Erai-raws Nth Season', () {
_expect(
'[Erai-raws] Kusuriya no Hitorigoto 2nd Season - 04 [1080p][Multiple Subtitle].mkv',
'Kusuriya no Hitorigoto',
season: 2, episode: 4, group: 'Erai-raws', label: 'S02E04');
});
test('four-digit episode', () {
_expect('One Piece - 1120 [1080p].mkv', 'One Piece',
episode: 1120, label: 'E1120');
});
test('SxxEyy behind a group', () {
_expect('[Judas] Vinland Saga - S01E28.mkv', 'Vinland Saga',
season: 1, episode: 28, group: 'Judas', label: 'S01E28');
});
test('S2 in the title and a v2 release', () {
_expect('Mushoku Tensei S2 - 03v2 [1080p].mkv', 'Mushoku Tensei',
season: 2, episode: 3, label: 'S02E03');
});
test('Season N in the title', () {
_expect('[Group] Spy x Family Season 2 - 07 [720p].mkv', 'Spy x Family',
season: 2, episode: 7, group: 'Group');
});
test('Episode word', () {
_expect('Cowboy Bebop - Episode 12.mkv', 'Cowboy Bebop', episode: 12);
_expect('Cowboy Bebop Ep.05.mkv', 'Cowboy Bebop', episode: 5);
_expect('Cowboy Bebop EP07.mkv', 'Cowboy Bebop', episode: 7);
});
test('numbers in the title are not episodes', () {
_expect('[SubsPlease] 86 - 05 (1080p).mkv', '86',
episode: 5, group: 'SubsPlease');
_expect('Mob Psycho 100 - 03 [1080p].mkv', 'Mob Psycho 100', episode: 3);
_expect('Mob Psycho 100 III - 03.mkv', 'Mob Psycho 100 III', episode: 3);
});
test('batch keeps the first episode', () {
_expect('Show.S01E01-E02.1080p.WEB.mkv', 'Show', season: 1, episode: 1);
_expect('Show S01E05E06.mkv', 'Show', season: 1, episode: 5);
_expect('[Grp] Show - 01-02 [1080p].mkv', 'Show', episode: 1, group: 'Grp');
_expect('[Grp] Show - 01~12 [1080p].mkv', 'Show', episode: 1, group: 'Grp');
_expect('Show E01-E02.mkv', 'Show', episode: 1);
});
test('resolution, codec, CRC and audio never become episodes', () {
_expect('[Grp] Show [1080p][HEVC][ABCD1234].mkv', 'Show', group: 'Grp');
_expect('Show (1920x1080 x264 AAC 5.1).mkv', 'Show');
_expect('Show 1080p x265 10bit.mkv', 'Show');
_expect('Show.720p.HDTV.x264-GRP.mkv', 'Show', group: 'GRP');
});
});
group('extras', () {
test('NCOP / NCED', () {
_expect('[Grp] Show - NCOP1 [1080p].mkv', 'Show',
group: 'Grp', extra: 'NCOP1', label: 'NCOP1');
_expect('[Grp] Show - NCED [1080p].mkv', 'Show',
group: 'Grp', extra: 'NCED');
_expect('[Grp] Show S2 NCOP 2 [BD 1080p].mkv', 'Show',
season: 2, group: 'Grp', extra: 'NCOP2');
});
test('OVA, OAD, Special, OP/ED after the dash', () {
_expect('[Grp] Show - OVA [720p].mkv', 'Show', group: 'Grp', extra: 'OVA');
_expect('[Grp] Show OVA - 02 [720p].mkv', 'Show',
group: 'Grp', extra: 'OVA 2');
_expect('Show - OAD 3.mkv', 'Show', extra: 'OAD 3');
_expect('Show - Special 1.mkv', 'Show', extra: 'Special 1');
_expect('Show - SP2.mkv', 'Show', extra: 'SP2');
_expect('Show - OP1 [1080p].mkv', 'Show', extra: 'OP1');
_expect('Show - ED.mkv', 'Show', extra: 'ED');
});
test('"Special" inside a title is a title', () {
_expect('Special Operations Unit - 04.mkv', 'Special Operations Unit',
episode: 4);
});
});
group('scene / western', () {
test('dotted SxxEyy with a trailing group', () {
_expect('The.Expanse.S01E07.1080p.WEB.H264-GRP.mkv', 'The Expanse',
season: 1, episode: 7, group: 'GRP', label: 'S01E07');
});
test('year before the episode', () {
_expect('Doctor.Who.2005.S02E03.720p.mkv', 'Doctor Who',
season: 2, episode: 3, year: 2005);
_expect('Fargo (2014) - S03E01.mkv', 'Fargo',
season: 3, episode: 1, year: 2014);
});
test('1x03 style', () {
_expect('Friends 1x03 The One.avi', 'Friends', season: 1, episode: 3);
});
test('season pack name', () {
_expect('Show.S02.1080p.BluRay.x264-GRP.mkv', 'Show',
season: 2, group: 'GRP', label: 'S02');
});
test('lowercase', () {
_expect('show.name.s01e02.mkv', 'show name', season: 1, episode: 2);
});
});
group('movies', () {
test('dotted movie: year taken out of the title', () {
_expect('Movie.Title.2023.1080p.BluRay.x264.mkv', 'Movie Title',
year: 2023, label: '');
});
test('year in parentheses', () {
_expect('Movie Title (2019) [1080p].mkv', 'Movie Title', year: 2019);
});
test('title that is a number, year too far ahead', () {
_expect('Blade.Runner.2049.2017.1080p.BluRay.mkv', 'Blade Runner 2049',
year: 2017);
_expect('Blade Runner 2049.mkv', 'Blade Runner 2049');
_expect('1917.2019.1080p.WEB-DL.mkv', '1917', year: 2019);
});
test('WEB-DL at the end is not a group', () {
_expect('Some.Movie.2021.1080p.WEB-DL.mkv', 'Some Movie', year: 2021);
});
test('hyphen in the title', () {
_expect('Spider-Man.2002.1080p.BluRay.mkv', 'Spider-Man', year: 2002);
});
});
group('unstructured', () {
test('cleaned name, no episode', () {
_expect('vid_2024_final.mp4', 'vid 2024 final');
_expect('holiday video.mov', 'holiday video');
_expect('Dr. Stone - 05.mkv', 'Dr. Stone', episode: 5);
});
test('never empty', () {
expect(parseReleaseName('[Grp].mkv').series, isNotEmpty);
expect(parseReleaseName('1080p.mkv').series, '1080p');
expect(parseReleaseName('').series, '');
});
});
test('seriesKey ignores case and punctuation', () {
expect(parseReleaseName('Re:Zero - 01.mkv').seriesKey,
parseReleaseName('re zero - 02.mkv').seriesKey);
expect(parseReleaseName("Frieren: Beyond Journey's End - 01.mkv").seriesKey,
'frieren beyond journeys end');
});
}
+301
View File
@@ -0,0 +1,301 @@
import 'dart:convert';
import 'package:flutter_test/flutter_test.dart';
import 'package:starsync/src/library/library_store.dart';
import 'package:starsync/src/library/show_matcher.dart';
import 'package:starsync/src/schedule/anilist.dart';
import 'package:starsync/src/schedule/animeflow.dart' show AniListRun;
import 'package:sync_relay_core/core.dart';
// L2: local shows (L1 over the Binux names) matched against a fake
// AniList: synonyms and season hints, numbering along split entries,
// pins and offsets that win, the cache answering offline, spaced requests.
const root = '/lib/Binux';
const b2 = '$root/[Judas] Bleach 056-111 [BD 1080p][HEVC x265 10bit]';
const b3 = '$root/[Judas] Bleach 112-139 [BD 1080p][HEVC x265 10bit][Dual-Audio][Eng-Subs]';
const sao = '$root/[Judas] Sword Art Online (Season 3 - Alicization) [BD 1080p][HEVC x265 10bit][Eng-Subs]';
const reS1 = 'Re:Zero kara Hajimeru Isekai Seikatsu';
const reEn = 'Re:ZERO -Starting Life in Another World-';
final entries = <AniListEntry>[
const AniListEntry(id: 269, romaji: 'BLEACH', english: 'Bleach', format: 'TV', episodes: 366, year: 2004),
const AniListEntry(
id: 41467, romaji: 'BLEACH: Sennen Kessen-hen', english: 'Bleach: Thousand-Year Blood War', format: 'TV', episodes: 13),
const AniListEntry(
id: 21355, romaji: reS1, english: reEn, synonyms: ['Re:Zero', 'ReZero'], format: 'TV', episodes: 25, sequel: 108632),
const AniListEntry(
id: 108632, romaji: '$reS1 2nd Season', english: '$reEn Season 2', format: 'TV', episodes: 13, sequel: 119661),
const AniListEntry(
id: 119661, romaji: '$reS1 2nd Season Part 2', english: '$reEn Season 2 Part 2', format: 'TV', episodes: 12, sequel: 163134),
const AniListEntry(
id: 163134, romaji: '$reS1 3rd Season', english: '$reEn Season 3', format: 'TV', episodes: 16, sequel: 189046),
const AniListEntry(id: 189046, romaji: '$reS1 4th Season', english: '$reEn Season 4', format: 'TV'),
const AniListEntry(id: 11757, romaji: 'Sword Art Online', format: 'TV', episodes: 25, sequel: 20594),
const AniListEntry(id: 20594, romaji: 'Sword Art Online II', format: 'TV', episodes: 24),
const AniListEntry(
id: 100182,
romaji: 'Sword Art Online: Alicization',
format: 'TV',
episodes: 24,
sequel: 108759,
episodeTitles: {1: 'Underworld'}),
const AniListEntry(
id: 108759, romaji: 'Sword Art Online: Alicization - War of Underworld', format: 'TV', episodes: 12, sequel: 114308),
const AniListEntry(
id: 114308, romaji: 'Sword Art Online: Alicization - War of Underworld 2nd Season', format: 'TV', episodes: 11),
const AniListEntry(
id: 177937,
romaji: 'Kimetsu no Yaiba: Mugen Jou-hen',
english: 'Demon Slayer: Kimetsu no Yaiba Infinity Castle',
format: 'MOVIE',
episodes: 1),
const AniListEntry(id: 101922, romaji: 'Kimetsu no Yaiba', english: 'Demon Slayer: Kimetsu no Yaiba', format: 'TV', episodes: 26),
];
/// Answers searches by shared words (AniList's own order roughly), and
/// ids from [entries]; counts the requests.
class FakeAniList implements AniListLookup {
final searches = <String>[];
final lookups = <int>[];
bool offline = false;
static Set<String> _words(String s) =>
normalizeSeries(s).split(' ').where((w) => w.length > 1 && int.tryParse(w) == null && w != 'season').toSet();
@override
Future<List<AniListEntry>> searchAnime(String query) async {
if (offline) throw const AniListException('AniList unreachable');
searches.add(query);
final q = _words(query);
final scored = [
for (final e in entries)
(e, e.names.map((n) => _words(n).intersection(q).length).reduce((a, b) => a > b ? a : b)),
].where((p) => p.$2 > 0).toList()
..sort((a, b) => b.$2.compareTo(a.$2));
return [for (final p in scored.take(8)) p.$1];
}
@override
Future<AniListEntry?> anime(int id) async {
if (offline) throw const AniListException('AniList unreachable');
lookups.add(id);
return entries.where((e) => e.id == id).firstOrNull;
}
}
class FakeFlow implements FlowLookup {
final Map<int, FlowShow> shows;
FakeFlow(this.shows);
@override
Future<FlowShow?> forAniList(int anilistId) async => shows[anilistId];
}
String three(int n) => n.toString().padLeft(3, '0');
LocalLibrary binux({List<String> extra = const []}) => groupLocal([
for (final p in [
for (final n in [56, 57, 58, 59, 60, 61, 62, 63, 64, 65, 108, 109, 110, 111]) '$b2/[Judas] Bleach - ${three(n)}.mkv',
for (var n = 112; n <= 139; n++) '$b3/[Judas] Bleach - ${three(n)}.mkv',
for (var n = 1; n <= 24; n++) '$sao/[Judas] Sword Art Online S3 - ${n.toString().padLeft(2, '0')}.mkv',
'$sao/[Judas] Sword Art Online S3 - Opening 1.mkv',
'$root/[Judas] Re.Zero - S04E17.mkv',
'$root/[Erai-raws] Kimetsu no Yaiba - Mugenjou Hen - Movie 01 [1080p CR WEBRip HEVC AAC][MultiSub][8E47462C].mkv',
...extra,
])
LocalFile(p, 1 << 30, DateTime(2026)),
], roots: [root]);
void main() {
late FakeAniList aniList;
late MemoryLibraryStore store;
late List<Duration> waits;
ShowMatcher matcher({FlowLookup? flow, AniListLookup? api}) => ShowMatcher(
store: store,
aniList: api ?? aniList,
flow: flow,
delay: (d) async => waits.add(d),
);
Future<void> settle(ShowMatcher m) async {
for (var i = 0; i < 200 && !m.idle; i++) {
await Future<void>.delayed(Duration.zero);
}
await Future<void>.delayed(Duration.zero);
expect(m.idle, isTrue);
}
Future<ShowMatcher> matched(LocalLibrary lib, {FlowLookup? flow}) async {
final m = matcher(flow: flow)..remember(lib);
for (final s in lib.shows) {
m.want(s);
}
for (final mv in lib.movies) {
m.wantMovie(mv);
}
await settle(m);
return m;
}
LocalEpisode ep(LocalLibrary lib, String show, int n, {int? season}) =>
lib.show(show)!.episodes.firstWhere((e) => e.episode == n && (season == null || e.season == season));
setUp(() {
aniList = FakeAniList();
store = MemoryLibraryStore();
waits = [];
});
test('each show and movie finds its AniList entry, names and numbers follow', () async {
final lib = binux();
final m = await matched(lib);
// Bleach: one 366-episode entry, absolute numbers as is.
final bleach = lib.show('bleach')!;
expect(m.titleOf(bleach), 'Bleach');
expect(m.refFor(ep(lib, 'bleach', 108)),
const MediaRef(kind: 'episode', title: 'Bleach', anilistId: 269, anilistEp: 108, absolute: 108));
// Re.Zero S04E17: the season hint finds the 4th season among the
// synonyms and spellings ("Re.Zero" is "Re:ZERO -Starting Life…").
final re = lib.show('re zero')!;
expect(m.showMatch(re)!.entry.id, 189046);
expect(m.titleOf(re), '$reEn Season 4');
expect(m.refFor(re.episodes.single),
MediaRef(kind: 'episode', title: '$reEn Season 4', anilistId: 189046, anilistEp: 17, season: 4, ep: 17));
// SAO: the folder's "(Season 3 - Alicization)" names the entry; its
// extras belong to it too; AniList's episode title shows.
final s = lib.show('sword art online')!;
expect(m.showMatch(s)!.entry.id, 100182);
expect(m.target(ep(lib, 'sword art online', 13)), const EpisodeTarget(100182, 13));
expect(m.episodeTitle(ep(lib, 'sword art online', 1)), 'Underworld');
expect(m.refFor(s.extras.single), const MediaRef(kind: 'extra', title: 'Sword Art Online: Alicization', anilistId: 100182));
// The movie is the movie entry, not the TV show.
final movie = lib.movies.single;
expect(m.seasonMatch(movie)!.entry.id, 177937);
expect(m.refFor(movie).kind, 'movie');
});
test('past an entry\'s episodes the numbering goes on along its sequels (split cours)', () async {
final lib = binux(extra: [
'$sao/[Judas] Sword Art Online S3 - 25.mkv',
'$sao/[Judas] Sword Art Online S3 - 38.mkv',
]);
final m = await matched(lib);
// 25 is War of Underworld 1 (before that entry is even fetched: its
// id comes with Alicization), 38 its second part's 2.
expect(m.target(ep(lib, 'sword art online', 25)), const EpisodeTarget(108759, 1));
m.target(ep(lib, 'sword art online', 38));
await settle(m);
expect(m.target(ep(lib, 'sword art online', 38)), const EpisodeTarget(114308, 2));
expect(aniList.lookups, containsAll([108759]));
});
test('AnimeFlow\'s anilist_map wins for numbered seasons and gives the TVDB id', () async {
final lib = binux();
final flow = FakeFlow({
100182: const FlowShow([
AniListRun(season: 3, anilistId: 100182, firstEpisode: 1, offset: 0),
AniListRun(season: 3, anilistId: 108759, firstEpisode: 25, offset: 24),
], tvdbId: 259640),
});
final m = await matched(lib, flow: flow);
await settle(m);
final r = m.refFor(ep(lib, 'sword art online', 13));
expect([r.anilistId, r.anilistEp, r.tvdbId, r.season, r.ep], [100182, 13, 259640, 3, 13]);
});
test('pins win over the match, per show and per folder, and survive a rescan', () async {
final lib = binux();
final m = await matched(lib);
final tybw = entries.firstWhere((e) => e.id == 41467);
final reZero = entries.firstWhere((e) => e.id == 21355);
m.pinShow('bleach', tybw);
expect(m.showMatch(lib.show('bleach')!)!.pinned, PinScope.show);
expect(m.target(ep(lib, 'bleach', 60))!.anilistId, 41467);
// A folder pin is more specific than the show's.
m.pinFolder(b3, reZero);
expect(m.target(ep(lib, 'bleach', 120))!.anilistId, isNot(41467));
expect(m.seasonMatch(ep(lib, 'bleach', 120))!.pinned, PinScope.folder);
expect(m.seasonMatch(ep(lib, 'bleach', 60))!.entry.id, 41467);
// A rescan (a new library, a new run) over the same store: the pins
// hold, with AniList out of reach.
final again = binux();
final m2 = matcher(api: FakeAniList()..offline = true)..remember(again);
for (final s in again.shows) {
m2.want(s);
}
await settle(m2);
expect(m2.seasonMatch(ep(again, 'bleach', 60))!.entry.id, 41467);
expect(m2.seasonMatch(ep(again, 'bleach', 120))!.entry.id, 21355);
m2.unpinFolder(b3);
m2.unpinShow('bleach');
expect(m2.seasonMatch(ep(again, 'bleach', 120))!.entry.id, 269);
});
test('an episode offset: "these files are episodes N.."', () async {
final lib = binux();
final m = await matched(lib);
// The Alicization files are really War of Underworld 1-24 here.
m.setOffset('sword art online', 3, 24);
final r = m.refFor(ep(lib, 'sword art online', 1));
expect([r.ep, r.anilistId, r.anilistEp], [25, 108759, 1]);
m.setOffset('sword art online', 3, 0);
expect(m.refFor(ep(lib, 'sword art online', 1)).anilistEp, 1);
});
test('cached: one search per season, none again (offline too), requests spaced out', () async {
final lib = binux();
final m = await matched(lib);
final asked = aniList.searches.length;
expect(asked, greaterThanOrEqualTo(4));
expect(waits.where((w) => w > Duration.zero), isNotEmpty, reason: 'spaced, not a burst');
for (final s in lib.shows) {
m.want(s);
}
await settle(m);
expect(aniList.searches.length, asked);
final offline = FakeAniList()..offline = true;
final m2 = matcher(api: offline)..remember(lib);
for (final s in lib.shows) {
m2.want(s);
}
await settle(m2);
expect(offline.searches, isEmpty);
expect(m2.refFor(ep(lib, 'bleach', 108)), m.refFor(ep(lib, 'bleach', 108)));
expect(m2.titleOf(lib.show('re zero')!), '$reEn Season 4');
});
test('before a match (or offline with nothing cached) a ref still says what L1 read', () async {
final lib = binux();
final m = matcher(api: FakeAniList()..offline = true)..remember(lib);
m.want(lib.show('bleach')!);
await settle(m);
expect(m.refFor(ep(lib, 'bleach', 56)), const MediaRef(kind: 'episode', title: 'Bleach', absolute: 56));
// A file never scanned: its name and folder alone.
expect(m.refForPath('$sao/[Judas] Sword Art Online S3 - 07.mkv'),
const MediaRef(kind: 'episode', title: 'Sword Art Online', season: 3, ep: 7));
});
test('the ref fits the wire: round-trips and stays under 2 KiB', () async {
final long = 'A Very Long Light Novel Title ' * 12;
final lib = groupLocal([LocalFile('$root/$long - 05.mkv', 1, DateTime(2026))], roots: [root]);
final m = matcher(api: FakeAniList()..offline = true);
final ref = m.refFor(lib.shows.single.episodes.single);
final media = HelloMedia(name: 'x.mkv', size: 1, duration: 0, ref: ref);
final json = jsonEncode(media.toJson());
final back = HelloMedia.fromJson(jsonDecode(json) as Map<String, Object?>);
expect(back.ref!.kind, 'episode');
expect(back.ref!.absolute, 5);
expect(utf8.encode(jsonEncode(ref.toJson())).length, lessThan(RelayConstants.maxMediaRefBytes));
expect(back.ref!.title.length, lessThanOrEqualTo(200));
});
}