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
@@ -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';
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
@@ -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'};
|
||||
|
||||
|
||||
@@ -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