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

+2
View File
@@ -22,3 +22,5 @@ export 'src/jellyfin/http.dart';
export 'src/jellyfin/links.dart';
export 'src/jellyfin/models.dart';
export 'src/jellyfin/track_match.dart';
export 'src/library/local_library.dart';
export 'src/library/release_name.dart';
+1 -1
View File
@@ -183,7 +183,7 @@ class JellyfinClient {
/// Fields requested for list items: enough for cards and detail headers.
static const listFields =
'PrimaryImageAspectRatio,Overview,ChildCount,RecursiveItemCount,DateCreated';
'PrimaryImageAspectRatio,Overview,ChildCount,RecursiveItemCount,DateCreated,ProviderIds';
static const _imageParams = {
'enableImageTypes': 'Primary,Backdrop,Thumb,Logo',
'imageTypeLimit': '1',
@@ -232,6 +232,10 @@ class JellyfinItem {
final String? parentLogoItemId;
final String? parentLogoImageTag;
/// Ids on other databases (`AniList`, `Tvdb`, `Imdb`...), as Jellyfin's
/// metadata plugins set them; empty when none or not requested.
final Map<String, String> providerIds;
/// Present on full item responses (`getItem`) for playable items.
final List<JellyfinMediaSource> mediaSources;
/// Streams of the default source; present on `getItem` and when
@@ -273,6 +277,7 @@ class JellyfinItem {
this.parentThumbImageTag,
this.parentLogoItemId,
this.parentLogoImageTag,
this.providerIds = const {},
this.mediaSources = const [],
this.mediaStreams = const [],
});
@@ -314,12 +319,26 @@ class JellyfinItem {
parentThumbImageTag: _str(j['ParentThumbImageTag']),
parentLogoItemId: _str(j['ParentLogoItemId']),
parentLogoImageTag: _str(j['ParentLogoImageTag']),
providerIds: {
for (final e in (j['ProviderIds'] as Map? ?? const {}).entries)
if (e.value is String && (e.value as String).isNotEmpty) e.key.toString(): e.value as String,
},
mediaSources: _list(j['MediaSources']).map(JellyfinMediaSource.fromJson).toList(),
mediaStreams: _list(j['MediaStreams']).map(JellyfinMediaStream.fromJson).toList(),
);
JellyfinItemKind get kind => JellyfinItemKind.parse(type);
/// A provider id as a number (`providerId('AniList')`), case-insensitive
/// on the provider's name; null when absent or not a number.
int? providerId(String provider) {
final p = provider.toLowerCase();
for (final e in providerIds.entries) {
if (e.key.toLowerCase() == p) return int.tryParse(e.value.trim());
}
return null;
}
Duration? get runTime => runTimeTicks == null ? null : ticksToDuration(runTimeTicks!);
/// "S2E12" for episodes with both numbers, else null.
@@ -0,0 +1,549 @@
import 'release_name.dart';
// L1 of the library (ARCHITECTURE.md "Library"): the videos found in the
// user's folders grouped into shows, offline and deterministic. A file's
// own name wins; the folders above it (up to the watched folder) are
// lower-priority hints for the title, the season, the season's name and
// an episode range. One show spread over several folders (`Bleach
// 001-055`, `Bleach 056-111`) is one show. Numbering is kept as found
// (absolute or per season) and holes in it are known, so "next" can say
// what is missing. Matching to AniList (L2) is the app's.
const videoExtensions = {
'mkv', 'mp4', 'webm', 'avi', 'mov', 'm4v', 'ts', 'm2ts', 'wmv', 'flv', //
};
const subtitleExtensions = {'ass', 'ssa', 'srt', 'vtt', 'sup'};
enum FileKind { video, subtitle, other }
FileKind kindOf(String path) {
final ext = _extension(_baseName(path));
if (videoExtensions.contains(ext)) return FileKind.video;
if (subtitleExtensions.contains(ext)) return FileKind.subtitle;
return FileKind.other;
}
/// A file found in a folder.
class LocalFile {
final String path;
final int size;
final DateTime modified;
const LocalFile(this.path, this.size, this.modified);
String get name => _baseName(path);
/// The folder it is in.
String get folder => _parent(path);
}
/// A run of episode numbers that are not in the folders (`66–107`).
class EpisodeGap {
final int first;
final int last;
const EpisodeGap(this.first, this.last);
int get length => last - first + 1;
/// `66–107`, or `5` for one.
String get label => first == last ? '$first' : '$first–$last';
@override
bool operator ==(Object other) => other is EpisodeGap && other.first == first && other.last == last;
@override
int get hashCode => Object.hash(first, last);
@override
String toString() => 'EpisodeGap($label)';
}
/// What a local file is in its show.
enum LocalKind { episode, movie, extra, other }
/// One video as L1 sees it.
class LocalEpisode {
final LocalFile file;
/// The file name's own reading.
final ParsedName parsed;
final LocalKind kind;
/// Its show ([LocalShow.key]); '' for [LocalKind.other].
final String showKey;
/// Display title of what it belongs to (file name, else folder).
final String title;
/// File's season, else the folder's; null: absolute or none.
final int? season;
/// The season's own name (`Alicization`), from a parenthetical.
final String? seasonName;
final int? episode;
/// `OP1`, `NCED2`, `OVA 2` for extras.
final String? extra;
/// `Movie 2`'s number.
final int? movieNumber;
const LocalEpisode({
required this.file,
required this.parsed,
required this.kind,
required this.showKey,
required this.title,
this.season,
this.seasonName,
this.episode,
this.extra,
this.movieNumber,
});
String get path => file.path;
String get name => file.name;
String get folder => file.folder;
/// Stable across rescans: the show, season and number (or the extra,
/// the movie, or the path for anything else). Two copies of one
/// episode share it.
String get key => switch (kind) {
LocalKind.episode => '$showKey|s${season ?? ''}|e$episode',
LocalKind.extra => '$showKey|s${season ?? ''}|x${extra!.toLowerCase()}',
LocalKind.movie => '$showKey|m${movieNumber ?? ''}',
LocalKind.other => '|f|${file.path}',
};
/// `E056`, `S03E01`, `OP1`, `Movie 1`, else ''.
String get label => switch (kind) {
LocalKind.episode => [
if (season != null) 'S${season.toString().padLeft(2, '0')}',
'E${episode.toString().padLeft(2, '0')}',
].join(),
LocalKind.extra => extra!,
LocalKind.movie => movieNumber == null ? 'Movie' : 'Movie $movieNumber',
LocalKind.other => '',
};
@override
String toString() => 'LocalEpisode($key, ${file.name})';
}
/// One season of a show as found: a numbered season, or the absolute run
/// ([number] null).
class LocalSeason {
final int? number;
final String? name;
/// In episode order; a second copy of an episode follows the first
/// (newest release name first).
final List<LocalEpisode> episodes;
/// Numbers before, between and (by a folder's range) after the
/// episodes that are not there.
final List<EpisodeGap> gaps;
const LocalSeason(this.number, this.name, this.episodes, this.gaps);
bool get absolute => number == null;
/// One file per episode number (the first copy), in order.
List<LocalEpisode> get distinct {
final seen = <int>{};
return [
for (final e in episodes)
if (seen.add(e.episode!)) e
];
}
/// "Season 3 · Alicization", "Season 1", "Episodes".
String get title {
if (number == null) return name ?? 'Episodes';
return name == null ? 'Season $number' : 'Season $number · $name';
}
}
final _episodeKey = RegExp(r'\|s(\d*)\|e(\d+)$');
/// What to play after an episode: the next one there is, and the
/// numbers skipped to reach it (not in the folders).
class LocalNext {
final LocalEpisode episode;
final EpisodeGap? skipped;
const LocalNext(this.episode, {this.skipped});
}
/// A show found in the folders.
class LocalShow {
/// [normalizeSeries] of its title: stable across rescans and folders.
final String key;
/// The most common spelling among its files.
final String title;
final List<LocalSeason> seasons;
/// OP/ED, OVA, specials: by season, then kind, then number.
final List<LocalEpisode> extras;
/// Its movies (`Title - Movie 2`).
final List<LocalEpisode> movies;
/// The folders its files are in, sorted.
final List<String> folders;
/// The most common release group.
final String? group;
const LocalShow({
required this.key,
required this.title,
required this.seasons,
this.extras = const [],
this.movies = const [],
this.folders = const [],
this.group,
});
List<LocalEpisode> get episodes => [for (final s in seasons) ...s.episodes];
/// Every file of the show: episodes, extras, movies.
Iterable<LocalEpisode> get files sync* {
yield* episodes;
yield* extras;
yield* movies;
}
int get episodeCount => seasons.fold(0, (n, s) => n + s.distinct.length);
LocalSeason? season(int? number) => seasons.where((s) => s.number == number).firstOrNull;
/// The episode after [lastKey] (a [LocalEpisode.key]; null: nothing
/// watched yet, so the first one), with the numbers skipped on the way;
/// null when there is nothing after it. The next season follows the
/// last episode of one.
LocalNext? nextAfter(String? lastKey) {
final all = [
for (final s in seasons)
for (final e in s.distinct) (s, e)
];
if (all.isEmpty) return null;
if (lastKey == null) {
final (s, e) = all.first;
final lead = s.gaps.where((g) => g.last < e.episode!).firstOrNull;
return LocalNext(e, skipped: lead);
}
final at = all.indexWhere((p) => p.$2.key == lastKey);
if (at < 0) {
// Gone from the folders: the first one after its number.
final m = _episodeKey.firstMatch(lastKey);
if (m == null) return null;
final season = m.group(1)!.isEmpty ? null : int.parse(m.group(1)!);
final ep = int.parse(m.group(2)!);
final next = all.indexWhere((p) => p.$1.number == season && p.$2.episode! > ep);
if (next < 0) return null;
final (s, e) = all[next];
return LocalNext(e, skipped: e.episode! > ep + 1 ? EpisodeGap(ep + 1, e.episode! - 1) : null);
}
if (at + 1 >= all.length) return null;
final (s0, e0) = all[at];
final (s1, e1) = all[at + 1];
if (!identical(s0, s1)) {
final lead = s1.gaps.where((g) => g.last < e1.episode!).firstOrNull;
return LocalNext(e1, skipped: lead);
}
final from = e0.episode! + 1, to = e1.episode! - 1;
return LocalNext(e1, skipped: to >= from ? EpisodeGap(from, to) : null);
}
@override
String toString() => 'LocalShow($key, ${seasons.length} seasons)';
}
/// The folders' videos, grouped.
class LocalLibrary {
final List<LocalShow> shows;
/// Movies of no show found here.
final List<LocalEpisode> movies;
/// Videos that are not episodes, extras of a show or movies.
final List<LocalEpisode> other;
/// Files left out: not videos (`.lnk`, `.nfo`, pictures).
final int ignored;
const LocalLibrary({
this.shows = const [],
this.movies = const [],
this.other = const [],
this.ignored = 0,
});
static const empty = LocalLibrary();
LocalShow? show(String key) => shows.where((s) => s.key == key).firstOrNull;
/// Every video of the library.
Iterable<LocalEpisode> get files sync* {
for (final s in shows) {
yield* s.files;
}
yield* movies;
yield* other;
}
LocalEpisode? byPath(String path) => files.where((e) => e.path == path).firstOrNull;
}
/// The show and season a file names, with its folders as hints.
LocalEpisode readLocalFile(LocalFile file, {Iterable<String> roots = const []}) {
final p = parseReleaseName(file.name);
final hints = [for (final d in _foldersBelow(file.path, roots)) parseFolderName(d)];
FolderHint? first(bool Function(FolderHint h) test) => hints.where(test).firstOrNull;
final titled = first((h) => h.title.isNotEmpty && RegExp(r'\p{L}', unicode: true).hasMatch(h.title));
final seasonHint = first((h) => h.season != null);
final nearest = hints.firstOrNull;
var kind = switch (p.kind) {
ReleaseKind.episode => LocalKind.episode,
ReleaseKind.extra => LocalKind.extra,
ReleaseKind.movie => LocalKind.movie,
ReleaseKind.other => LocalKind.other,
};
String? extra = p.extra;
var ownTitle = p.series.trim();
// A file in an `Extras` / `NCOP` folder is an extra of the show above;
// a name that says nothing else (`Creditless Opening 1`) is its label.
if (nearest != null && nearest.extras && kind != LocalKind.movie) {
if (kind == LocalKind.episode) extra = 'Extra ${p.episode}';
if (kind == LocalKind.other) {
extra = ownTitle;
ownTitle = '';
}
kind = LocalKind.extra;
}
final usesFolder = kind != LocalKind.other && ownTitle.isEmpty;
final title = usesFolder && titled != null ? titled.title : ownTitle;
final season = p.season ?? (kind == LocalKind.movie ? null : seasonHint?.season);
final seasonName = p.subtitle ?? (season != null && seasonHint?.season == season ? seasonHint?.subtitle : null);
return LocalEpisode(
file: file,
parsed: p,
kind: kind,
showKey: kind == LocalKind.other ? '' : normalizeSeries(title),
title: title.isEmpty ? file.name : title,
season: season,
seasonName: seasonName,
episode: kind == LocalKind.episode ? p.episode : null,
extra: kind == LocalKind.extra ? (extra ?? 'Extra') : null,
movieNumber: kind == LocalKind.movie ? p.movieNumber : null,
);
}
/// Groups [files] (any kind; only videos are kept) into shows. [roots]:
/// the watched folders the files were found under; folders below them
/// give hints, the root itself last.
LocalLibrary groupLocal(Iterable<LocalFile> files, {Iterable<String> roots = const []}) {
var ignored = 0;
final read = <LocalEpisode>[];
for (final f in files) {
if (kindOf(f.path) != FileKind.video) {
ignored++;
continue;
}
read.add(readLocalFile(f, roots: roots));
}
final byKey = <String, List<LocalEpisode>>{};
final extras = <String, List<LocalEpisode>>{};
final movies = <String, List<LocalEpisode>>{};
final other = <LocalEpisode>[];
for (final e in read) {
if (e.kind != LocalKind.other && e.showKey.isEmpty) {
other.add(e);
continue;
}
switch (e.kind) {
case LocalKind.episode:
byKey.putIfAbsent(e.showKey, () => []).add(e);
case LocalKind.extra:
extras.putIfAbsent(e.showKey, () => []).add(e);
case LocalKind.movie:
movies.putIfAbsent(e.showKey, () => []).add(e);
case LocalKind.other:
other.add(e);
}
}
// Ranges in folder names (`Bleach 112-150`) say where a run should end.
final ranges = <String, int>{};
for (final e in read) {
if (e.kind != LocalKind.episode) continue;
final h = parseFolderName(_baseName(e.folder));
final last = h.lastEpisode;
if (last == null || h.key != e.showKey && h.key.isNotEmpty) continue;
final k = '${e.showKey}|${e.season}';
if (last > (ranges[k] ?? 0)) ranges[k] = last;
}
final shows = <LocalShow>[];
for (final entry in byKey.entries) {
final key = entry.key;
final eps = entry.value;
final bySeason = <int?, List<LocalEpisode>>{};
for (final e in eps) {
bySeason.putIfAbsent(e.season, () => []).add(e);
}
final numbers = bySeason.keys.toList()..sort(_nullFirst);
final seasons = [
for (final n in numbers) _season(n, bySeason[n]!, ranges['$key|$n']),
];
final x = (extras.remove(key) ?? [])..sort(_byExtra);
final m = (movies.remove(key) ?? [])..sort(_byMovie);
final all = [...eps, ...x, ...m];
shows.add(LocalShow(
key: key,
title: _mostCommon([for (final e in eps) e.title])!,
seasons: seasons,
extras: x,
movies: m,
folders: ({for (final e in all) e.folder}.toList()..sort()),
group: _mostCommon([
for (final e in all)
if (e.parsed.group != null) e.parsed.group!
]),
));
}
shows.sort((a, b) {
final t = a.title.toLowerCase().compareTo(b.title.toLowerCase());
return t != 0 ? t : a.key.compareTo(b.key);
});
// Extras of no show here are just videos; movies of none stand alone.
for (final list in extras.values) {
other.addAll(list);
}
final standalone = [for (final list in movies.values) ...list]..sort((a, b) {
final t = a.title.toLowerCase().compareTo(b.title.toLowerCase());
return t != 0 ? t : _byMovie(a, b);
});
other.sort(_byName);
return LocalLibrary(shows: shows, movies: standalone, other: other, ignored: ignored);
}
LocalSeason _season(int? number, List<LocalEpisode> eps, int? rangeEnd) {
eps.sort(_byEpisode);
final present = <int>{for (final e in eps) e.episode!}.toList()..sort();
final gaps = <EpisodeGap>[];
var expect = 1;
for (final n in present) {
if (n > expect) gaps.add(EpisodeGap(expect, n - 1));
expect = n + 1;
}
if (rangeEnd != null && rangeEnd >= expect) gaps.add(EpisodeGap(expect, rangeEnd));
return LocalSeason(
number,
_mostCommon([
for (final e in eps)
if (e.seasonName != null) e.seasonName!
]),
eps,
gaps);
}
int _nullFirst(int? a, int? b) {
if (a == b) return 0;
if (a == null) return -1;
if (b == null) return 1;
return a.compareTo(b);
}
int _byName(LocalEpisode a, LocalEpisode b) {
final t = a.name.toLowerCase().compareTo(b.name.toLowerCase());
return t != 0 ? t : a.path.compareTo(b.path);
}
int _byEpisode(LocalEpisode a, LocalEpisode b) {
final e = a.episode!.compareTo(b.episode!);
if (e != 0) return e;
// Same episode twice (v2, another group): newest release name first.
final n = b.name.compareTo(a.name);
return n != 0 ? n : a.path.compareTo(b.path);
}
final _extraNumber = RegExp(r'(\d+)$');
int _byExtra(LocalEpisode a, LocalEpisode b) {
final s = _nullFirst(a.season, b.season);
if (s != 0) return s;
String word(LocalEpisode e) => e.extra!.replaceFirst(_extraNumber, '').trim().toLowerCase();
int number(LocalEpisode e) => int.tryParse(_extraNumber.firstMatch(e.extra!)?.group(1) ?? '') ?? 0;
final w = _extraOrder(word(a)).compareTo(_extraOrder(word(b)));
if (w != 0) return w;
final ww = word(a).compareTo(word(b));
if (ww != 0) return ww;
final n = number(a).compareTo(number(b));
return n != 0 ? n : _byName(a, b);
}
// OP before ED (creditless with them), then the rest by name.
int _extraOrder(String word) => switch (word) {
'op' || 'ncop' => 0,
'ed' || 'nced' => 1,
_ => 2,
};
int _byMovie(LocalEpisode a, LocalEpisode b) {
final n = (a.movieNumber ?? 0).compareTo(b.movieNumber ?? 0);
return n != 0 ? n : _byName(a, b);
}
/// The most frequent of [values]; ties go to the first in sort order.
String? _mostCommon(Iterable<String> values) {
final counts = <String, int>{};
for (final v in values) {
counts.update(v, (n) => n + 1, ifAbsent: () => 1);
}
if (counts.isEmpty) return null;
final keys = counts.keys.toList()..sort();
var best = keys.first;
for (final k in keys) {
if (counts[k]! > counts[best]!) best = k;
}
return best;
}
/// Folder names between the watched folder [roots] holding [path] and
/// the file, nearest first, then the watched folder's own name; just the
/// parent when no root holds it.
List<String> _foldersBelow(String path, Iterable<String> roots) {
final parts = _split(path);
if (parts.length < 2) return const [];
final dirs = parts.sublist(0, parts.length - 1);
List<String>? best;
for (final r in roots) {
final rp = _split(r);
if (rp.length > dirs.length) continue;
var ok = true;
for (var i = 0; i < rp.length; i++) {
if (rp[i] != dirs[i]) {
ok = false;
break;
}
}
if (ok && (best == null || rp.length > best.length)) best = rp;
}
if (best == null) return [dirs.last];
final below = dirs.sublist(best.length).reversed.toList();
return [...below, if (best.isNotEmpty) best.last];
}
List<String> _split(String path) => path.split(RegExp(r'[/\\]')).where((p) => p.isNotEmpty).toList();
String _baseName(String path) {
final parts = _split(path);
return parts.isEmpty ? path : parts.last;
}
String _parent(String path) {
final i = path.lastIndexOf(RegExp(r'[/\\]'));
return i <= 0 ? (i == 0 ? path.substring(0, 1) : '') : path.substring(0, i);
}
String _extension(String name) {
final dot = name.lastIndexOf('.');
return dot <= 0 ? '' : name.substring(dot + 1).toLowerCase();
}
@@ -0,0 +1,417 @@
// Release file and folder names -> show / season / episode / extra / movie,
// anitomy-style but small: enough to group a downloads folder into shows.
// Unknown shapes fall back to the cleaned name with no episode, never to a
// wrong number. Pure and offline (L1 of the library, see ARCHITECTURE.md
// "Library").
/// What a release name is about.
enum ReleaseKind {
/// A numbered episode.
episode,
/// `Title (2019)`, `Title - Movie 2`, `Gekijouban Title`.
movie,
/// Not an episode of the run: OP/ED (creditless or not), OVA, specials,
/// PV, menus.
extra,
/// Nothing says which: a clip, a season pack's name, a page title.
other,
}
/// What a release file name says.
class ParsedName {
/// Title without group, season marker, episode, year or release tags.
final String series;
final int? season;
final int? episode;
/// Release group: leading `[Group]` or scene-style trailing `-GRP`.
final String? group;
/// Release year, taken out of [series] (movies, `Title (2019)`).
final int? year;
/// Non-episode marker (`NCOP1`, `OP2`, `ED`, `OVA 2`, `Special`, `PV1`,
/// `Menu`): such files carry no [episode] so they never sort among the
/// real ones.
final String? extra;
/// A movie marker was found (`Movie`, `Movie 01`, `Gekijouban`).
final bool movie;
/// `Movie 2`: its number in the show's movies.
final int? movieNumber;
/// From a parenthetical next to the season (`(Season 3 - Alicization)`):
/// the season's own name.
final String? subtitle;
const ParsedName(
this.series, {
this.season,
this.episode,
this.group,
this.year,
this.extra,
this.movie = false,
this.movieNumber,
this.subtitle,
});
ReleaseKind get kind {
if (episode != null) return ReleaseKind.episode;
if (extra != null) return ReleaseKind.extra;
if (movie || year != null) return ReleaseKind.movie;
return ReleaseKind.other;
}
/// `S01E03`, `E25`, the [extra] marker, `Movie 2`, `S02` for a season
/// pack, else ''.
String get label {
final s = season == null ? '' : 'S${season.toString().padLeft(2, '0')}';
if (episode != null) return '${s}E${episode.toString().padLeft(2, '0')}';
if (extra != null) return extra!;
if (movie) return movieNumber == null ? 'Movie' : 'Movie $movieNumber';
return s;
}
/// Grouping key: case, punctuation and spacing do not split a series.
String get seriesKey => normalizeSeries(series);
@override
String toString() => 'ParsedName($series, s=$season, e=$episode, '
'group=$group, year=$year, extra=$extra, movie=$movie/$movieNumber, '
'subtitle=$subtitle)';
}
/// Lowercase letters and digits of [s], single spaces between words.
String normalizeSeries(String s) => s
.toLowerCase()
.replaceAll(RegExp(r"['’`]"), '')
.replaceAll(RegExp(r'[^\p{L}\p{N}]+', unicode: true), ' ')
.trim();
// Release tags: the title ends where the first of these starts.
final _junk = RegExp(
r'\b(?:2160p|1440p|1080[pi]|720p|576p|480p|4K|UHD|[xh]\.?26[45]|HEVC|AVC|'
r'AV1|VP9|WEB(?:-?DL|-?Rip)?|BD(?:Rip|Remux)?|Blu-?Ray|BRRip|DVD(?:Rip)?|'
r'HDTV|HDR(?:10)?|DV|REMUX|AAC(?:\d\.\d)?|FLAC|Opus|DTS(?:-HD)?|E?-?AC-?3|'
r'DDP?\d?|TrueHD|Atmos|10-?bit|8-?bit|Hi10P?|PROPER|REPACK|Dual[ -]Audio|'
r'Multi(?:ple)?[ -]Subs?(?:titles?)?|AMZN|NF|CR|DSNP|HMAX|ATVP|'
r'Batch|Complete)\b',
caseSensitive: false,
);
final _ext = RegExp(r'\.(?=[a-z0-9]*[a-z])[a-z0-9]{2,4}$', caseSensitive: false);
final _leadingGroup = RegExp(r'^\s*\[([^\]]+)\]');
final _sceneGroup = RegExp(r'-([A-Za-z0-9]+)$');
final _bracketYear = RegExp(r'[(\[]((?:19|20)\d{2})[)\]]');
final _brackets = RegExp(r'\[[^\]]*\]|\([^)]*\)|\{[^}]*\}|【[^】]*】');
final _parenthetical = RegExp(r'\(([^)]*)\)');
// Inside a parenthetical: `Season 3 - Alicization`, `S2: Subtitle`,
// `2nd Season`.
final _seasonPhrase = RegExp(
r'^\s*(?:Season\s?(\d{1,2})|S(\d{1,2})|(\d{1,2})(?:st|nd|rd|th)\s+Season)'
r'(?:\s*[-–:~]\s*(.+?))?\s*$',
caseSensitive: false,
);
// S01E02, S01.E02, S1 E2; a batch `S01E01-E02` / `S01E01E02` keeps the first.
final _sxe = RegExp(r'\bS(\d{1,2})[ .]?E(\d{1,4})(?:v\d)?(?!\d)',
caseSensitive: false);
final _nxn = RegExp(r'\b(\d{1,2})x(\d{2,3})\b');
// Anime: `Title - 25`, `- 03v2`, `- 01-02` (batch keeps the first).
final _dashNumber = RegExp(r'\s[-–]\s(\d{1,4})(?:v\d)?(?=$|[\s\-~.])');
final _episodeWord = RegExp(
r'\b(?:Episode|Ep\.?|E)\s?(\d{1,4})(?:v\d)?\b',
caseSensitive: false,
);
// A name that is only the number (`01.mkv`, `001v2`; not a year).
final _bareNumber = RegExp(r'^(?!(?:19|20)\d\d(?:v\d)?$)(\d{1,4})(?:v\d)?$');
// Extras: unambiguous words anywhere, short ones only right after the dash.
final _extraAnywhere = RegExp(
r'(?:^|\s)(NCOP|NCED|OVA|OAD|ONA)\s?(\d{1,2})?(?:v\d)?(?=$|\s)',
caseSensitive: false,
);
final _extraAfterDash = RegExp(
r'\s[-–]\s(?:Creditless\s|Clean\s|NC\s)?'
r'(OP|ED|SP|Specials?|Extras?|Recap|Preview|PV|CM|Menu|Opening|Ending)'
r'\s?(\d{1,2})?(?:v\d)?(?=$|\s)',
caseSensitive: false,
);
final _creditless = RegExp(r'\s[-–]\s(?:Creditless|Clean|NC)\s', caseSensitive: false);
// Movies: `Title - Movie 01`, `Title - The Movie`, `Title Movie 2` at the
// end, `Gekijouban Title` in front.
final _movieAfterDash = RegExp(
r'\s[-–]\s(?:The\s)?Movie(?:\s(\d{1,2}))?(?=$|\s)',
caseSensitive: false,
);
final _movieAtEnd =
RegExp(r'\s(?:The\sMovie|Movie(?=\s\d))(?:\s(\d{1,2}))?$', caseSensitive: false);
final _gekijouban = RegExp(r'^\s*Gekij(?:ou|ō|o)ban\s+', caseSensitive: false);
final _seasonInTitle = RegExp(
r'\s(?:S(\d{1,2})|Season\s?(\d{1,2})|(\d{1,2})(?:st|nd|rd|th)\s+Season)\s*$',
caseSensitive: false,
);
// A folder named only for its season: `Season 2`, `S02`, `2nd Season`.
final _seasonOnly = RegExp(
r'^(?:S(\d{1,2})|Season\s?(\d{1,2})|(\d{1,2})(?:st|nd|rd|th)\s+Season)$',
caseSensitive: false,
);
final _trailingYear = RegExp(r'\s((?:19|20)\d{2})$');
// A folder's episode range: `Bleach 001-055`, `Show 01~12`.
final _range = RegExp(r'\s(\d{1,4})\s?[-~–]\s?(\d{1,4})$');
final _extrasFolder = RegExp(
r'^(?:Extras?|Specials?|SPs?|Bonus|NC(?:OP|ED)?s?|Creditless|OP\s?&?\s?ED|'
r'Openings?|Endings?|Menus?|PVs?|CMs?|Featurettes?)$',
caseSensitive: false,
);
/// Season and season name from parentheticals of [s] (`(Season 3 -
/// Alicization)`), read before the brackets are thrown away.
({int? season, String? subtitle}) _parentheticalSeason(String s) {
for (final m in _parenthetical.allMatches(s)) {
final p = _seasonPhrase.firstMatch(m.group(1)!);
if (p == null) continue;
final n = int.parse(p.group(1) ?? p.group(2) ?? p.group(3)!);
final sub = p.group(4)?.trim();
return (season: n, subtitle: sub == null || sub.isEmpty ? null : sub);
}
return (season: null, subtitle: null);
}
/// Parses a file name (with or without the extension, no directories).
ParsedName parseReleaseName(String fileName) {
var s = fileName.replaceFirst(_ext, '').trim();
final original = s;
String? group = _leadingGroup.firstMatch(s)?.group(1)?.trim();
int? year;
final by = _bracketYear.firstMatch(s);
if (by != null) year = int.parse(by.group(1)!);
final paren = _parentheticalSeason(s);
s = s.replaceAll(_brackets, ' ');
// Dots and underscores as separators (scene style), unless the name
// already has spaces and only the odd `Dr. Stone` dot.
final dotted = !s.contains(' ') || RegExp(r'\w\.\w').allMatches(s).length > 2;
if (dotted) {
// `...H264-GRP`, but not `...WEB-DL`.
final tags = _junk.allMatches(s);
if (group == null && tags.isNotEmpty && tags.last.end < s.length) {
final g = _sceneGroup.firstMatch(s);
if (g != null) {
group = g.group(1);
s = s.substring(0, g.start);
}
}
s = s.replaceAll('.', ' ');
}
s = s.replaceAll('_', ' ').replaceAll(RegExp(r'\s+'), ' ').trim();
// Release tags end the interesting part.
final junk = _junk.firstMatch(s);
if (junk != null && junk.start > 0) s = s.substring(0, junk.start).trim();
var movie = false;
int? movieNumber;
final gk = _gekijouban.firstMatch(s);
if (gk != null) {
movie = true;
s = s.substring(gk.end);
}
int? season, episode;
String? extra;
var cut = s.length;
final extraMatch =
_extraAfterDash.firstMatch(s) ?? _extraAnywhere.firstMatch(s);
final sxe = _sxe.firstMatch(s);
final movieMatch = _movieAfterDash.firstMatch(s) ?? _movieAtEnd.firstMatch(s);
if (extraMatch != null) {
extra = _extraLabel(s, extraMatch);
cut = extraMatch.start;
if (sxe != null && sxe.start < cut) {
season = int.parse(sxe.group(1)!);
cut = sxe.start;
}
} else if (sxe != null) {
season = int.parse(sxe.group(1)!);
episode = int.parse(sxe.group(2)!);
cut = sxe.start;
} else if (movieMatch != null) {
movie = true;
final n = movieMatch.group(1);
movieNumber = n == null ? null : int.parse(n);
cut = movieMatch.start;
} else {
final nxn = _nxn.firstMatch(s);
if (nxn != null) {
season = int.parse(nxn.group(1)!);
episode = int.parse(nxn.group(2)!);
cut = nxn.start;
} else {
final m = _dashNumber.firstMatch(s) ??
_episodeWord.firstMatch(s) ??
(movie ? null : _bareNumber.firstMatch(s));
if (m != null) {
episode = int.parse(m.group(1)!);
cut = m.start;
}
}
}
var series = s.substring(0, cut);
// Season pack or season marker kept in the title: `Title S2`,
// `Title Season 2`, `Title 2nd Season`, `Title.S01.1080p`.
final sIn = _seasonInTitle.firstMatch(series);
if (sIn != null) {
season ??= int.parse(sIn.group(1) ?? sIn.group(2) ?? sIn.group(3)!);
series = series.substring(0, sIn.start);
}
season ??= paren.season;
series = _trimTail(series);
// A trailing year is the release year, unless it is the whole title or
// too far ahead to be one (`Blade Runner 2049`).
final ty = _trailingYear.firstMatch(series);
if (ty != null) {
final y = int.parse(ty.group(1)!);
if (y <= DateTime.now().year + 1) {
year ??= y;
series = series.substring(0, ty.start).trim();
}
}
series = _trimTail(series);
// A name that is only an episode or extra (`01.mkv`, `NCOP1.mkv`) has
// no title: the folder gives one.
if (series.isEmpty && episode == null && extra == null && !movie) {
series = original.replaceAll(RegExp(r'[._]+'), ' ').trim();
}
return ParsedName(
series,
season: season,
episode: episode,
group: group,
year: year,
extra: extra,
movie: movie,
movieNumber: movieNumber,
subtitle: paren.subtitle,
);
}
String _trimTail(String s) => s.replaceAll(RegExp(r'[\s\-–:,~]+$'), '').trim();
/// `NCOP1`, `OP2`, `ED`, `OVA 2`, `Special 1`, `PV1`: short words joined,
/// long ones spaced; Opening / Ending read as OP / ED, creditless ones as
/// NCOP / NCED.
String _extraLabel(String s, RegExpMatch m) {
final word = m.group(1)!;
var upper = switch (word.toLowerCase()) {
'opening' => 'OP',
'ending' => 'ED',
final w when w.length <= 3 || w.startsWith('nc') => word.toUpperCase(),
_ => _title(word),
};
final creditless = _creditless.matchAsPrefix(s, m.start) != null;
if (creditless && (upper == 'OP' || upper == 'ED')) upper = 'NC$upper';
// `OVA - 02`: the number may follow the dash instead.
final n = m.group(2) ?? _dashNumber.firstMatch(s.substring(m.end))?.group(1);
if (n == null) return upper;
return (upper.startsWith('NC') || upper.length <= 2)
? '$upper${int.parse(n)}'
: '$upper ${int.parse(n)}';
}
String _title(String w) => w[0].toUpperCase() + w.substring(1).toLowerCase();
/// What a folder's name says about the videos in it: lower priority than
/// the file's own name (see `groupLocal`).
class FolderHint {
/// Show title; '' when the folder names none (`Season 2`, `Extras`).
final String title;
final int? season;
/// The season's own name, from a parenthetical (`Alicization`).
final String? subtitle;
/// An episode range in the name (`Bleach 056-111`).
final int? firstEpisode;
final int? lastEpisode;
final String? group;
final int? year;
/// A folder of extras (`Extras`, `NCOP`, `Specials`).
final bool extras;
const FolderHint({
this.title = '',
this.season,
this.subtitle,
this.firstEpisode,
this.lastEpisode,
this.group,
this.year,
this.extras = false,
});
String get key => normalizeSeries(title);
@override
String toString() => 'FolderHint("$title", s=$season, sub=$subtitle, '
'range=$firstEpisode-$lastEpisode, extras=$extras)';
}
/// Parses one folder name (no separators): `[Judas] Bleach 056-111 [BD
/// 1080p]`, `Sword Art Online (Season 3 - Alicization)`, `Season 2`.
FolderHint parseFolderName(String name) {
var s = name.trim();
final group = _leadingGroup.firstMatch(s)?.group(1)?.trim();
final by = _bracketYear.firstMatch(s);
final paren = _parentheticalSeason(s);
s = s.replaceAll(_brackets, ' ');
final dotted = !s.contains(' ') || RegExp(r'\w\.\w').allMatches(s).length > 2;
if (dotted) s = s.replaceAll('.', ' ');
s = s.replaceAll('_', ' ').replaceAll(RegExp(r'\s+'), ' ').trim();
final junk = _junk.firstMatch(s);
if (junk != null && junk.start > 0) s = s.substring(0, junk.start).trim();
s = _trimTail(s);
if (_extrasFolder.hasMatch(s)) return FolderHint(group: group, extras: true);
final only = _seasonOnly.firstMatch(s);
if (only != null) {
return FolderHint(
season: int.parse(only.group(1) ?? only.group(2) ?? only.group(3)!),
group: group,
);
}
int? first, last;
final r = _range.firstMatch(s);
if (r != null) {
final a = int.parse(r.group(1)!), b = int.parse(r.group(2)!);
if (a <= b && !(a >= 1900 && b <= 2100)) {
first = a;
last = b;
s = _trimTail(s.substring(0, r.start));
}
}
int? season = paren.season;
final sIn = _seasonInTitle.firstMatch(s);
if (sIn != null) {
season ??= int.parse(sIn.group(1) ?? sIn.group(2) ?? sIn.group(3)!);
s = _trimTail(s.substring(0, sIn.start));
}
int? year = by == null ? null : int.parse(by.group(1)!);
final ty = _trailingYear.firstMatch(s);
if (ty != null && ty.start > 0) {
year ??= int.parse(ty.group(1)!);
s = _trimTail(s.substring(0, ty.start));
}
return FolderHint(
title: s,
season: season,
subtitle: paren.subtitle,
firstEpisode: first,
lastEpisode: last,
group: group,
year: year,
);
}
+3 -2
View File
@@ -103,8 +103,9 @@ class JellyfinRef {
/// What a media is, independent of where its bytes come from (`media.ref`):
/// an episode, a movie or an extra, by AniList and/or TVDB ids. Carried
/// as is in `swap.media`, `state.media` and `queue[].media` (the server
/// keeps it, at most [RelayConstants.maxMediaRefBytes] of JSON); nothing
/// produces it yet (step 4).
/// keeps it, at most [RelayConstants.maxMediaRefBytes] of JSON). The app
/// makes it from its library (files), Jellyfin's provider ids and a
/// link's name.
class MediaRef {
static const kinds = {'episode', 'movie', 'extra'};
+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');
});
}