Restructure step 4: local library and media identity
- 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:
1 parent
4d00b7455e
commit
bf9fc193b3
44 files changed
+4350
-891
No files matched your search
@@ -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));
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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']);
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -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');
|
||||
});
|
||||
}
|
||||
@@ -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));
|
||||
});
|
||||
}
|
||||
Reference in new issue
Block a user