forked from Foxyz/sync_replay_project
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
@@ -0,0 +1,135 @@
|
||||
import 'package:sync_relay_core/core.dart';
|
||||
import 'package:test/test.dart';
|
||||
|
||||
// L1 grouping (groupLocal) over the names of a real anime folder: three
|
||||
// Bleach folders (one holding only shortcut junk) with absolute numbering
|
||||
// and a hole, a Sword Art Online season folder with OP/ED extras, a loose
|
||||
// Re:Zero S04E17 and a Kimetsu no Yaiba movie. Paths only: nothing here
|
||||
// touches the disk (the app's scanner test walks a generated tree).
|
||||
|
||||
const root = '/lib/Binux';
|
||||
const bleach1 = '$root/[Judas] Bleach 001-055 [BD 1080p][HEVC x265 10bit]';
|
||||
const bleach2 = '$root/[Judas] Bleach 056-111 [BD 1080p][HEVC x265 10bit]';
|
||||
const bleach3 = '$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]';
|
||||
|
||||
String three(int n) => n.toString().padLeft(3, '0');
|
||||
String two(int n) => n.toString().padLeft(2, '0');
|
||||
|
||||
/// The Binux names (and order as `find` lists them: not sorted).
|
||||
List<String> binux() => [
|
||||
'$root/[Erai-raws] Kimetsu no Yaiba - Mugenjou Hen - Movie 01 [1080p CR WEBRip HEVC AAC][MultiSub][8E47462C].mkv',
|
||||
for (final junk in ['Amine', 'App', 'Audio', 'Document', 'Download', 'Image', 'Recent', 'Video'])
|
||||
'$bleach1/$junk#DfopghDGLvYAG.lnk',
|
||||
for (final n in [...List.generate(10, (i) => 56 + i), 108, 109, 110, 111]) '$bleach2/[Judas] Bleach - ${three(n)}.mkv',
|
||||
for (var n = 139; n >= 112; n--) '$bleach3/[Judas] Bleach - ${three(n)}.mkv',
|
||||
'$root/[Judas] Re.Zero - S04E17.mkv',
|
||||
for (var n = 1; n <= 24; n++) '$sao/[Judas] Sword Art Online S3 - ${two(n)}.mkv',
|
||||
for (final x in ['Ending 1', 'Ending 2', 'Opening 1', 'Opening 2', 'Opening 3'])
|
||||
'$sao/[Judas] Sword Art Online S3 - $x.mkv',
|
||||
];
|
||||
|
||||
LocalLibrary group(Iterable<String> paths) =>
|
||||
groupLocal([for (final p in paths) LocalFile(p, 1 << 30, DateTime(2026))], roots: [root]);
|
||||
|
||||
void main() {
|
||||
test('the Binux folder: three shows, a movie, junk left out', () {
|
||||
final lib = group(binux());
|
||||
expect(lib.shows.map((s) => s.title), ['Bleach', 'Re.Zero', 'Sword Art Online']);
|
||||
expect(lib.movies.map((m) => (m.title, m.label)), [('Kimetsu no Yaiba - Mugenjou Hen', 'Movie 1')]);
|
||||
expect(lib.other, isEmpty);
|
||||
expect(lib.ignored, 8, reason: 'the .lnk shortcuts');
|
||||
});
|
||||
|
||||
test('Bleach: one show across folders, absolute numbering, the hole known', () {
|
||||
final bleach = group(binux()).show('bleach')!;
|
||||
expect(bleach.folders, [bleach2, bleach3]);
|
||||
expect(bleach.group, 'Judas');
|
||||
final run = bleach.seasons.single;
|
||||
expect(run.absolute, isTrue);
|
||||
expect(run.number, isNull);
|
||||
expect(run.distinct.map((e) => e.episode), [...List.generate(10, (i) => 56 + i), ...List.generate(32, (i) => 108 + i)]);
|
||||
expect(run.gaps, const [EpisodeGap(1, 55), EpisodeGap(66, 107)]);
|
||||
expect(bleach.episodeCount, 42);
|
||||
expect(run.episodes.first.label, 'E56');
|
||||
expect(run.episodes.first.key, 'bleach|s|e56');
|
||||
|
||||
// Next: from nothing it starts at 56 (1–55 missing); after 65 it
|
||||
// skips 66–107; after 111 it is 112, nothing skipped; 139 is the end.
|
||||
final start = bleach.nextAfter(null)!;
|
||||
expect([start.episode.episode, start.skipped], [56, const EpisodeGap(1, 55)]);
|
||||
final at65 = run.episodes.firstWhere((e) => e.episode == 65);
|
||||
final skip = bleach.nextAfter(at65.key)!;
|
||||
expect([skip.episode.episode, skip.skipped?.label], [108, '66–107']);
|
||||
final at111 = run.episodes.firstWhere((e) => e.episode == 111);
|
||||
expect([bleach.nextAfter(at111.key)!.episode.episode, bleach.nextAfter(at111.key)!.skipped], [112, null]);
|
||||
expect(bleach.nextAfter('bleach|s|e139'), isNull);
|
||||
// An episode no longer in the folders: the next one after its number.
|
||||
expect(bleach.nextAfter('bleach|s|e70')!.episode.episode, 108);
|
||||
});
|
||||
|
||||
test('Sword Art Online: season and its name from the folder, OP/ED as extras', () {
|
||||
final s = group(binux()).show('sword art online')!;
|
||||
final season = s.seasons.single;
|
||||
expect([season.number, season.name, season.title], [3, 'Alicization', 'Season 3 · Alicization']);
|
||||
expect(season.distinct.map((e) => e.episode), List.generate(24, (i) => i + 1));
|
||||
expect(season.gaps, isEmpty);
|
||||
expect(s.extras.map((e) => e.label), ['OP1', 'OP2', 'OP3', 'ED1', 'ED2']);
|
||||
expect(s.extras.every((e) => e.kind == LocalKind.extra && e.season == 3), isTrue);
|
||||
expect(season.episodes.first.label, 'S03E01');
|
||||
final first = s.nextAfter(null)!;
|
||||
expect([first.episode.episode, first.skipped], [1, null]);
|
||||
});
|
||||
|
||||
test('Re:Zero: a loose S04E17 is a show of its own, numbers as found', () {
|
||||
final re = group(binux()).show('re zero')!;
|
||||
final s4 = re.seasons.single;
|
||||
expect([s4.number, s4.distinct.single.episode, s4.gaps], [4, 17, const [EpisodeGap(1, 16)]]);
|
||||
expect(re.folders, [root]);
|
||||
});
|
||||
|
||||
test('deterministic: any input order gives the same library', () {
|
||||
String dump(LocalLibrary l) => [
|
||||
for (final s in l.shows) '${s.key}:${[for (final e in s.files) e.path].join(',')}',
|
||||
for (final m in l.movies) m.path,
|
||||
].join('\n');
|
||||
final a = group(binux());
|
||||
final b = group(binux().reversed);
|
||||
final c = group([...binux()]..sort());
|
||||
expect(dump(b), dump(a));
|
||||
expect(dump(c), dump(a));
|
||||
});
|
||||
|
||||
test('folder hints: titles for bare names, season folders, extras folders, ranges', () {
|
||||
final lib = group([
|
||||
'$root/Frieren/Season 2/01.mkv',
|
||||
'$root/Frieren/Season 2/02.mkv',
|
||||
'$root/Frieren/Season 2/Extras/Creditless Opening.mkv',
|
||||
'$root/Frieren/Frieren - 05.mkv',
|
||||
'$root/Show 01-12/Show - 01.mkv',
|
||||
'$root/Show 01-12/Show - 02.mkv',
|
||||
'$root/clip.mp4',
|
||||
'$root/Lonely/[A] Lonely - OVA.mkv',
|
||||
]);
|
||||
final f = lib.show('frieren')!;
|
||||
expect(f.seasons.map((s) => s.number), [null, 2]);
|
||||
expect(f.season(2)!.distinct.map((e) => e.episode), [1, 2]);
|
||||
expect(f.extras.single.extra, 'Creditless Opening');
|
||||
// A range in the folder name: what should follow is missing too.
|
||||
expect(lib.show('show')!.seasons.single.gaps, const [EpisodeGap(3, 12)]);
|
||||
// An extra of no show here, and a clip: just videos.
|
||||
expect(lib.other.map((e) => e.name), ['[A] Lonely - OVA.mkv', 'clip.mp4']);
|
||||
});
|
||||
|
||||
test('copies of one episode share its key; the newest name comes first', () {
|
||||
final lib = group([
|
||||
'$root/a/[A] Show - 01.mkv',
|
||||
'$root/a/[A] Show - 01v2.mkv',
|
||||
'$root/a/[A] Show - 02.mkv',
|
||||
]);
|
||||
final s = lib.shows.single.seasons.single;
|
||||
expect(s.episodes.map((e) => e.name), ['[A] Show - 01v2.mkv', '[A] Show - 01.mkv', '[A] Show - 02.mkv']);
|
||||
expect(s.distinct.map((e) => e.name), ['[A] Show - 01v2.mkv', '[A] Show - 02.mkv']);
|
||||
expect(s.episodes[0].key, s.episodes[1].key);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,229 @@
|
||||
import 'package:test/test.dart';
|
||||
import 'package:sync_relay_core/core.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('Opening / Ending, creditless, PV, Menu, numbered', () {
|
||||
_expect('[Judas] Sword Art Online S3 - Opening 1.mkv', 'Sword Art Online',
|
||||
season: 3, group: 'Judas', extra: 'OP1', label: 'OP1');
|
||||
_expect('[Judas] Sword Art Online S3 - Ending 2.mkv', 'Sword Art Online',
|
||||
season: 3, group: 'Judas', extra: 'ED2');
|
||||
_expect('Show - Creditless Opening 2.mkv', 'Show', extra: 'NCOP2');
|
||||
_expect('Show - PV 3.mkv', 'Show', extra: 'PV3');
|
||||
_expect('Show - Menu 1.mkv', 'Show', extra: 'Menu 1');
|
||||
expect(parseReleaseName('Show - Opening 1.mkv').kind, ReleaseKind.extra);
|
||||
});
|
||||
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('Movie N, The Movie, Gekijouban', () {
|
||||
final k = parseReleaseName(
|
||||
'[Erai-raws] Kimetsu no Yaiba - Mugenjou Hen - Movie 01 [1080p CR WEBRip HEVC AAC][MultiSub][8E47462C].mkv');
|
||||
expect([k.series, k.movie, k.movieNumber, k.episode, k.group, k.kind, k.label],
|
||||
['Kimetsu no Yaiba - Mugenjou Hen', true, 1, null, 'Erai-raws', ReleaseKind.movie, 'Movie 1']);
|
||||
final t = parseReleaseName('Show - The Movie [BD].mkv');
|
||||
expect([t.series, t.movie, t.movieNumber], ['Show', true, null]);
|
||||
final g = parseReleaseName('Gekijouban Bleach Fade to Black.mkv');
|
||||
expect([g.series, g.movie, g.kind], ['Bleach Fade to Black', true, ReleaseKind.movie]);
|
||||
// A title with the word in it is still a title.
|
||||
final s = parseReleaseName('Some Movie (2019).mkv');
|
||||
expect([s.series, s.movie, s.year, s.kind], ['Some Movie', false, 2019, ReleaseKind.movie]);
|
||||
});
|
||||
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('a name that is only the number has no title (the folder gives one)', () {
|
||||
final p = parseReleaseName('01.mkv');
|
||||
expect([p.series, p.episode, p.kind], ['', 1, ReleaseKind.episode]);
|
||||
expect(parseReleaseName('Episode 07.mkv').series, '');
|
||||
expect(parseReleaseName('2019.mkv').episode, isNull, reason: 'a year, not an episode');
|
||||
expect(parseReleaseName('vid.mp4').kind, ReleaseKind.other);
|
||||
});
|
||||
|
||||
test('parenthetical season and its name', () {
|
||||
final p = parseReleaseName('Sword Art Online (Season 3 - Alicization) - 05.mkv');
|
||||
expect([p.series, p.season, p.subtitle, p.episode], ['Sword Art Online', 3, 'Alicization', 5]);
|
||||
});
|
||||
|
||||
group('folder names', () {
|
||||
test('ranges, groups and tags', () {
|
||||
final b = parseFolderName('[Judas] Bleach 056-111 [BD 1080p][HEVC x265 10bit]');
|
||||
expect([b.title, b.firstEpisode, b.lastEpisode, b.group, b.season], ['Bleach', 56, 111, 'Judas', null]);
|
||||
final c = parseFolderName('[Judas] Bleach 112-139 [BD 1080p][HEVC x265 10bit][Dual-Audio][Eng-Subs]');
|
||||
expect([c.title, c.firstEpisode, c.lastEpisode], ['Bleach', 112, 139]);
|
||||
});
|
||||
test('a parenthetical season with its name, read before brackets go', () {
|
||||
final s = parseFolderName('[Judas] Sword Art Online (Season 3 - Alicization) [BD 1080p][HEVC x265 10bit][Eng-Subs]');
|
||||
expect([s.title, s.season, s.subtitle, s.group], ['Sword Art Online', 3, 'Alicization', 'Judas']);
|
||||
});
|
||||
test('season-only and extras folders, years', () {
|
||||
expect(parseFolderName('Season 2').season, 2);
|
||||
expect(parseFolderName('Season 2').title, '');
|
||||
expect(parseFolderName('S03').season, 3);
|
||||
expect(parseFolderName('Extras').extras, isTrue);
|
||||
expect(parseFolderName('NCOP').extras, isTrue);
|
||||
final y = parseFolderName('Show (2019)');
|
||||
expect([y.title, y.year], ['Show', 2019]);
|
||||
expect(parseFolderName('Show 2nd Season').season, 2);
|
||||
expect(parseFolderName('Show 2nd Season').title, 'Show');
|
||||
});
|
||||
});
|
||||
|
||||
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');
|
||||
});
|
||||
}
|
||||
Reference in new issue
Block a user