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'};
|
||||
|
||||
|
||||
Reference in new issue
Block a user