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

+135
View File
@@ -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);
});
}
+229
View File
@@ -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');
});
}