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'};