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
@@ -216,8 +216,21 @@ Up next, add the folder to Files, or fill Link).
|
||||
open here (needs cookies, 403, 404, not a video) says why and goes up
|
||||
only with "Put it up anyway"; in the player, whoever cannot play it is
|
||||
named ("Bob can't play this: Not allowed (403)") and nobody waits for them.
|
||||
- **Files**: video folders you add on this computer, grouped by series
|
||||
(click a series to list its episodes), or any file through Browse. The others stream it from you.
|
||||
- **Files**: your own library from the video folders you add on this
|
||||
computer (the **Library** shows them all together, each folder on its
|
||||
own too), or any file through Browse. Files are sorted into shows and
|
||||
movies by their names and folders: one show split over several folders
|
||||
is one show, OP/ED and specials go under its **Extras**, a folder like
|
||||
"Show (Season 3 - Alicization)" gives the season and its name. Shows
|
||||
are looked up on AniList for their proper name, cover and banner. A
|
||||
show's page has its seasons and episodes and a button for the next one
|
||||
after what you last played here; when episodes are missing on the way
|
||||
it says so ("66–107 aren't in your folders — skip to 108?"). Matched
|
||||
the wrong show? **Wrong show?** searches AniList and pins the right one
|
||||
for the show everywhere or just one folder; **Episode numbers…** says
|
||||
which episode the first file is when the files count differently. Both
|
||||
stay through rescans. The others stream what you put up from you, and
|
||||
see its proper name too.
|
||||
|
||||
Put it up and the player opens. With nobody else in the server, the
|
||||
buttons say Play and a link plays straight from the site.
|
||||
@@ -467,8 +480,9 @@ Watching:
|
||||
- Friends without an account watch through the server: whoever put the
|
||||
item up passes it on automatically, with their own sign-in, which never
|
||||
leaves their computer (only the current item, only to people in the
|
||||
session). They show "via server" in the member list (click the avatars). When the app hosts,
|
||||
or a dedicated server has its own login (`--jellyfin <file>`, see
|
||||
session). They show "via server" in the member list (click the avatars). On your
|
||||
own server (with your sign-in on that item's Jellyfin, whichever of
|
||||
your linked servers it is), or a dedicated server with its own login (`--jellyfin <file>`, see
|
||||
[docs/ARCHITECTURE.md](docs/ARCHITECTURE.md), Jellyfin relay), the server
|
||||
fetches it itself instead.
|
||||
- If nobody can pass it on (the person who put it up left), the app says
|
||||
|
||||
@@ -65,6 +65,8 @@ Future<void> main(List<String> args) async {
|
||||
} catch (e, st) {
|
||||
debugPrint('load connect form failed: $e\n$st');
|
||||
}
|
||||
// The library's matches, pins and offsets (device-owned).
|
||||
unawaited(graph.library.ensureLoaded());
|
||||
MediaKit.ensureInitialized();
|
||||
// yt-dlp for web page links: downloaded on first run, kept current.
|
||||
ports.links.warmUp();
|
||||
|
||||
@@ -1,9 +1,13 @@
|
||||
import 'package:flutter/widgets.dart';
|
||||
|
||||
import '../jellyfin/accounts.dart';
|
||||
import '../library/library_store.dart';
|
||||
import '../library/show_matcher.dart';
|
||||
import '../platform/app_log.dart' show appLogDir;
|
||||
import '../platform/desktop/desktop_ports.dart';
|
||||
import '../platform/ports/ports.dart';
|
||||
import '../schedule/anilist.dart';
|
||||
import '../schedule/animeflow.dart';
|
||||
import '../screens/home/health_probe.dart';
|
||||
import '../session/connection.dart' show Connection;
|
||||
import '../session/identity.dart' show ClientIdentity, Profile;
|
||||
@@ -44,6 +48,9 @@ class AppGraph {
|
||||
/// Home (your server) or a friend's.
|
||||
final Navigation navigation;
|
||||
|
||||
/// The library's matches (L2): local shows to AniList, pins, offsets.
|
||||
final ShowMatcher library;
|
||||
|
||||
AppGraph._({
|
||||
required this.ports,
|
||||
required this.sfx,
|
||||
@@ -53,6 +60,7 @@ class AppGraph {
|
||||
required this.forms,
|
||||
required this.yourServer,
|
||||
required this.navigation,
|
||||
required this.library,
|
||||
});
|
||||
|
||||
/// The desktop app's graph. Tests pass their own [ports], [identity]
|
||||
@@ -63,6 +71,7 @@ class AppGraph {
|
||||
HealthGetter healthGet = defaultHealthGet,
|
||||
ServerStarter? serverStarter,
|
||||
JellyfinAccounts? jellyfin,
|
||||
ShowMatcher? library,
|
||||
}) {
|
||||
final p = ports ?? desktopPorts();
|
||||
final sfx = SynthSfx(
|
||||
@@ -81,6 +90,14 @@ class AppGraph {
|
||||
final connection = Connection(notify: notifier.notify, identity: identity ?? ClientIdentity.stored());
|
||||
final coordinator =
|
||||
SessionCoordinator(notifier: notifier, connection: connection, ytDlp: p.links, jellyfin: jellyfin);
|
||||
// Device-owned: the library box; AnimeFlow's runs when signed in.
|
||||
final matcher = library ??
|
||||
ShowMatcher(
|
||||
store: HiveLibraryStore(),
|
||||
aniList: AniListClient(),
|
||||
flow: AnimeFlowLookup(AnimeFlowClient.fromAccounts(coordinator.jellyfin)),
|
||||
);
|
||||
coordinator.fileRef = matcher.refForPath;
|
||||
final forms = ConnectStore();
|
||||
final yourServer = YourServer(
|
||||
forms: forms,
|
||||
@@ -119,6 +136,7 @@ class AppGraph {
|
||||
forms: forms,
|
||||
yourServer: yourServer,
|
||||
navigation: navigation,
|
||||
library: matcher,
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,196 +0,0 @@
|
||||
// Release file names -> series / season / episode, 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.
|
||||
|
||||
/// 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`, `NCED`, `OVA 2`, `Special`): such files
|
||||
/// carry no [episode] so they never sort among the real ones.
|
||||
final String? extra;
|
||||
|
||||
const ParsedName(
|
||||
this.series, {
|
||||
this.season,
|
||||
this.episode,
|
||||
this.group,
|
||||
this.year,
|
||||
this.extra,
|
||||
});
|
||||
|
||||
/// `S01E03`, `E25`, the [extra] marker, `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')}';
|
||||
return extra ?? 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)';
|
||||
}
|
||||
|
||||
/// 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'\[[^\]]*\]|\([^)]*\)|\{[^}]*\}|【[^】]*】');
|
||||
// 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,
|
||||
);
|
||||
// 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(OP|ED|SP|Specials?|Extras?|Recap|Preview|PV|Menu)\s?(\d{1,2})?'
|
||||
r'(?:v\d)?(?=$|\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,
|
||||
);
|
||||
final _trailingYear = RegExp(r'\s((?:19|20)\d{2})$');
|
||||
|
||||
/// 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)!);
|
||||
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();
|
||||
|
||||
int? season, episode;
|
||||
String? extra;
|
||||
var cut = s.length;
|
||||
final extraMatch =
|
||||
_extraAfterDash.firstMatch(s) ?? _extraAnywhere.firstMatch(s);
|
||||
final sxe = _sxe.firstMatch(s);
|
||||
if (extraMatch != null) {
|
||||
final word = extraMatch.group(1)!;
|
||||
final upper = word.length <= 4 ? word.toUpperCase() : _title(word);
|
||||
// `OVA - 02`: the number may follow the dash instead.
|
||||
final n = extraMatch.group(2) ??
|
||||
_dashNumber.firstMatch(s.substring(extraMatch.end))?.group(1);
|
||||
extra = n == null
|
||||
? upper
|
||||
: (upper.startsWith('NC') || upper.length <= 2)
|
||||
? '$upper${int.parse(n)}'
|
||||
: '$upper ${int.parse(n)}';
|
||||
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 {
|
||||
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);
|
||||
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);
|
||||
}
|
||||
series = series.replaceAll(RegExp(r'[\s\-–:,~]+$'), '').trim();
|
||||
// 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 = series.replaceAll(RegExp(r'[\s\-–:,~]+$'), '').trim();
|
||||
if (series.isEmpty) {
|
||||
series = original.replaceAll(RegExp(r'[._]+'), ' ').trim();
|
||||
}
|
||||
return ParsedName(
|
||||
series,
|
||||
season: season,
|
||||
episode: episode,
|
||||
group: group,
|
||||
year: year,
|
||||
extra: extra,
|
||||
);
|
||||
}
|
||||
|
||||
String _title(String w) => w[0].toUpperCase() + w.substring(1).toLowerCase();
|
||||
@@ -1,16 +1,13 @@
|
||||
import 'dart:io';
|
||||
import 'dart:isolate';
|
||||
|
||||
import 'name_parser.dart';
|
||||
import 'package:sync_relay_core/core.dart';
|
||||
|
||||
// Folder scan for the Files source: videos with their parsed names,
|
||||
// sidecar subtitles, everything else counted and hidden. The walk is
|
||||
// synchronous I/O, so it runs in its own isolate.
|
||||
export 'package:sync_relay_core/core.dart' show FileKind, LocalFile, kindOf, subtitleExtensions, videoExtensions;
|
||||
|
||||
const videoExtensions = {
|
||||
'mkv', 'mp4', 'webm', 'avi', 'mov', 'm4v', 'ts', 'm2ts', 'wmv', 'flv', //
|
||||
};
|
||||
const subtitleExtensions = {'ass', 'ssa', 'srt', 'vtt', 'sup'};
|
||||
// Folder scan for the Files source: the videos (grouped into shows by
|
||||
// core's `groupLocal`, L1), sidecar subtitles, everything else counted
|
||||
// and hidden. The walk is synchronous I/O, so it runs in its own isolate.
|
||||
|
||||
/// Files looked at per scan; past this the result is [FolderScan.truncated].
|
||||
const maxScannedFiles = 20000;
|
||||
@@ -18,27 +15,8 @@ const maxScannedFiles = 20000;
|
||||
/// Samples and trailers under this size are hidden next to the real thing.
|
||||
const _sampleMaxBytes = 300 << 20;
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
class VideoFile {
|
||||
final String path;
|
||||
final int size;
|
||||
final ParsedName parsed;
|
||||
final DateTime modified;
|
||||
const VideoFile(this.path, this.size, this.parsed, this.modified);
|
||||
|
||||
String get name => _baseName(path);
|
||||
}
|
||||
|
||||
class FolderScan {
|
||||
final List<VideoFile> videos;
|
||||
final List<LocalFile> videos;
|
||||
/// Non-video files and samples (subtitles and dot-entries not counted).
|
||||
final int hiddenCount;
|
||||
/// Up to 6 of the hidden file names, for "and N other files" hints.
|
||||
@@ -75,7 +53,7 @@ Future<FolderScan> scanFolder(String dir, {int maxFiles = maxScannedFiles}) =>
|
||||
|
||||
/// The walk behind [scanFolder]; blocks, so call it from an isolate.
|
||||
FolderScan scanFolderSync(String dir, {int maxFiles = maxScannedFiles}) {
|
||||
final videos = <VideoFile>[];
|
||||
final videos = <LocalFile>[];
|
||||
final subs = <String>[];
|
||||
final examples = <String>[];
|
||||
var hidden = 0, seen = 0;
|
||||
@@ -123,8 +101,7 @@ FolderScan scanFolderSync(String dir, {int maxFiles = maxScannedFiles}) {
|
||||
if (examples.length < 6) examples.add(name);
|
||||
continue;
|
||||
}
|
||||
videos.add(VideoFile(e.path, size, parseReleaseName(name),
|
||||
stat?.modified ?? DateTime.fromMillisecondsSinceEpoch(0)));
|
||||
videos.add(LocalFile(e.path, size, stat?.modified ?? DateTime.fromMillisecondsSinceEpoch(0)));
|
||||
}
|
||||
}
|
||||
return FolderScan(
|
||||
@@ -138,7 +115,7 @@ FolderScan scanFolderSync(String dir, {int maxFiles = maxScannedFiles}) {
|
||||
|
||||
/// Subtitles in the same folder whose name is the video's base name,
|
||||
/// optionally followed by `.lang` / `.forced` style suffixes.
|
||||
Map<String, List<String>> _sidecars(List<VideoFile> videos, List<String> subs) {
|
||||
Map<String, List<String>> _sidecars(List<LocalFile> videos, List<String> subs) {
|
||||
final byStem = <String, List<String>>{};
|
||||
for (final s in subs) {
|
||||
final stem = _stripExtension(s);
|
||||
@@ -163,93 +140,11 @@ Map<String, List<String>> _sidecars(List<VideoFile> videos, List<String> subs) {
|
||||
};
|
||||
}
|
||||
|
||||
/// One show: its episodes in season/episode order plus its extras
|
||||
/// (NCOP, OVA...) in name order.
|
||||
class SeriesGroup {
|
||||
/// Display title: the most common spelling among the files.
|
||||
final String title;
|
||||
final String key;
|
||||
final List<VideoFile> episodes;
|
||||
final List<VideoFile> extras;
|
||||
const SeriesGroup(this.title, this.key, this.episodes, this.extras);
|
||||
|
||||
/// Distinct seasons, ascending; files without one count as season 1.
|
||||
List<int> get seasons =>
|
||||
({for (final e in episodes) e.parsed.season ?? 1}.toList()..sort());
|
||||
}
|
||||
|
||||
/// [groupBySeries] result: shows, and videos that are not episodes.
|
||||
class GroupedVideos {
|
||||
final List<SeriesGroup> series;
|
||||
final List<VideoFile> loose;
|
||||
const GroupedVideos(this.series, this.loose);
|
||||
}
|
||||
|
||||
/// Groups videos with an episode number (or an extra marker) by
|
||||
/// [ParsedName.seriesKey]; the rest (movies, clips) stay loose, by name.
|
||||
/// An extra whose series has no episodes in the list is loose too.
|
||||
GroupedVideos groupBySeries(Iterable<VideoFile> videos) {
|
||||
final episodes = <String, List<VideoFile>>{};
|
||||
final extras = <String, List<VideoFile>>{};
|
||||
final loose = <VideoFile>[];
|
||||
for (final v in videos) {
|
||||
final p = v.parsed;
|
||||
final key = p.seriesKey;
|
||||
if (p.episode != null && key.isNotEmpty) {
|
||||
episodes.putIfAbsent(key, () => []).add(v);
|
||||
} else if (p.extra != null && key.isNotEmpty) {
|
||||
extras.putIfAbsent(key, () => []).add(v);
|
||||
} else {
|
||||
loose.add(v);
|
||||
}
|
||||
}
|
||||
for (final e in extras.entries) {
|
||||
if (!episodes.containsKey(e.key)) loose.addAll(e.value);
|
||||
}
|
||||
int byName(VideoFile a, VideoFile b) =>
|
||||
a.name.toLowerCase().compareTo(b.name.toLowerCase());
|
||||
final groups = [
|
||||
for (final e in episodes.entries)
|
||||
SeriesGroup(
|
||||
_commonTitle(e.value),
|
||||
e.key,
|
||||
e.value..sort(_byEpisode),
|
||||
(extras[e.key] ?? [])..sort(byName),
|
||||
),
|
||||
]..sort((a, b) => a.title.toLowerCase().compareTo(b.title.toLowerCase()));
|
||||
return GroupedVideos(groups, loose..sort(byName));
|
||||
}
|
||||
|
||||
int _byEpisode(VideoFile a, VideoFile b) {
|
||||
final pa = a.parsed, pb = b.parsed;
|
||||
final s = (pa.season ?? 1).compareTo(pb.season ?? 1);
|
||||
if (s != 0) return s;
|
||||
final e = pa.episode!.compareTo(pb.episode!);
|
||||
if (e != 0) return e;
|
||||
// Same episode twice (v2, another group): newest release first.
|
||||
return b.name.compareTo(a.name);
|
||||
}
|
||||
|
||||
String _commonTitle(List<VideoFile> files) {
|
||||
final counts = <String, int>{};
|
||||
for (final f in files) {
|
||||
counts.update(f.parsed.series, (n) => n + 1, ifAbsent: () => 1);
|
||||
}
|
||||
return counts.entries
|
||||
.reduce((a, b) => b.value > a.value ? b : a)
|
||||
.key;
|
||||
}
|
||||
|
||||
String _baseName(String path) {
|
||||
final i = path.lastIndexOf(RegExp(r'[/\\]'));
|
||||
return i < 0 ? path : path.substring(i + 1);
|
||||
}
|
||||
|
||||
String _extension(String name) {
|
||||
final dot = name.lastIndexOf('.');
|
||||
return dot <= 0 ? '' : name.substring(dot + 1).toLowerCase();
|
||||
}
|
||||
|
||||
String _stripExtension(String path) {
|
||||
final dot = path.lastIndexOf('.');
|
||||
final sep = path.lastIndexOf(RegExp(r'[/\\]'));
|
||||
|
||||
@@ -2,13 +2,13 @@ import 'dart:io';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import 'package:sync_relay_core/core.dart';
|
||||
|
||||
import '../../shell/avatar.dart';
|
||||
import '../name_parser.dart';
|
||||
import '../scanner.dart';
|
||||
import 'thumb.dart';
|
||||
|
||||
// The Files page's rows and cards: a series header, an episode row (also
|
||||
// used for Recent), a loose-video card, and the empty states.
|
||||
// The Files page's rows and cards: an episode row (also used for Recent),
|
||||
// a loose-video card, and the empty states.
|
||||
|
||||
/// What a play control is doing right now.
|
||||
enum PlayState { idle, starting, playing }
|
||||
@@ -25,76 +25,8 @@ String parentName(String path) => baseName(File(path).parent.path);
|
||||
/// Cleaned title of a loose video: `Title (2019)`.
|
||||
String cleanTitle(ParsedName p) => p.year == null ? p.series : '${p.series} (${p.year})';
|
||||
|
||||
class SeriesHeader extends StatelessWidget {
|
||||
final SeriesGroup group;
|
||||
final ThumbCache thumbs;
|
||||
/// Its episodes are listed below; [onTap] opens or closes them.
|
||||
final bool expanded;
|
||||
final VoidCallback? onTap;
|
||||
const SeriesHeader(
|
||||
{super.key, required this.group, required this.thumbs, this.expanded = true, this.onTap});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final scheme = Theme.of(context).colorScheme;
|
||||
final first = group.episodes.first;
|
||||
final n = group.episodes.length;
|
||||
final extras = group.extras.length;
|
||||
final detail = [
|
||||
'$n ${n == 1 ? 'episode' : 'episodes'}',
|
||||
if (extras > 0) '$extras ${extras == 1 ? 'extra' : 'extras'}',
|
||||
?first.parsed.group,
|
||||
].join(' · ');
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(top: 14),
|
||||
child: InkWell(
|
||||
onTap: onTap,
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 4),
|
||||
child: Row(
|
||||
children: [
|
||||
ClipRRect(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
child: ThumbBuilder(
|
||||
cache: thumbs,
|
||||
path: first.path,
|
||||
builder: (context, t, _) => ThumbImage(t, width: 96, height: 54),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(group.title,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: Theme.of(context).textTheme.titleMedium),
|
||||
Text(detail,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: TextStyle(color: scheme.onSurfaceVariant, fontSize: 12)),
|
||||
],
|
||||
),
|
||||
),
|
||||
if (onTap != null)
|
||||
AnimatedRotation(
|
||||
turns: expanded ? 0.5 : 0,
|
||||
duration: const Duration(milliseconds: 200),
|
||||
child: Icon(Icons.expand_more, color: scheme.onSurfaceVariant),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// One file as a row: an episode under its series header, or a Recent
|
||||
/// entry ([title] given, [indent] off).
|
||||
/// One file as a row: an episode on a show's page, or a Recent entry
|
||||
/// ([indent] off).
|
||||
class FileRow extends StatelessWidget {
|
||||
final String path;
|
||||
final String title;
|
||||
@@ -207,7 +139,7 @@ const videoCardTextHeight = 66.0;
|
||||
/// A loose video (movie, clip): 16:9 frame with its duration, then the
|
||||
/// cleaned title and size.
|
||||
class VideoCard extends StatelessWidget {
|
||||
final VideoFile video;
|
||||
final LocalEpisode video;
|
||||
final PlayState state;
|
||||
final ThumbCache thumbs;
|
||||
final VoidCallback? onTap;
|
||||
@@ -281,7 +213,7 @@ class VideoCard extends StatelessWidget {
|
||||
),
|
||||
),
|
||||
),
|
||||
Text(formatSize(video.size),
|
||||
Text(formatSize(video.file.size),
|
||||
style: TextStyle(color: scheme.onSurfaceVariant, fontSize: 12)),
|
||||
],
|
||||
),
|
||||
|
||||
@@ -3,51 +3,57 @@ import 'dart:io';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:signals/signals_flutter.dart';
|
||||
import 'package:sync_relay_core/core.dart';
|
||||
|
||||
import '../../library/show_matcher.dart';
|
||||
import '../../shell/motion.dart';
|
||||
import '../../platform/ports/folder_access.dart';
|
||||
import '../../app/app_graph.dart';
|
||||
import '../../widgets/app_menu.dart';
|
||||
import '../folder_store.dart';
|
||||
import '../name_parser.dart';
|
||||
import '../scanner.dart';
|
||||
import 'file_tiles.dart';
|
||||
import 'library_view.dart';
|
||||
import 'thumb.dart';
|
||||
|
||||
export 'thumb.dart' show ThumbnailLoader;
|
||||
|
||||
// The Files source: watched folders on the left (chips on top when
|
||||
// narrow), the open one on the right grouped into series and loose
|
||||
// videos, or Recent. A folder is scanned when first opened and on Rescan;
|
||||
// scans are kept for the app session. Series start collapsed; the ones
|
||||
// opened stay open for the app session. Playing goes through [onPlay].
|
||||
// The Files source as a library: the watched folders on the left (chips
|
||||
// on top when narrow): the Library (every folder together), Recent, then
|
||||
// each folder. A library is shows and movies as poster cards, grouped by
|
||||
// the library's L1 and named by its L2 match, plus the videos that are
|
||||
// neither; a show opens its own page. Folders are scanned when first
|
||||
// needed and on Rescan; scans, the open place and the open show are kept
|
||||
// for the app session. Playing goes through [onPlay].
|
||||
|
||||
/// Below this width the sidebar turns into chips above the list.
|
||||
const filesNarrowWidth = 700.0;
|
||||
|
||||
/// The place that shows every watched folder together.
|
||||
const libraryPlace = '\u0000library';
|
||||
|
||||
/// Scans kept for the app session, by folder.
|
||||
final _scans = <String, FolderScan>{};
|
||||
final _running = <String, Future<FolderScan>>{};
|
||||
String? _lastOpen;
|
||||
/// Series opened this session, by `folder\nseries key`.
|
||||
final _expanded = <String>{};
|
||||
String? _lastOpen = libraryPlace;
|
||||
/// The show open in each place (by show key) for the app session.
|
||||
final _openShows = <String, String>{};
|
||||
final _showRequests = ValueNotifier(0);
|
||||
|
||||
|
||||
/// Opens [dir] (null: Recent) in the Files page: now if one is showing,
|
||||
/// else when it next opens.
|
||||
/// Opens [dir] (null: Recent; [libraryPlace]: the library) in the Files
|
||||
/// page: now if one is showing, else when it next opens.
|
||||
void showFilesFolder(String? dir) {
|
||||
_lastOpen = dir;
|
||||
_showRequests.value++;
|
||||
}
|
||||
|
||||
/// Forgets the session's scans, open series and the open folder.
|
||||
/// Forgets the session's scans, open shows and the open place.
|
||||
@visibleForTesting
|
||||
void resetFilesPageSession() {
|
||||
_scans.clear();
|
||||
_running.clear();
|
||||
_expanded.clear();
|
||||
_lastOpen = null;
|
||||
_openShows.clear();
|
||||
_lastOpen = libraryPlace;
|
||||
}
|
||||
|
||||
class FilesPage extends StatefulWidget {
|
||||
@@ -66,6 +72,8 @@ class FilesPage extends StatefulWidget {
|
||||
final Future<FolderScan> Function(String dir)? scan;
|
||||
/// Defaults to [FolderStore.instance].
|
||||
final FolderStore? store;
|
||||
/// Defaults to the app's (`AppGraph.library`).
|
||||
final ShowMatcher? matcher;
|
||||
/// Default: the system folder / file dialogs.
|
||||
final Future<String?> Function()? pickFolder;
|
||||
final Future<String?> Function()? pickVideo;
|
||||
@@ -80,6 +88,7 @@ class FilesPage extends StatefulWidget {
|
||||
this.thumbnails,
|
||||
this.scan,
|
||||
this.store,
|
||||
this.matcher,
|
||||
this.pickFolder,
|
||||
this.pickVideo,
|
||||
});
|
||||
@@ -88,52 +97,56 @@ class FilesPage extends StatefulWidget {
|
||||
State<FilesPage> createState() => _FilesPageState();
|
||||
}
|
||||
|
||||
/// A scan grouped for display, made once per scan.
|
||||
/// A place's scans grouped (L1), made once per set of scans.
|
||||
class _Library {
|
||||
final GroupedVideos grouped;
|
||||
final int subtitleFiles;
|
||||
final List<FolderScan> scans;
|
||||
final LocalLibrary lib;
|
||||
final Map<String, LocalEpisode> byPath;
|
||||
|
||||
_Library(FolderScan scan)
|
||||
: grouped = groupBySeries(scan.videos),
|
||||
subtitleFiles = {for (final l in scan.subtitles.values) ...l}.length;
|
||||
_Library(this.scans, List<String> roots)
|
||||
: lib = groupLocal([for (final s in scans) ...s.videos], roots: roots),
|
||||
byPath = {} {
|
||||
for (final e in lib.files) {
|
||||
byPath[e.path] = e;
|
||||
}
|
||||
}
|
||||
|
||||
/// Series rows in order: a header (video null), then, for the series
|
||||
/// [open] says, its episodes and extras.
|
||||
List<(SeriesGroup, VideoFile?)> rows(bool Function(SeriesGroup g) open) => [
|
||||
for (final g in grouped.series) ...[
|
||||
(g, null),
|
||||
if (open(g))
|
||||
for (final v in [...g.episodes, ...g.extras]) (g, v),
|
||||
],
|
||||
];
|
||||
int get videos => scans.fold(0, (n, s) => n + s.videos.length);
|
||||
int get hidden => scans.fold(0, (n, s) => n + s.hiddenCount);
|
||||
int get subtitleFiles => {
|
||||
for (final s in scans)
|
||||
for (final l in s.subtitles.values) ...l
|
||||
}.length;
|
||||
bool get truncated => scans.any((s) => s.truncated);
|
||||
}
|
||||
|
||||
final _libraries = Expando<_Library>();
|
||||
|
||||
class _FilesPageState extends State<FilesPage> {
|
||||
FolderAccess get _folders => AppScope.of(context).ports.folders;
|
||||
ShowMatcher get _matcher => widget.matcher ?? AppScope.of(context).library;
|
||||
|
||||
late final ThumbCache _thumbs =
|
||||
widget.thumbnails == null ? ThumbCache.shared : ThumbCache(widget.thumbnails!);
|
||||
FolderStore get _store => widget.store ?? FolderStore.instance;
|
||||
|
||||
/// Null: Recent.
|
||||
/// Null: Recent; [libraryPlace]: every folder.
|
||||
String? _open = _lastOpen;
|
||||
bool _showHidden = false;
|
||||
String? _starting;
|
||||
String? _error;
|
||||
final _libs = <String, _Library>{};
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_showRequests.addListener(_shown);
|
||||
unawaited(_matcher.ensureLoaded());
|
||||
_store.ensureLoaded().then((_) {
|
||||
if (!mounted) return;
|
||||
final open = _open;
|
||||
if (open != null && !_store.folders.value.contains(open)) {
|
||||
_select(null);
|
||||
} else if (open != null) {
|
||||
_scan(open);
|
||||
if (open != null && open != libraryPlace && !_store.folders.value.contains(open)) {
|
||||
_select(libraryPlace);
|
||||
} else {
|
||||
_scanFor(open);
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -148,19 +161,32 @@ class _FilesPageState extends State<FilesPage> {
|
||||
|
||||
Future<FolderScan> _runScan(String dir) => (widget.scan ?? _folders.scan)(dir);
|
||||
|
||||
void _select(String? dir) {
|
||||
/// The folders a place needs scanned.
|
||||
List<String> _foldersOf(String? place) => place == null
|
||||
? const []
|
||||
: place == libraryPlace
|
||||
? _store.folders.peek()
|
||||
: [place];
|
||||
|
||||
void _scanFor(String? place) {
|
||||
for (final f in _foldersOf(place)) {
|
||||
if (!_scans.containsKey(f) && !_running.containsKey(f)) _scan(f);
|
||||
}
|
||||
}
|
||||
|
||||
void _select(String? place) {
|
||||
setState(() {
|
||||
_open = _lastOpen = dir;
|
||||
_open = _lastOpen = place;
|
||||
_showHidden = false;
|
||||
});
|
||||
if (dir != null && !_scans.containsKey(dir)) _scan(dir);
|
||||
_scanFor(place);
|
||||
}
|
||||
|
||||
Future<void> _scan(String dir, {bool rescan = false}) async {
|
||||
var f = _running[dir];
|
||||
if (f == null || rescan) {
|
||||
f = _running[dir] = _runScan(dir);
|
||||
if (rescan) _thumbs.forget([for (final v in _scans[dir]?.videos ?? const <VideoFile>[]) v.path]);
|
||||
if (rescan) _thumbs.forget([for (final v in _scans[dir]?.videos ?? const <LocalFile>[]) v.path]);
|
||||
}
|
||||
if (mounted) setState(() {});
|
||||
FolderScan result;
|
||||
@@ -176,6 +202,26 @@ class _FilesPageState extends State<FilesPage> {
|
||||
if (mounted) setState(() {});
|
||||
}
|
||||
|
||||
/// The grouped library of [place], once its folders are scanned (the
|
||||
/// library: those scanned so far).
|
||||
_Library? _libraryOf(String place) {
|
||||
final folders = _foldersOf(place);
|
||||
final scans = [for (final f in folders) ?_scans[f]];
|
||||
if (place != libraryPlace && scans.isEmpty) return null;
|
||||
final known = _libs[place];
|
||||
if (known != null && known.scans.length == scans.length && _same(known.scans, scans)) return known;
|
||||
final lib = _libs[place] = _Library(scans, folders);
|
||||
_matcher.remember(lib.lib);
|
||||
return lib;
|
||||
}
|
||||
|
||||
static bool _same(List<FolderScan> a, List<FolderScan> b) {
|
||||
for (var i = 0; i < a.length; i++) {
|
||||
if (!identical(a[i], b[i])) return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
Future<void> _addFolder() async {
|
||||
String? dir;
|
||||
try {
|
||||
@@ -207,7 +253,8 @@ class _FilesPageState extends State<FilesPage> {
|
||||
void _removeFolder(String dir) {
|
||||
_store.removeFolder(dir);
|
||||
_scans.remove(dir);
|
||||
if (_open == dir) _select(null);
|
||||
if (_open == dir) _select(libraryPlace);
|
||||
setState(() {});
|
||||
}
|
||||
|
||||
Future<void> _play(String path) async {
|
||||
@@ -271,6 +318,22 @@ class _FilesPageState extends State<FilesPage> {
|
||||
child: Builder(
|
||||
builder: (context) => GestureDetector(onSecondaryTap: () => _menu(context, path), child: child)));
|
||||
|
||||
late final _actions = FilesActions(
|
||||
play: (p) => unawaited(_play(p)),
|
||||
stateOf: _stateOf,
|
||||
menuable: _menuable,
|
||||
thumbs: _thumbs,
|
||||
);
|
||||
|
||||
/// The last episode of [show] played here (Recent), by key.
|
||||
String? _lastPlayed(_Library lib, LocalShow show) {
|
||||
for (final p in _store.recent.value) {
|
||||
final e = lib.byPath[p];
|
||||
if (e != null && e.showKey == show.key && e.kind == LocalKind.episode) return e.key;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) => LayoutBuilder(
|
||||
builder: (context, box) {
|
||||
@@ -278,11 +341,13 @@ class _FilesPageState extends State<FilesPage> {
|
||||
return SignalBuilder(builder: (context) {
|
||||
final folders = _store.folders.value;
|
||||
final recent = _store.recent.value;
|
||||
final open = _open != null && folders.contains(_open) ? _open : null;
|
||||
_matcher.revision.value;
|
||||
final open = _open == null || _open == libraryPlace || folders.contains(_open) ? _open : libraryPlace;
|
||||
final showKey = open == null ? null : _openShows[open];
|
||||
final main = fadeThrough(
|
||||
KeyedSubtree(
|
||||
key: ValueKey(open ?? ''),
|
||||
child: _main(context, narrow, folders, recent, open),
|
||||
key: ValueKey('${open ?? ''}\n${showKey ?? ''}'),
|
||||
child: _main(context, narrow, folders, recent, open, showKey),
|
||||
),
|
||||
);
|
||||
if (narrow) return main;
|
||||
@@ -302,12 +367,23 @@ class _FilesPageState extends State<FilesPage> {
|
||||
Widget _sidebar(BuildContext context, List<String> folders, String? open) {
|
||||
final scheme = Theme.of(context).colorScheme;
|
||||
final shape = RoundedRectangleBorder(borderRadius: BorderRadius.circular(10));
|
||||
Widget? status(String? s) =>
|
||||
s == null ? null : Text(s, style: TextStyle(color: scheme.onSurfaceVariant, fontSize: 11));
|
||||
return ListView(
|
||||
padding: const EdgeInsets.fromLTRB(20, 20, 8, 20),
|
||||
children: [
|
||||
const Text('Folders', style: TextStyle(fontWeight: FontWeight.w600)),
|
||||
const SizedBox(height: 8),
|
||||
ListTile(
|
||||
key: const ValueKey('place:library'),
|
||||
dense: true,
|
||||
selected: open == libraryPlace,
|
||||
shape: shape,
|
||||
leading: const Icon(Icons.video_library_outlined, size: 18),
|
||||
title: const Text('Library'),
|
||||
subtitle: status(_libraryStatus(folders)),
|
||||
onTap: () => _select(libraryPlace),
|
||||
),
|
||||
ListTile(
|
||||
key: const ValueKey('place:recent'),
|
||||
dense: true,
|
||||
selected: open == null,
|
||||
shape: shape,
|
||||
@@ -315,6 +391,9 @@ class _FilesPageState extends State<FilesPage> {
|
||||
title: const Text('Recent'),
|
||||
onTap: () => _select(null),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
const Text('Folders', style: TextStyle(fontWeight: FontWeight.w600)),
|
||||
const SizedBox(height: 8),
|
||||
for (final f in folders)
|
||||
ListTile(
|
||||
key: ValueKey('folder:$f'),
|
||||
@@ -323,10 +402,7 @@ class _FilesPageState extends State<FilesPage> {
|
||||
shape: shape,
|
||||
leading: const Icon(Icons.folder_outlined, size: 18),
|
||||
title: Text(baseName(f), maxLines: 1, overflow: TextOverflow.ellipsis),
|
||||
subtitle: switch (_folderStatus(f)) {
|
||||
final s? => Text(s, style: TextStyle(color: scheme.onSurfaceVariant, fontSize: 11)),
|
||||
null => null,
|
||||
},
|
||||
subtitle: status(_folderStatus(f)),
|
||||
trailing: IconButton(
|
||||
tooltip: 'Remove folder',
|
||||
icon: const Icon(Icons.close, size: 16),
|
||||
@@ -364,33 +440,77 @@ class _FilesPageState extends State<FilesPage> {
|
||||
return n == null ? null : '$n ${n == 1 ? 'video' : 'videos'}';
|
||||
}
|
||||
|
||||
String? _libraryStatus(List<String> folders) {
|
||||
if (folders.any(_running.containsKey)) return 'scanning…';
|
||||
if (folders.isEmpty || folders.any((f) => !_scans.containsKey(f))) return null;
|
||||
final n = _libraryOf(libraryPlace)?.lib.shows.length ?? 0;
|
||||
return '$n ${n == 1 ? 'show' : 'shows'}';
|
||||
}
|
||||
|
||||
List<Widget> _notices() => [
|
||||
if (!widget.alone && widget.relayed) const _RelayNotice(),
|
||||
AnimatedSize(
|
||||
duration: easeTime,
|
||||
curve: ease,
|
||||
alignment: Alignment.topLeft,
|
||||
child: _error == null
|
||||
? const SizedBox(width: double.infinity)
|
||||
: _ErrorBanner(_error!, onClose: () => setState(() => _error = null)),
|
||||
),
|
||||
];
|
||||
|
||||
Widget _main(
|
||||
BuildContext context,
|
||||
bool narrow,
|
||||
List<String> folders,
|
||||
List<String> recent,
|
||||
String? open,
|
||||
String? showKey,
|
||||
) {
|
||||
final scan = open == null ? null : _scans[open];
|
||||
final scanning = open != null && _running.containsKey(open);
|
||||
final lib = open == null ? null : _libraryOf(open);
|
||||
final show = showKey == null ? null : lib?.lib.show(showKey);
|
||||
if (open != null && lib != null && show != null) {
|
||||
return ShowPage(
|
||||
key: ValueKey('show:$open:${show.key}'),
|
||||
show: show,
|
||||
matcher: _matcher,
|
||||
actions: _actions,
|
||||
lastPlayed: _lastPlayed(lib, show),
|
||||
backLabel: open == libraryPlace ? 'Library' : baseName(open),
|
||||
onBack: () => setState(() => _openShows.remove(open)),
|
||||
top: [if (narrow) _chips(folders, open), ..._notices()],
|
||||
);
|
||||
}
|
||||
final scanning = _foldersOf(open).any(_running.containsKey);
|
||||
// A folder added elsewhere (a drop, the handoff) joins the library.
|
||||
if (open == libraryPlace && folders.any((f) => !_scans.containsKey(f) && !_running.containsKey(f))) {
|
||||
scheduleMicrotask(() {
|
||||
if (mounted) _scanFor(libraryPlace);
|
||||
});
|
||||
}
|
||||
final top = <Widget>[
|
||||
_header(context, open),
|
||||
if (narrow) _chips(folders, open),
|
||||
if (!widget.alone && widget.relayed) const _RelayNotice(),
|
||||
AnimatedSize(
|
||||
duration: easeTime,
|
||||
curve: ease,
|
||||
alignment: Alignment.topLeft,
|
||||
child: _error == null
|
||||
? const SizedBox(width: double.infinity)
|
||||
: _ErrorBanner(_error!, onClose: () => setState(() => _error = null)),
|
||||
),
|
||||
..._notices(),
|
||||
];
|
||||
final slivers = <Widget>[
|
||||
SliverList.list(children: top),
|
||||
if (open == null)
|
||||
..._recent(context, recent)
|
||||
else if (scan == null)
|
||||
else if (open == libraryPlace && folders.isEmpty)
|
||||
SliverToBoxAdapter(
|
||||
child: FilesEmpty(
|
||||
Icons.video_library_outlined,
|
||||
'Your library is empty',
|
||||
'Add a folder with your videos: shows, seasons and movies are found by their names.',
|
||||
action: FilledButton.icon(
|
||||
onPressed: _addFolder,
|
||||
icon: const Icon(Icons.create_new_folder_outlined),
|
||||
label: const Text('Add folder'),
|
||||
),
|
||||
),
|
||||
)
|
||||
else if (lib == null)
|
||||
const SliverToBoxAdapter(
|
||||
child: Padding(
|
||||
padding: EdgeInsets.only(top: 60),
|
||||
@@ -398,7 +518,7 @@ class _FilesPageState extends State<FilesPage> {
|
||||
),
|
||||
)
|
||||
else
|
||||
..._folder(context, open, scan, scanning),
|
||||
..._library(context, open, lib, scanning),
|
||||
];
|
||||
return CustomScrollView(
|
||||
slivers: [
|
||||
@@ -412,25 +532,30 @@ class _FilesPageState extends State<FilesPage> {
|
||||
|
||||
Widget _header(BuildContext context, String? open) {
|
||||
final scheme = Theme.of(context).colorScheme;
|
||||
final (icon, title, sub) = switch (open) {
|
||||
null => (Icons.history, 'Recent', null),
|
||||
libraryPlace => (Icons.video_library, 'Library', null),
|
||||
final dir => (Icons.folder, baseName(dir), dir),
|
||||
};
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(bottom: 14),
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(open == null ? Icons.history : Icons.folder, color: scheme.primary),
|
||||
Icon(icon, color: scheme.primary),
|
||||
const SizedBox(width: 10),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
open == null ? 'Recent' : baseName(open),
|
||||
title,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: Theme.of(context).textTheme.headlineSmall,
|
||||
),
|
||||
if (open != null)
|
||||
if (sub != null)
|
||||
Text(
|
||||
open,
|
||||
sub,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: TextStyle(color: scheme.onSurfaceVariant, fontSize: 12),
|
||||
@@ -449,6 +574,11 @@ class _FilesPageState extends State<FilesPage> {
|
||||
spacing: 8,
|
||||
runSpacing: 8,
|
||||
children: [
|
||||
ChoiceChip(
|
||||
label: const Text('Library'),
|
||||
selected: open == libraryPlace,
|
||||
onSelected: (_) => _select(libraryPlace),
|
||||
),
|
||||
ChoiceChip(
|
||||
label: const Text('Recent'),
|
||||
selected: open == null,
|
||||
@@ -506,6 +636,7 @@ class _FilesPageState extends State<FilesPage> {
|
||||
_RecentRow(
|
||||
key: ValueKey(path),
|
||||
path: path,
|
||||
matcher: _matcher,
|
||||
state: _stateOf(path),
|
||||
thumbs: _thumbs,
|
||||
onTap: _tapOf(path),
|
||||
@@ -516,21 +647,32 @@ class _FilesPageState extends State<FilesPage> {
|
||||
];
|
||||
}
|
||||
|
||||
List<Widget> _folder(BuildContext context, String dir, FolderScan scan, bool scanning) {
|
||||
Widget _section(BuildContext context, String title, {String? count}) => Padding(
|
||||
padding: const EdgeInsets.only(top: 22, bottom: 10),
|
||||
child: Row(children: [
|
||||
Text(title, style: Theme.of(context).textTheme.titleMedium),
|
||||
if (count != null) ...[
|
||||
const SizedBox(width: 8),
|
||||
Text(count, style: Theme.of(context).textTheme.bodySmall?.copyWith(color: Colors.grey)),
|
||||
],
|
||||
]),
|
||||
);
|
||||
|
||||
List<Widget> _library(BuildContext context, String place, _Library lib, bool scanning) {
|
||||
final scheme = Theme.of(context).colorScheme;
|
||||
final lib = _libraries[scan] ??= _Library(scan);
|
||||
final series = lib.grouped.series.length;
|
||||
final l = lib.lib;
|
||||
final folderScan = place == libraryPlace ? null : _scans[place];
|
||||
String count(int n, String one, String many) => '$n ${n == 1 ? one : many}';
|
||||
final summary = [
|
||||
count(scan.videos.length, 'video', 'videos') + (series > 0 ? ' in $series series' : ''),
|
||||
if (scan.hiddenCount > 0)
|
||||
'${count(scan.hiddenCount, 'other file', 'other files')} hidden',
|
||||
count(lib.videos, 'video', 'videos') +
|
||||
(l.shows.isEmpty ? '' : ' in ${count(l.shows.length, 'show', 'shows')}'),
|
||||
if (lib.hidden > 0) '${count(lib.hidden, 'other file', 'other files')} hidden',
|
||||
if (lib.subtitleFiles > 0) count(lib.subtitleFiles, 'subtitle file', 'subtitle files'),
|
||||
if (scan.truncated) 'stopped after $maxScannedFiles files',
|
||||
if (lib.truncated) 'stopped after $maxScannedFiles files',
|
||||
].join(' · ');
|
||||
final loose = lib.grouped.loose;
|
||||
String seriesId(SeriesGroup g) => '$dir\n${g.key}';
|
||||
final rows = lib.rows((g) => _expanded.contains(seriesId(g)));
|
||||
final folders = _foldersOf(place);
|
||||
final movies = l.movies;
|
||||
final other = l.other;
|
||||
return [
|
||||
SliverToBoxAdapter(
|
||||
child: Wrap(
|
||||
@@ -539,7 +681,7 @@ class _FilesPageState extends State<FilesPage> {
|
||||
Icon(Icons.filter_alt_outlined, size: 16, color: scheme.onSurfaceVariant),
|
||||
const SizedBox(width: 6),
|
||||
Text(summary, style: TextStyle(color: scheme.onSurfaceVariant, fontSize: 12)),
|
||||
if (scan.hiddenCount > 0) ...[
|
||||
if (folderScan != null && folderScan.hiddenCount > 0) ...[
|
||||
const SizedBox(width: 8),
|
||||
TextButton(
|
||||
onPressed: () => setState(() => _showHidden = !_showHidden),
|
||||
@@ -547,49 +689,55 @@ class _FilesPageState extends State<FilesPage> {
|
||||
),
|
||||
],
|
||||
TextButton.icon(
|
||||
onPressed: scanning ? null : () => _scan(dir, rescan: true),
|
||||
onPressed: scanning
|
||||
? null
|
||||
: () {
|
||||
for (final f in folders) {
|
||||
_scan(f, rescan: true);
|
||||
}
|
||||
},
|
||||
icon: scanning
|
||||
? const SizedBox.square(
|
||||
dimension: 14, child: CircularProgressIndicator(strokeWidth: 2))
|
||||
? const SizedBox.square(dimension: 14, child: CircularProgressIndicator(strokeWidth: 2))
|
||||
: const Icon(Icons.refresh, size: 16),
|
||||
label: Text(scanning ? 'Scanning…' : 'Rescan'),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
SliverToBoxAdapter(
|
||||
child: AnimatedSize(
|
||||
duration: easeTime,
|
||||
curve: ease,
|
||||
alignment: Alignment.topLeft,
|
||||
child: !_showHidden
|
||||
? const SizedBox(width: double.infinity)
|
||||
: Column(
|
||||
children: [
|
||||
for (final f in scan.hiddenExamples)
|
||||
ListTile(
|
||||
dense: true,
|
||||
enabled: false,
|
||||
leading: const Icon(Icons.insert_drive_file_outlined, size: 18),
|
||||
title: Text(f, maxLines: 1, overflow: TextOverflow.ellipsis),
|
||||
trailing: Text(
|
||||
kindOf(f) == FileKind.video ? 'sample' : 'not a video',
|
||||
style: const TextStyle(fontSize: 12),
|
||||
),
|
||||
),
|
||||
if (scan.hiddenCount > scan.hiddenExamples.length)
|
||||
ListTile(
|
||||
dense: true,
|
||||
enabled: false,
|
||||
title: Text('and ${scan.hiddenCount - scan.hiddenExamples.length} more'),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
if (scan.videos.isEmpty)
|
||||
if (folderScan != null)
|
||||
SliverToBoxAdapter(
|
||||
child: _folders.folderExists(dir)
|
||||
child: AnimatedSize(
|
||||
duration: easeTime,
|
||||
curve: ease,
|
||||
alignment: Alignment.topLeft,
|
||||
child: !_showHidden
|
||||
? const SizedBox(width: double.infinity)
|
||||
: Column(
|
||||
children: [
|
||||
for (final f in folderScan.hiddenExamples)
|
||||
ListTile(
|
||||
dense: true,
|
||||
enabled: false,
|
||||
leading: const Icon(Icons.insert_drive_file_outlined, size: 18),
|
||||
title: Text(f, maxLines: 1, overflow: TextOverflow.ellipsis),
|
||||
trailing: Text(
|
||||
kindOf(f) == FileKind.video ? 'sample' : 'not a video',
|
||||
style: const TextStyle(fontSize: 12),
|
||||
),
|
||||
),
|
||||
if (folderScan.hiddenCount > folderScan.hiddenExamples.length)
|
||||
ListTile(
|
||||
dense: true,
|
||||
enabled: false,
|
||||
title: Text('and ${folderScan.hiddenCount - folderScan.hiddenExamples.length} more'),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
if (lib.videos == 0)
|
||||
SliverToBoxAdapter(
|
||||
child: place == libraryPlace || _folders.folderExists(place)
|
||||
? const FilesEmpty(
|
||||
Icons.search_off,
|
||||
'No videos here',
|
||||
@@ -601,46 +749,25 @@ class _FilesPageState extends State<FilesPage> {
|
||||
'It may have been moved, renamed or be on a drive that is not connected.',
|
||||
),
|
||||
),
|
||||
// Series: files whose names carry an episode number; a header
|
||||
// click opens or closes its episodes.
|
||||
SliverList.builder(
|
||||
itemCount: rows.length,
|
||||
itemBuilder: (context, i) {
|
||||
final (group, v) = rows[i];
|
||||
if (v == null) {
|
||||
final id = seriesId(group);
|
||||
return SeriesHeader(
|
||||
key: ValueKey('series:${group.key}'),
|
||||
group: group,
|
||||
thumbs: _thumbs,
|
||||
expanded: _expanded.contains(id),
|
||||
onTap: () => setState(() => _expanded.contains(id) ? _expanded.remove(id) : _expanded.add(id)),
|
||||
);
|
||||
}
|
||||
return _menuable(
|
||||
v.path,
|
||||
FileRow(
|
||||
key: ValueKey(v.path),
|
||||
path: v.path,
|
||||
title: v.parsed.label.isEmpty ? v.parsed.series : v.parsed.label,
|
||||
subtitle: v.name,
|
||||
size: formatSize(v.size),
|
||||
state: _stateOf(v.path),
|
||||
thumbs: _thumbs,
|
||||
onTap: _tapOf(v.path),
|
||||
));
|
||||
},
|
||||
),
|
||||
// Everything else (movies, clips) as a thumbnail grid.
|
||||
if (loose.isNotEmpty) ...[
|
||||
if (l.shows.isNotEmpty) ...[
|
||||
SliverToBoxAdapter(child: _section(context, 'Shows', count: '${l.shows.length}')),
|
||||
SliverGrid.builder(
|
||||
gridDelegate: posterGrid(),
|
||||
itemCount: l.shows.length,
|
||||
itemBuilder: (context, i) => _showCard(place, lib, l.shows[i]),
|
||||
),
|
||||
],
|
||||
if (movies.isNotEmpty) ...[
|
||||
SliverToBoxAdapter(child: _section(context, 'Movies', count: '${movies.length}')),
|
||||
SliverGrid.builder(
|
||||
gridDelegate: posterGrid(),
|
||||
itemCount: movies.length,
|
||||
itemBuilder: (context, i) => _movieCard(movies[i]),
|
||||
),
|
||||
],
|
||||
if (other.isNotEmpty) ...[
|
||||
SliverToBoxAdapter(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.only(top: 22, bottom: 10),
|
||||
child: Text(
|
||||
series == 0 ? 'Videos' : 'Other videos',
|
||||
style: Theme.of(context).textTheme.titleMedium,
|
||||
),
|
||||
),
|
||||
child: _section(context, l.shows.isEmpty && movies.isEmpty ? 'Videos' : 'Other videos'),
|
||||
),
|
||||
SliverLayoutBuilder(builder: (context, c) {
|
||||
const gap = 12.0;
|
||||
@@ -654,9 +781,9 @@ class _FilesPageState extends State<FilesPage> {
|
||||
crossAxisSpacing: gap,
|
||||
mainAxisExtent: cardW * 9 / 16 + videoCardTextHeight,
|
||||
),
|
||||
itemCount: loose.length,
|
||||
itemCount: other.length,
|
||||
itemBuilder: (context, i) {
|
||||
final v = loose[i];
|
||||
final v = other[i];
|
||||
return _menuable(
|
||||
v.path,
|
||||
VideoCard(
|
||||
@@ -672,12 +799,60 @@ class _FilesPageState extends State<FilesPage> {
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
Widget _showCard(String place, _Library lib, LocalShow show) {
|
||||
final m = _matcher.showMatch(show);
|
||||
final last = _lastPlayed(lib, show);
|
||||
final String subtitle;
|
||||
if (last == null) {
|
||||
final n = show.episodeCount;
|
||||
subtitle = '$n ${n == 1 ? 'episode' : 'episodes'}';
|
||||
} else {
|
||||
final next = show.nextAfter(last);
|
||||
subtitle = next == null ? 'Watched to the end' : 'Next: ${episodeNumber(next.episode)}';
|
||||
}
|
||||
final playing = widget.nowPlayingPath != null && show.files.any((e) => e.path == widget.nowPlayingPath);
|
||||
return PosterCard(
|
||||
key: ValueKey('showcard:${show.key}'),
|
||||
title: m?.entry.title ?? show.title,
|
||||
subtitle: subtitle,
|
||||
coverUrl: m?.entry.cover,
|
||||
framePath: show.episodes.firstOrNull?.path,
|
||||
thumbs: _thumbs,
|
||||
playing: playing,
|
||||
onShown: () => _matcher.want(show),
|
||||
onTap: () => setState(() => _openShows[place] = show.key),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _movieCard(LocalEpisode movie) {
|
||||
final m = _matcher.seasonMatch(movie);
|
||||
return _menuable(
|
||||
movie.path,
|
||||
PosterCard(
|
||||
key: ValueKey('moviecard:${movie.path}'),
|
||||
title: m?.entry.title ?? (movie.parsed.year == null ? movie.title : '${movie.title} (${movie.parsed.year})'),
|
||||
subtitle: [
|
||||
if (movie.movieNumber != null) movie.label,
|
||||
if (m?.entry.year != null) '${m!.entry.year}' else if (movie.parsed.year != null) '${movie.parsed.year}',
|
||||
formatSize(movie.file.size),
|
||||
].join(' · '),
|
||||
coverUrl: m?.entry.cover,
|
||||
framePath: movie.path,
|
||||
thumbs: _thumbs,
|
||||
playing: _stateOf(movie.path) == PlayState.playing,
|
||||
onShown: () => _matcher.wantMovie(movie),
|
||||
onTap: _tapOf(movie.path),
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
/// A Recent entry: `Series · S01E03` over the file name, or the cleaned
|
||||
/// title over its folder; greyed when the file is gone.
|
||||
/// A Recent entry: `Show · E02` over the file name (the library's names
|
||||
/// when it knows the show), or the cleaned title over its folder; greyed
|
||||
/// when the file is gone.
|
||||
class _RecentRow extends StatefulWidget {
|
||||
final String path;
|
||||
final ShowMatcher matcher;
|
||||
final PlayState state;
|
||||
final ThumbCache thumbs;
|
||||
final VoidCallback? onTap;
|
||||
@@ -685,6 +860,7 @@ class _RecentRow extends StatefulWidget {
|
||||
const _RecentRow({
|
||||
super.key,
|
||||
required this.path,
|
||||
required this.matcher,
|
||||
required this.state,
|
||||
required this.thumbs,
|
||||
required this.onTap,
|
||||
@@ -711,12 +887,14 @@ class _RecentRowState extends State<_RecentRow> {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final path = widget.path;
|
||||
final p = parseReleaseName(baseName(path));
|
||||
final e = widget.matcher.episodeAt(path) ??
|
||||
readLocalFile(LocalFile(path, 0, DateTime.fromMillisecondsSinceEpoch(0)));
|
||||
final missing = _stat?.type == FileSystemEntityType.notFound;
|
||||
final episode = p.episode != null || p.extra != null;
|
||||
final episode = e.kind == LocalKind.episode || e.kind == LocalKind.extra;
|
||||
final name = widget.matcher.seasonMatch(e)?.entry.title ?? e.title;
|
||||
return FileRow(
|
||||
path: path,
|
||||
title: episode ? '${p.series} · ${p.label}' : cleanTitle(p),
|
||||
title: episode ? '$name · ${e.label}' : (e.kind == LocalKind.other ? cleanTitle(e.parsed) : name),
|
||||
subtitle: missing ? 'File not found' : (episode ? baseName(path) : parentName(path)),
|
||||
size: _stat == null || missing ? null : formatSize(_stat!.size),
|
||||
state: widget.state,
|
||||
|
||||
@@ -0,0 +1,712 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/rendering.dart';
|
||||
import 'package:signals/signals_flutter.dart';
|
||||
import 'package:sync_relay_core/core.dart';
|
||||
|
||||
import '../../library/show_matcher.dart';
|
||||
import '../../schedule/anilist.dart' show AniListEntry;
|
||||
import '../../shell/avatar.dart' show Pill;
|
||||
import 'file_tiles.dart';
|
||||
import 'thumb.dart';
|
||||
|
||||
// The Files library: shows and movies as poster cards (like the Jellyfin
|
||||
// browser's), a show's own page (banner, the next episode with what is
|
||||
// missing on the way, seasons, extras, movies) and the corrections
|
||||
// ("Wrong show?", "Episode numbers…"). Names and art come from the
|
||||
// library's AniList match when there is one, else from the files.
|
||||
|
||||
/// What the page hands its views: playing, the right-click menu, the
|
||||
/// thumbnails, what plays now.
|
||||
class FilesActions {
|
||||
final void Function(String path) play;
|
||||
final PlayState Function(String path) stateOf;
|
||||
final Widget Function(String path, Widget child) menuable;
|
||||
final ThumbCache thumbs;
|
||||
const FilesActions({required this.play, required this.stateOf, required this.menuable, required this.thumbs});
|
||||
}
|
||||
|
||||
/// "66–107 aren't in your folders", "66 isn't in your folders".
|
||||
String missingText(EpisodeGap gap) =>
|
||||
gap.first == gap.last ? '${gap.label} isn\'t in your folders' : '${gap.label} aren\'t in your folders';
|
||||
|
||||
/// The next-episode prompt: "66–107 aren't in your folders — skip to
|
||||
/// 108?", or at the start "1–55 … — start at 56?"; null when nothing is
|
||||
/// skipped.
|
||||
String? skipPrompt(LocalNext next, {required bool started}) {
|
||||
final gap = next.skipped;
|
||||
if (gap == null) return null;
|
||||
final n = next.episode.episode;
|
||||
return started ? '${missingText(gap)} — skip to $n?' : '${missingText(gap)} — start at $n?';
|
||||
}
|
||||
|
||||
/// A show's label for an episode: `56`, `S3 E1`.
|
||||
String episodeNumber(LocalEpisode e) =>
|
||||
e.season == null ? '${e.episode}' : 'S${e.season} E${e.episode}';
|
||||
|
||||
/// The 2:3 picture of a show or movie: its AniList cover, else a frame of
|
||||
/// [framePath].
|
||||
class PosterImage extends StatelessWidget {
|
||||
final String? url;
|
||||
final String? framePath;
|
||||
final ThumbCache thumbs;
|
||||
const PosterImage({super.key, this.url, this.framePath, required this.thumbs});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final frame = framePath == null
|
||||
? const ThumbImage(null)
|
||||
: ThumbBuilder(cache: thumbs, path: framePath!, builder: (context, t, _) => ThumbImage(t));
|
||||
final u = url;
|
||||
if (u == null) return frame;
|
||||
return Image.network(
|
||||
u,
|
||||
fit: BoxFit.cover,
|
||||
gaplessPlayback: true,
|
||||
errorBuilder: (_, _, _) => frame,
|
||||
loadingBuilder: (context, child, progress) => progress == null ? child : frame,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// A show (or movie) as a poster card: picture, title, one line under it.
|
||||
class PosterCard extends StatefulWidget {
|
||||
final String title;
|
||||
final String subtitle;
|
||||
final String? coverUrl;
|
||||
final String? framePath;
|
||||
final ThumbCache thumbs;
|
||||
final VoidCallback onTap;
|
||||
final bool playing;
|
||||
/// Called once when the card is first built (the library matches what
|
||||
/// is on screen).
|
||||
final VoidCallback? onShown;
|
||||
const PosterCard({
|
||||
super.key,
|
||||
required this.title,
|
||||
required this.subtitle,
|
||||
required this.thumbs,
|
||||
required this.onTap,
|
||||
this.coverUrl,
|
||||
this.framePath,
|
||||
this.playing = false,
|
||||
this.onShown,
|
||||
});
|
||||
|
||||
@override
|
||||
State<PosterCard> createState() => _PosterCardState();
|
||||
}
|
||||
|
||||
class _PosterCardState extends State<PosterCard> {
|
||||
bool _hover = false;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
widget.onShown?.call();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final scheme = Theme.of(context).colorScheme;
|
||||
final t = Theme.of(context).textTheme;
|
||||
return InkWell(
|
||||
onTap: widget.onTap,
|
||||
onHover: (h) => setState(() => _hover = h),
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Expanded(
|
||||
child: AnimatedContainer(
|
||||
duration: const Duration(milliseconds: 150),
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
border: Border.all(
|
||||
color: widget.playing ? scheme.primary : (_hover ? scheme.onSurfaceVariant : Colors.transparent),
|
||||
width: 2,
|
||||
),
|
||||
),
|
||||
child: ClipRRect(
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
child: PosterImage(url: widget.coverUrl, framePath: widget.framePath, thumbs: widget.thumbs),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 6),
|
||||
Text(widget.title,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: t.titleSmall?.copyWith(color: widget.playing ? scheme.primary : null)),
|
||||
Text(widget.subtitle,
|
||||
maxLines: 1, overflow: TextOverflow.ellipsis, style: t.bodySmall?.copyWith(color: Colors.grey)),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Title and meta lines under a [PosterCard]'s picture.
|
||||
const posterTextHeight = 46.0;
|
||||
|
||||
/// Poster cards in a grid: about 170 px wide each.
|
||||
SliverGridDelegate posterGrid() => const _PosterGrid();
|
||||
|
||||
class _PosterGrid extends SliverGridDelegate {
|
||||
const _PosterGrid();
|
||||
|
||||
@override
|
||||
SliverGridLayout getLayout(SliverConstraints constraints) {
|
||||
const gap = 12.0;
|
||||
final w = constraints.crossAxisExtent;
|
||||
final cols = (w / 170).floor().clamp(2, 12);
|
||||
final cardW = (w - gap * (cols - 1)) / cols;
|
||||
return SliverGridRegularTileLayout(
|
||||
crossAxisCount: cols,
|
||||
mainAxisStride: cardW * 1.5 + posterTextHeight + gap,
|
||||
crossAxisStride: cardW + gap,
|
||||
childMainAxisExtent: cardW * 1.5 + posterTextHeight,
|
||||
childCrossAxisExtent: cardW,
|
||||
reverseCrossAxis: false,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
bool shouldRelayout(covariant SliverGridDelegate oldDelegate) => false;
|
||||
}
|
||||
|
||||
/// One show's page: header (banner, proper name, what the files are,
|
||||
/// the match), the next episode with what is missing on the way, the
|
||||
/// season's episodes with their holes, extras and the show's movies.
|
||||
class ShowPage extends StatefulWidget {
|
||||
final LocalShow show;
|
||||
final ShowMatcher matcher;
|
||||
final FilesActions actions;
|
||||
/// The last episode of this show played here ([LocalEpisode.key]).
|
||||
final String? lastPlayed;
|
||||
final String backLabel;
|
||||
final VoidCallback onBack;
|
||||
/// Notices above the page (the relay notice, errors).
|
||||
final List<Widget> top;
|
||||
const ShowPage({
|
||||
super.key,
|
||||
required this.show,
|
||||
required this.matcher,
|
||||
required this.actions,
|
||||
required this.lastPlayed,
|
||||
required this.backLabel,
|
||||
required this.onBack,
|
||||
this.top = const [],
|
||||
});
|
||||
|
||||
@override
|
||||
State<ShowPage> createState() => _ShowPageState();
|
||||
}
|
||||
|
||||
class _ShowPageState extends State<ShowPage> {
|
||||
int? _season;
|
||||
bool _seasonPicked = false;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
widget.matcher.want(widget.show);
|
||||
}
|
||||
|
||||
LocalShow get show => widget.show;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) => SignalBuilder(builder: (context) {
|
||||
widget.matcher.revision.value;
|
||||
final busy = widget.matcher.busy.value.contains(show.key);
|
||||
final m = widget.matcher.showMatch(show);
|
||||
final next = show.nextAfter(widget.lastPlayed);
|
||||
final seasons = show.seasons;
|
||||
if (!_seasonPicked) _season = next?.episode.season ?? seasons.firstOrNull?.number;
|
||||
final season = show.season(_season) ?? seasons.firstOrNull;
|
||||
return CustomScrollView(
|
||||
key: PageStorageKey('show:${show.key}'),
|
||||
slivers: [
|
||||
SliverPadding(
|
||||
padding: const EdgeInsets.fromLTRB(12, 12, 20, 24),
|
||||
sliver: SliverMainAxisGroup(slivers: [
|
||||
SliverToBoxAdapter(
|
||||
child: Align(
|
||||
alignment: Alignment.centerLeft,
|
||||
child: TextButton.icon(
|
||||
onPressed: widget.onBack,
|
||||
icon: const Icon(Icons.arrow_back, size: 18),
|
||||
label: Text(widget.backLabel),
|
||||
),
|
||||
),
|
||||
),
|
||||
SliverList.list(children: widget.top),
|
||||
SliverToBoxAdapter(child: _header(context, m, next, busy)),
|
||||
if (seasons.length > 1)
|
||||
SliverToBoxAdapter(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.only(top: 14),
|
||||
child: Wrap(spacing: 8, runSpacing: 8, children: [
|
||||
for (final s in seasons)
|
||||
ChoiceChip(
|
||||
key: ValueKey('season:${s.number}'),
|
||||
label: Text(s.title),
|
||||
selected: identical(s, season),
|
||||
onSelected: (_) => setState(() {
|
||||
_season = s.number;
|
||||
_seasonPicked = true;
|
||||
}),
|
||||
),
|
||||
]),
|
||||
),
|
||||
),
|
||||
if (season != null) ..._episodes(context, season),
|
||||
if (show.extras.isNotEmpty) ...[
|
||||
_section(context, 'Extras', show.extras.length),
|
||||
SliverList.list(children: [for (final e in show.extras) _row(e, e.label, e.name)]),
|
||||
],
|
||||
if (show.movies.isNotEmpty) ...[
|
||||
_section(context, show.movies.length == 1 ? 'Movie' : 'Movies', show.movies.length),
|
||||
SliverList.list(children: [for (final e in show.movies) _row(e, e.label, e.name)]),
|
||||
],
|
||||
]),
|
||||
),
|
||||
],
|
||||
);
|
||||
});
|
||||
|
||||
Widget _section(BuildContext context, String title, int n) => SliverToBoxAdapter(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.only(top: 22, bottom: 6),
|
||||
child: Row(children: [
|
||||
Text(title, style: Theme.of(context).textTheme.titleMedium),
|
||||
const SizedBox(width: 8),
|
||||
Text('$n', style: Theme.of(context).textTheme.bodySmall?.copyWith(color: Colors.grey)),
|
||||
]),
|
||||
),
|
||||
);
|
||||
|
||||
Widget _row(LocalEpisode e, String title, String subtitle) => widget.actions.menuable(
|
||||
e.path,
|
||||
FileRow(
|
||||
key: ValueKey(e.path),
|
||||
path: e.path,
|
||||
title: title,
|
||||
subtitle: subtitle,
|
||||
size: formatSize(e.file.size),
|
||||
state: widget.actions.stateOf(e.path),
|
||||
thumbs: widget.actions.thumbs,
|
||||
onTap: () => widget.actions.play(e.path),
|
||||
));
|
||||
|
||||
List<Widget> _episodes(BuildContext context, LocalSeason season) {
|
||||
final rows = <Widget>[];
|
||||
var expect = 1;
|
||||
for (final e in season.episodes) {
|
||||
final n = e.episode!;
|
||||
final gap = season.gaps.where((g) => g.first >= expect && g.last < n).firstOrNull;
|
||||
if (gap != null) rows.add(_GapRow(missingText(gap)));
|
||||
if (n >= expect) expect = n + 1;
|
||||
final offset = widget.matcher.offset(show.key, season.number);
|
||||
final shown = n + offset;
|
||||
final name = widget.matcher.episodeTitle(e);
|
||||
// Like the Jellyfin browser's rows: "12. Name", else "Episode 12".
|
||||
final title = name == null ? 'Episode $shown' : '$shown. $name';
|
||||
rows.add(_row(e, title, e.name));
|
||||
}
|
||||
final tail = season.gaps.where((g) => g.first >= expect).firstOrNull;
|
||||
if (tail != null) rows.add(_GapRow(missingText(tail)));
|
||||
return [
|
||||
_section(context, season.title, season.distinct.length),
|
||||
SliverList.list(children: rows),
|
||||
];
|
||||
}
|
||||
|
||||
Widget _header(BuildContext context, SeasonMatch? m, LocalNext? next, bool busy) {
|
||||
final theme = Theme.of(context);
|
||||
final t = theme.textTheme;
|
||||
final scheme = theme.colorScheme;
|
||||
final entry = m?.entry;
|
||||
final title = entry?.title ?? show.title;
|
||||
final count = show.episodeCount;
|
||||
final meta = [
|
||||
if (show.seasons.length > 1) '${show.seasons.length} seasons',
|
||||
'$count ${count == 1 ? 'episode' : 'episodes'}',
|
||||
if (show.extras.isNotEmpty) '${show.extras.length} ${show.extras.length == 1 ? 'extra' : 'extras'}',
|
||||
if (show.movies.isNotEmpty) '${show.movies.length} ${show.movies.length == 1 ? 'movie' : 'movies'}',
|
||||
?show.group,
|
||||
if (show.folders.length > 1) 'in ${show.folders.length} folders',
|
||||
].join(' · ');
|
||||
final String matchLine;
|
||||
if (entry != null) {
|
||||
final what = [
|
||||
if (entry.format.isNotEmpty) entry.format.replaceAll('_', ' '),
|
||||
if (entry.year != null) '${entry.year}',
|
||||
if (entry.episodes != null) '${entry.episodes} eps',
|
||||
].join(', ');
|
||||
matchLine = [
|
||||
'AniList: ${entry.romaji.isNotEmpty && entry.romaji != title ? entry.romaji : entry.title}'
|
||||
'${what.isEmpty ? '' : ' ($what)'}',
|
||||
if (title != show.title) 'files say "${show.title}"',
|
||||
].join(' · ');
|
||||
} else {
|
||||
matchLine = busy ? 'Looking it up on AniList…' : 'Not matched on AniList';
|
||||
}
|
||||
final started = widget.lastPlayed != null;
|
||||
final prompt = next == null ? null : skipPrompt(next, started: started);
|
||||
final nextLabel = next == null
|
||||
? 'Watched to the end'
|
||||
: next.skipped != null
|
||||
? (started ? 'Skip to ${next.episode.episode}' : 'Start at ${next.episode.episode}')
|
||||
: '${started ? 'Next' : 'Play'}: ${episodeNumber(next.episode)}';
|
||||
return LayoutBuilder(builder: (context, box) {
|
||||
final h = (box.maxWidth * 0.28).clamp(220.0, 340.0);
|
||||
final cover = entry?.cover;
|
||||
return ClipRRect(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
child: SizedBox(
|
||||
height: h,
|
||||
child: Stack(fit: StackFit.expand, children: [
|
||||
PosterImage(
|
||||
url: entry?.banner ?? entry?.cover,
|
||||
framePath: show.episodes.firstOrNull?.path,
|
||||
thumbs: widget.actions.thumbs),
|
||||
DecoratedBox(
|
||||
decoration: BoxDecoration(
|
||||
gradient: LinearGradient(
|
||||
begin: Alignment.centerLeft,
|
||||
end: Alignment.centerRight,
|
||||
colors: [
|
||||
scheme.surface.withValues(alpha: 0.96),
|
||||
scheme.surface.withValues(alpha: 0.8),
|
||||
scheme.surface.withValues(alpha: 0.15),
|
||||
],
|
||||
stops: const [0, 0.5, 1],
|
||||
),
|
||||
),
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.all(20),
|
||||
child: Row(crossAxisAlignment: CrossAxisAlignment.start, children: [
|
||||
if (cover != null && box.maxWidth > 560) ...[
|
||||
ClipRRect(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
child: SizedBox(
|
||||
width: (h - 40) / 1.5,
|
||||
child: PosterImage(url: cover, thumbs: widget.actions.thumbs),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 18),
|
||||
],
|
||||
Expanded(
|
||||
child: SingleChildScrollView(
|
||||
child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
|
||||
Row(children: [
|
||||
Flexible(
|
||||
child: Text(title,
|
||||
maxLines: 2, overflow: TextOverflow.ellipsis, style: t.headlineMedium),
|
||||
),
|
||||
if (m?.pinned != null) ...[
|
||||
const SizedBox(width: 8),
|
||||
Pill(m!.pinned == PinScope.folder ? 'pinned for this folder' : 'pinned', scheme.primary),
|
||||
],
|
||||
]),
|
||||
const SizedBox(height: 6),
|
||||
Text(meta, style: t.bodyMedium?.copyWith(color: Colors.grey)),
|
||||
const SizedBox(height: 2),
|
||||
Text(matchLine,
|
||||
maxLines: 2,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: t.bodySmall?.copyWith(color: scheme.onSurfaceVariant)),
|
||||
if (prompt != null) ...[
|
||||
const SizedBox(height: 10),
|
||||
Row(children: [
|
||||
Icon(Icons.info_outline, size: 16, color: scheme.tertiary),
|
||||
const SizedBox(width: 6),
|
||||
Flexible(child: Text(prompt, style: t.bodyMedium?.copyWith(color: scheme.tertiary))),
|
||||
]),
|
||||
],
|
||||
const SizedBox(height: 12),
|
||||
Wrap(spacing: 8, runSpacing: 8, children: [
|
||||
FilledButton.icon(
|
||||
key: const ValueKey('show-next'),
|
||||
onPressed: next == null ? null : () => widget.actions.play(next.episode.path),
|
||||
icon: const Icon(Icons.play_arrow),
|
||||
label: Text(nextLabel),
|
||||
),
|
||||
OutlinedButton.icon(
|
||||
onPressed: () => showWrongShowDialog(context, show, widget.matcher),
|
||||
icon: const Icon(Icons.manage_search, size: 18),
|
||||
label: const Text('Wrong show?'),
|
||||
),
|
||||
TextButton(
|
||||
onPressed: () => showEpisodeNumbersDialog(context, show, widget.matcher),
|
||||
child: const Text('Episode numbers…'),
|
||||
),
|
||||
]),
|
||||
]),
|
||||
),
|
||||
),
|
||||
]),
|
||||
),
|
||||
]),
|
||||
),
|
||||
);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
class _GapRow extends StatelessWidget {
|
||||
final String text;
|
||||
const _GapRow(this.text);
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final scheme = Theme.of(context).colorScheme;
|
||||
return Padding(
|
||||
padding: const EdgeInsets.fromLTRB(20, 6, 8, 6),
|
||||
child: Row(children: [
|
||||
Icon(Icons.more_vert, size: 18, color: scheme.onSurfaceVariant),
|
||||
const SizedBox(width: 10),
|
||||
Expanded(
|
||||
child: Text(text,
|
||||
maxLines: 2,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: TextStyle(color: scheme.onSurfaceVariant, fontStyle: FontStyle.italic, fontSize: 13)),
|
||||
),
|
||||
]),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// "Wrong show?": search AniList, pick the right entry, pin it for the
|
||||
/// show everywhere or for one of its folders.
|
||||
Future<void> showWrongShowDialog(BuildContext context, LocalShow show, ShowMatcher matcher) =>
|
||||
showDialog<void>(context: context, builder: (context) => WrongShowDialog(show: show, matcher: matcher));
|
||||
|
||||
class WrongShowDialog extends StatefulWidget {
|
||||
final LocalShow show;
|
||||
final ShowMatcher matcher;
|
||||
const WrongShowDialog({super.key, required this.show, required this.matcher});
|
||||
|
||||
@override
|
||||
State<WrongShowDialog> createState() => _WrongShowDialogState();
|
||||
}
|
||||
|
||||
class _WrongShowDialogState extends State<WrongShowDialog> {
|
||||
late final _query = TextEditingController(text: widget.show.title.replaceAll(RegExp(r'[._]+'), ' '));
|
||||
List<AniListEntry>? _hits;
|
||||
String? _error;
|
||||
bool _searching = false;
|
||||
/// Null: the whole show; else one of its folders.
|
||||
String? _folder;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_search();
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_query.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
Future<void> _search() async {
|
||||
setState(() {
|
||||
_searching = true;
|
||||
_error = null;
|
||||
});
|
||||
try {
|
||||
final hits = await widget.matcher.search(_query.text);
|
||||
if (mounted) setState(() => _hits = hits);
|
||||
} catch (e) {
|
||||
if (mounted) setState(() => _error = 'AniList did not answer ($e)');
|
||||
} finally {
|
||||
if (mounted) setState(() => _searching = false);
|
||||
}
|
||||
}
|
||||
|
||||
void _pin(AniListEntry e) {
|
||||
final folder = _folder;
|
||||
folder == null ? widget.matcher.pinShow(widget.show.key, e) : widget.matcher.pinFolder(folder, e);
|
||||
Navigator.of(context).pop();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final show = widget.show;
|
||||
final m = widget.matcher;
|
||||
final showPin = m.showPin(show.key);
|
||||
final folderPins = [for (final f in show.folders) if (m.folderPin(f)?.folder == f) f];
|
||||
final scheme = Theme.of(context).colorScheme;
|
||||
return AlertDialog(
|
||||
title: const Text('Wrong show?'),
|
||||
content: SizedBox(
|
||||
width: 520,
|
||||
child: Column(mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [
|
||||
Text('Search AniList and pick what "${show.title}" really is. '
|
||||
'A pin wins over the automatic match and survives rescans.',
|
||||
style: TextStyle(color: scheme.onSurfaceVariant)),
|
||||
const SizedBox(height: 12),
|
||||
TextField(
|
||||
controller: _query,
|
||||
autofocus: true,
|
||||
decoration: InputDecoration(
|
||||
isDense: true,
|
||||
border: const OutlineInputBorder(),
|
||||
hintText: 'Title on AniList',
|
||||
suffixIcon: IconButton(tooltip: 'Search', icon: const Icon(Icons.search), onPressed: _search),
|
||||
),
|
||||
onSubmitted: (_) => _search(),
|
||||
),
|
||||
if (show.folders.length > 1 || folderPins.isNotEmpty) ...[
|
||||
const SizedBox(height: 10),
|
||||
DropdownButton<String?>(
|
||||
value: _folder,
|
||||
isExpanded: true,
|
||||
items: [
|
||||
const DropdownMenuItem(value: null, child: Text('For this show everywhere')),
|
||||
for (final f in show.folders)
|
||||
DropdownMenuItem(value: f, child: Text('Only the folder "${baseName(f)}"', overflow: TextOverflow.ellipsis)),
|
||||
],
|
||||
onChanged: (v) => setState(() => _folder = v),
|
||||
),
|
||||
],
|
||||
const SizedBox(height: 8),
|
||||
ConstrainedBox(
|
||||
constraints: const BoxConstraints(maxHeight: 340),
|
||||
child: _searching && _hits == null
|
||||
? const Padding(padding: EdgeInsets.all(24), child: Center(child: CircularProgressIndicator()))
|
||||
: _error != null
|
||||
? Text(_error!, style: TextStyle(color: scheme.error))
|
||||
: (_hits ?? const []).isEmpty
|
||||
? const Padding(padding: EdgeInsets.all(12), child: Text('Nothing found'))
|
||||
: ListView(shrinkWrap: true, children: [
|
||||
for (final e in _hits!)
|
||||
ListTile(
|
||||
key: ValueKey('hit:${e.id}'),
|
||||
dense: true,
|
||||
leading: SizedBox(
|
||||
width: 32,
|
||||
height: 48,
|
||||
child: ClipRRect(
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
child: e.coverSmall == null
|
||||
? const ColoredBox(color: Colors.black26)
|
||||
: Image.network(e.coverSmall!,
|
||||
fit: BoxFit.cover,
|
||||
errorBuilder: (_, _, _) => const ColoredBox(color: Colors.black26)),
|
||||
),
|
||||
),
|
||||
title: Text(e.title, maxLines: 1, overflow: TextOverflow.ellipsis),
|
||||
subtitle: Text([
|
||||
if (e.romaji.isNotEmpty && e.romaji != e.title) e.romaji,
|
||||
if (e.format.isNotEmpty) e.format.replaceAll('_', ' '),
|
||||
if (e.year != null) '${e.year}',
|
||||
if (e.episodes != null) '${e.episodes} eps',
|
||||
].join(' · '), maxLines: 1, overflow: TextOverflow.ellipsis),
|
||||
onTap: () => _pin(e),
|
||||
),
|
||||
]),
|
||||
),
|
||||
]),
|
||||
),
|
||||
actions: [
|
||||
if (showPin != null)
|
||||
TextButton(
|
||||
onPressed: () {
|
||||
m.unpinShow(show.key);
|
||||
Navigator.of(context).pop();
|
||||
},
|
||||
child: const Text('Remove the pin'),
|
||||
),
|
||||
for (final f in folderPins)
|
||||
TextButton(
|
||||
onPressed: () {
|
||||
m.unpinFolder(f);
|
||||
Navigator.of(context).pop();
|
||||
},
|
||||
child: Text('Unpin "${baseName(f)}"'),
|
||||
),
|
||||
TextButton(onPressed: () => Navigator.of(context).pop(), child: const Text('Cancel')),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// "Episode numbers…": which episode the first file of each season is
|
||||
/// ("these files are episodes N..M").
|
||||
Future<void> showEpisodeNumbersDialog(BuildContext context, LocalShow show, ShowMatcher matcher) =>
|
||||
showDialog<void>(context: context, builder: (context) => _EpisodeNumbersDialog(show: show, matcher: matcher));
|
||||
|
||||
class _EpisodeNumbersDialog extends StatefulWidget {
|
||||
final LocalShow show;
|
||||
final ShowMatcher matcher;
|
||||
const _EpisodeNumbersDialog({required this.show, required this.matcher});
|
||||
|
||||
@override
|
||||
State<_EpisodeNumbersDialog> createState() => _EpisodeNumbersDialogState();
|
||||
}
|
||||
|
||||
class _EpisodeNumbersDialogState extends State<_EpisodeNumbersDialog> {
|
||||
late final _fields = {
|
||||
for (final s in widget.show.seasons)
|
||||
s.number: TextEditingController(
|
||||
text: '${s.distinct.first.episode! + widget.matcher.offset(widget.show.key, s.number)}'),
|
||||
};
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
for (final c in _fields.values) {
|
||||
c.dispose();
|
||||
}
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _save() {
|
||||
for (final s in widget.show.seasons) {
|
||||
final first = s.distinct.first.episode!;
|
||||
final want = int.tryParse(_fields[s.number]!.text.trim());
|
||||
if (want != null && want > 0) widget.matcher.setOffset(widget.show.key, s.number, want - first);
|
||||
}
|
||||
Navigator.of(context).pop();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) => AlertDialog(
|
||||
title: const Text('Episode numbers'),
|
||||
content: SizedBox(
|
||||
width: 420,
|
||||
child: Column(mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [
|
||||
const Text('When the files count differently from AniList, say which episode the first file is; '
|
||||
'the others follow.'),
|
||||
const SizedBox(height: 12),
|
||||
for (final s in widget.show.seasons)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(bottom: 8),
|
||||
child: Row(children: [
|
||||
Expanded(
|
||||
child: Text('${s.title}: the file numbered ${s.distinct.first.episode} is episode',
|
||||
maxLines: 2),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
SizedBox(
|
||||
width: 72,
|
||||
child: TextField(
|
||||
key: ValueKey('offset:${s.number}'),
|
||||
controller: _fields[s.number],
|
||||
keyboardType: TextInputType.number,
|
||||
decoration: const InputDecoration(isDense: true, border: OutlineInputBorder()),
|
||||
),
|
||||
),
|
||||
]),
|
||||
),
|
||||
]),
|
||||
),
|
||||
actions: [
|
||||
TextButton(onPressed: () => Navigator.of(context).pop(), child: const Text('Cancel')),
|
||||
FilledButton(onPressed: _save, child: const Text('Save')),
|
||||
],
|
||||
);
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import 'package:sync_relay_core/core.dart';
|
||||
|
||||
import '../library/media_refs.dart';
|
||||
import '../screens/home/picked_media.dart';
|
||||
import 'accounts.dart';
|
||||
|
||||
@@ -89,5 +90,6 @@ JellyfinMedia pickFor(JellyfinAccount account, JellyfinItem item) {
|
||||
title: item.name,
|
||||
subtitle: subtitle,
|
||||
runtime: item.runTime,
|
||||
ref: jellyfinMediaRef(item),
|
||||
);
|
||||
}
|
||||
@@ -7,6 +7,7 @@ import '../screens/home/picked_media.dart';
|
||||
import '../session/party.dart' show isPresent;
|
||||
import 'accounts.dart';
|
||||
import 'format.dart';
|
||||
import '../library/media_refs.dart';
|
||||
|
||||
// Jellyfin media in a session: turning picks and pasted links into refs
|
||||
// (the wire carries only `{serverId, itemId, ...}`), the hosted server's
|
||||
@@ -28,7 +29,8 @@ HelloMedia mediaForPick(JellyfinMedia m) => HelloMedia(
|
||||
name: m.title.isEmpty ? 'Jellyfin item' : m.title,
|
||||
size: 0,
|
||||
duration: 0,
|
||||
jellyfin: refForPick(m));
|
||||
jellyfin: refForPick(m),
|
||||
ref: m.ref ?? jellyfinPickRef(m.title, m.subtitle));
|
||||
|
||||
/// The pick a ref stands for on a linked server (for highlighting it in
|
||||
/// the browser); null when this install has no account there.
|
||||
@@ -78,7 +80,8 @@ final _sourceId = RegExp(r'^[A-Za-z0-9-]{1,64}$');
|
||||
/// is one.
|
||||
Future<HelloMedia> mediaForLink(JellyfinAccounts accounts, String url,
|
||||
{Duration timeout = const Duration(seconds: 6)}) async {
|
||||
final plain = HelloMedia(name: linkName(url), size: 0, duration: 0, url: url);
|
||||
final name = linkName(url);
|
||||
final plain = HelloMedia(name: name, size: 0, duration: 0, url: url, ref: linkMediaRef(name));
|
||||
final link = parseJellyfinLink(url);
|
||||
if (link == null) return plain;
|
||||
await accounts.ensureLoaded();
|
||||
@@ -103,9 +106,11 @@ Future<HelloMedia> mediaForLink(JellyfinAccounts accounts, String url,
|
||||
);
|
||||
final account = accounts.account(serverId);
|
||||
final client = accounts.clientFor(serverId);
|
||||
MediaRef? what;
|
||||
if (account != null && client != null) {
|
||||
try {
|
||||
final pick = pickFor(account, await client.item(link.itemId).timeout(timeout));
|
||||
what = pick.ref;
|
||||
ref = refForPick(JellyfinMedia(
|
||||
serverId: serverId,
|
||||
baseUrl: account.baseUrl,
|
||||
@@ -120,7 +125,11 @@ Future<HelloMedia> mediaForLink(JellyfinAccounts accounts, String url,
|
||||
}
|
||||
}
|
||||
return HelloMedia(
|
||||
name: ref.title.isEmpty ? 'Jellyfin item' : ref.title, size: 0, duration: 0, jellyfin: ref);
|
||||
name: ref.title.isEmpty ? 'Jellyfin item' : ref.title,
|
||||
size: 0,
|
||||
duration: 0,
|
||||
jellyfin: ref,
|
||||
ref: what ?? jellyfinPickRef(ref.title, ref.subtitle ?? ''));
|
||||
}
|
||||
|
||||
/// Display name for a link: last path segment, decoded, never the query
|
||||
@@ -139,27 +148,28 @@ String linkName(String url) {
|
||||
return uri.host.isNotEmpty ? uri.host : 'link';
|
||||
}
|
||||
|
||||
/// The hosted server's relay login: the account for [serverId] (the
|
||||
/// hosted item's server), else the default account, when its token is not
|
||||
/// known to be expired. The device fields are exactly the ones this app
|
||||
/// sends with that token ([JellyfinAccounts.clientInfo]): Jellyfin rewrites
|
||||
/// a token's device from the request header, so the relay must not differ.
|
||||
JellyfinCredential? relayCredentialFor(JellyfinAccounts accounts, {String? serverId}) {
|
||||
bool usable(JellyfinAccount? a) =>
|
||||
a != null && accounts.statusOf(a.serverId) != JellyfinAccountStatus.expired;
|
||||
var a = serverId == null ? null : accounts.account(serverId);
|
||||
if (!usable(a)) a = accounts.defaultAccount;
|
||||
if (!usable(a)) return null;
|
||||
/// The hosted server's relay logins: one per linked Jellyfin account
|
||||
/// whose token is not known to be expired (the default account first),
|
||||
/// so `/j` relays an item with its own server's login. The device fields
|
||||
/// are exactly the ones this app sends with those tokens
|
||||
/// ([JellyfinAccounts.clientInfo]): Jellyfin rewrites a token's device
|
||||
/// from the request header, so the relay must not differ.
|
||||
List<JellyfinCredential> relayCredentialsFor(JellyfinAccounts accounts) {
|
||||
final info = accounts.clientInfo;
|
||||
return JellyfinCredential(
|
||||
baseUrl: a!.baseUrl,
|
||||
serverId: a.serverId,
|
||||
token: a.token,
|
||||
client: info.client,
|
||||
device: info.device,
|
||||
deviceId: info.deviceId,
|
||||
version: info.version,
|
||||
);
|
||||
final first = accounts.defaultAccount;
|
||||
return [
|
||||
for (final a in [?first, ...accounts.accounts.where((a) => a.serverId != first?.serverId)])
|
||||
if (accounts.statusOf(a.serverId) != JellyfinAccountStatus.expired)
|
||||
JellyfinCredential(
|
||||
baseUrl: a.baseUrl,
|
||||
serverId: a.serverId,
|
||||
token: a.token,
|
||||
client: info.client,
|
||||
device: info.device,
|
||||
deviceId: info.deviceId,
|
||||
version: info.version,
|
||||
),
|
||||
];
|
||||
}
|
||||
|
||||
/// `/j/...` URL of the current Jellyfin media on the host this client
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:hive_ce/hive.dart';
|
||||
|
||||
// Where the library's matches live (device-owned, one Hive box): AniList
|
||||
// entries seen, automatic matches, the user's pins and episode offsets.
|
||||
// Plain JSON-ish values under string keys; reads are synchronous once
|
||||
// loaded, writes go through.
|
||||
|
||||
const libraryBoxName = 'library';
|
||||
|
||||
abstract interface class LibraryStore {
|
||||
/// Loads the stored values (once).
|
||||
Future<void> ensureLoaded();
|
||||
Object? get(String key);
|
||||
void put(String key, Object value);
|
||||
void delete(String key);
|
||||
|
||||
/// Every key starting with [prefix].
|
||||
Iterable<String> keys(String prefix);
|
||||
}
|
||||
|
||||
/// For tests and as the fallback when Hive is not available.
|
||||
class MemoryLibraryStore implements LibraryStore {
|
||||
final Map<String, Object> values;
|
||||
MemoryLibraryStore([Map<String, Object>? values]) : values = values ?? {};
|
||||
|
||||
@override
|
||||
Future<void> ensureLoaded() async {}
|
||||
@override
|
||||
Object? get(String key) => values[key];
|
||||
@override
|
||||
void put(String key, Object value) => values[key] = value;
|
||||
@override
|
||||
void delete(String key) => values.remove(key);
|
||||
@override
|
||||
Iterable<String> keys(String prefix) => values.keys.where((k) => k.startsWith(prefix)).toList();
|
||||
}
|
||||
|
||||
/// The `library` box; until it opens (or when it cannot) it acts as an
|
||||
/// empty memory store, so matching still works for the run.
|
||||
class HiveLibraryStore implements LibraryStore {
|
||||
Box? _box;
|
||||
final _memory = MemoryLibraryStore();
|
||||
Future<void>? _load;
|
||||
|
||||
@override
|
||||
Future<void> ensureLoaded() => _load ??= () async {
|
||||
try {
|
||||
final box = _box = await Hive.openBox(libraryBoxName);
|
||||
// Writes made before the box opened.
|
||||
for (final e in _memory.values.entries) {
|
||||
await box.put(e.key, e.value);
|
||||
}
|
||||
_memory.values.clear();
|
||||
} catch (e, st) {
|
||||
debugPrint('open library store failed: $e\n$st');
|
||||
}
|
||||
}();
|
||||
|
||||
@override
|
||||
Object? get(String key) => _box?.get(key) ?? _memory.get(key);
|
||||
|
||||
@override
|
||||
void put(String key, Object value) {
|
||||
final box = _box;
|
||||
if (box == null) return _memory.put(key, value);
|
||||
box.put(key, value).catchError((Object e) => debugPrint('library store put $key: $e'));
|
||||
}
|
||||
|
||||
@override
|
||||
void delete(String key) {
|
||||
_memory.delete(key);
|
||||
_box?.delete(key).catchError((Object e) => debugPrint('library store delete $key: $e'));
|
||||
}
|
||||
|
||||
@override
|
||||
Iterable<String> keys(String prefix) => [
|
||||
...?_box?.keys.whereType<String>().where((k) => k.startsWith(prefix)),
|
||||
..._memory.keys(prefix),
|
||||
];
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
import 'package:sync_relay_core/core.dart';
|
||||
|
||||
// `media.ref` (PROTOCOL.md "Media identity") for each source: what a media
|
||||
// is, independent of where its bytes come from. Local files get theirs
|
||||
// from the library (`ShowMatcher.refFor`, L1 + L2); these are the other
|
||||
// two: Jellyfin items by their provider ids, links best effort from their
|
||||
// name.
|
||||
|
||||
/// A Jellyfin episode, movie or video: kind, series (or movie) title,
|
||||
/// season and episode as Jellyfin numbers them, and the AniList / TVDB ids
|
||||
/// its metadata plugins set ([series]' when given: an episode's own ids
|
||||
/// are the episode's, not the show's). Null for anything that is not
|
||||
/// played (series, seasons, folders).
|
||||
MediaRef? jellyfinMediaRef(JellyfinItem item, {JellyfinItem? series}) {
|
||||
final kind = switch (item.kind) {
|
||||
JellyfinItemKind.episode => 'episode',
|
||||
JellyfinItemKind.movie || JellyfinItemKind.video => 'movie',
|
||||
_ => null,
|
||||
};
|
||||
if (kind == null) return null;
|
||||
final episode = kind == 'episode';
|
||||
// An episode's Tvdb id names the episode; the show's comes from the
|
||||
// series item. AniList ids are only ever set on shows and movies.
|
||||
final owner = episode ? series : item;
|
||||
final special = episode && item.parentIndexNumber == 0;
|
||||
return MediaRef(
|
||||
kind: special ? 'extra' : kind,
|
||||
title: episode ? (item.seriesName ?? series?.name ?? item.name) : item.name,
|
||||
anilistId: owner?.providerId('AniList'),
|
||||
tvdbId: owner?.providerId('Tvdb'),
|
||||
season: episode && !special ? item.parentIndexNumber : null,
|
||||
ep: episode && !special ? item.indexNumber : null,
|
||||
);
|
||||
}
|
||||
|
||||
final _sxe = RegExp(r'S(\d{1,3})\s?E(\d{1,4})$', caseSensitive: false);
|
||||
|
||||
/// A picked Jellyfin item from its card's lines alone ([title], and
|
||||
/// [subtitle] "Series · S1E6" for episodes, the year for movies): kind,
|
||||
/// title and numbers, no ids.
|
||||
MediaRef jellyfinPickRef(String title, String subtitle) {
|
||||
final sub = subtitle.trim();
|
||||
final cut = sub.lastIndexOf(' · ');
|
||||
final label = cut < 0 ? sub : sub.substring(cut + 3).trim();
|
||||
final m = _sxe.firstMatch(label);
|
||||
if (m == null) return MediaRef(kind: 'movie', title: title);
|
||||
final season = int.parse(m.group(1)!);
|
||||
return MediaRef(
|
||||
kind: season == 0 ? 'extra' : 'episode',
|
||||
title: cut < 0 ? title : sub.substring(0, cut).trim(),
|
||||
season: season == 0 ? null : season,
|
||||
ep: season == 0 ? null : int.parse(m.group(2)!),
|
||||
);
|
||||
}
|
||||
|
||||
/// A link, best effort: its name read like a release name (`Show - 05`
|
||||
/// is an episode, `OP1` an extra); anything else a movie of that title.
|
||||
MediaRef linkMediaRef(String name) {
|
||||
final p = parseReleaseName(name);
|
||||
final title = p.series.trim().isEmpty ? name.trim() : p.series.trim();
|
||||
return switch (p.kind) {
|
||||
ReleaseKind.episode => MediaRef(
|
||||
kind: 'episode',
|
||||
title: title,
|
||||
season: p.season,
|
||||
ep: p.season == null ? null : p.episode,
|
||||
absolute: p.season == null ? p.episode : null),
|
||||
ReleaseKind.extra => MediaRef(kind: 'extra', title: title, season: p.season),
|
||||
ReleaseKind.movie || ReleaseKind.other => MediaRef(kind: 'movie', title: title),
|
||||
};
|
||||
}
|
||||
|
||||
/// The episode line a ref gives (`S3 E13`, `E108`), or null (movies;
|
||||
/// extras keep the name's own marker).
|
||||
String? refEpisodeLine(MediaRef ref) {
|
||||
if (ref.kind != 'episode') return null;
|
||||
if (ref.season != null && ref.ep != null) return 'S${ref.season} E${ref.ep}';
|
||||
final n = ref.absolute ?? ref.ep ?? ref.anilistEp;
|
||||
return n == null ? null : 'E$n';
|
||||
}
|
||||
@@ -0,0 +1,656 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter/foundation.dart' show debugPrint, visibleForTesting;
|
||||
import 'package:signals/signals.dart';
|
||||
import 'package:sync_relay_core/core.dart';
|
||||
|
||||
import '../schedule/anilist.dart';
|
||||
import '../schedule/animeflow.dart';
|
||||
import 'library_store.dart';
|
||||
|
||||
// L2 of the library (ARCHITECTURE.md "Media identity"): local shows (L1)
|
||||
// matched to AniList, device-owned and cached. A season of a show is
|
||||
// searched once (title and synonyms, the season's number or name as
|
||||
// hints) and the answer kept by show key, season and season name; its
|
||||
// episodes map onto AniList entries along the run (a season AniList
|
||||
// splits into cours continues on the SEQUEL entry), or onto AnimeFlow's
|
||||
// `anilist_map` when signed in. The user's pins (a show everywhere, or one
|
||||
// folder) and episode offsets override everything and survive rescans.
|
||||
// Requests are lazy (only for shows on screen), one at a time and spaced
|
||||
// out for AniList's rate limit; everything answers offline from the cache.
|
||||
|
||||
/// Where AnimeFlow places an AniList entry: the TVDB season runs of its
|
||||
/// series ([AniListRun]) and the series' TVDB id.
|
||||
class FlowShow {
|
||||
final List<AniListRun> runs;
|
||||
final int? tvdbId;
|
||||
const FlowShow(this.runs, {this.tvdbId});
|
||||
}
|
||||
|
||||
/// AnimeFlow's knowledge of a show, when signed in.
|
||||
abstract interface class FlowLookup {
|
||||
/// The series [anilistId] belongs to; null when AnimeFlow has none (or
|
||||
/// cannot be asked).
|
||||
Future<FlowShow?> forAniList(int anilistId);
|
||||
}
|
||||
|
||||
/// [FlowLookup] over the AnimeFlow app API (`/library`, `/series`), read
|
||||
/// once per app run.
|
||||
class AnimeFlowLookup implements FlowLookup {
|
||||
final AnimeFlowClient client;
|
||||
AnimeFlowLookup(this.client);
|
||||
|
||||
Future<(List<AnimeFlowLibraryItem>, List<AnimeFlowSeries>)>? _load;
|
||||
|
||||
Future<(List<AnimeFlowLibraryItem>, List<AnimeFlowSeries>)> _fetch() async =>
|
||||
(await client.library(), await client.series());
|
||||
|
||||
@override
|
||||
Future<FlowShow?> forAniList(int anilistId) async {
|
||||
final load = _load ??= _fetch();
|
||||
final (List<AnimeFlowLibraryItem>, List<AnimeFlowSeries>) data;
|
||||
try {
|
||||
data = await load;
|
||||
} catch (e) {
|
||||
_load = null;
|
||||
return null;
|
||||
}
|
||||
final (library, series) = data;
|
||||
for (final item in library) {
|
||||
final has = item.anilistMap.any((r) => r.anilistId == anilistId) || item.anilistIds.values.contains(anilistId);
|
||||
if (!has) continue;
|
||||
final s = series
|
||||
.where((s) =>
|
||||
(item.seriesId != null && s.seriesId == item.seriesId) ||
|
||||
(s.jellyfinSeriesId != null && s.jellyfinSeriesId == item.jellyfinSeriesId))
|
||||
.firstOrNull;
|
||||
return FlowShow(item.anilistMap, tvdbId: s?.tvdbId);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// What a pin covers.
|
||||
enum PinScope { show, folder }
|
||||
|
||||
/// The AniList entry a season (or a movie, or an extra's show) is.
|
||||
class SeasonMatch {
|
||||
final AniListEntry entry;
|
||||
|
||||
/// Set by the user ("Wrong show?"), not by the search.
|
||||
final PinScope? pinned;
|
||||
const SeasonMatch(this.entry, {this.pinned});
|
||||
}
|
||||
|
||||
/// An episode on AniList.
|
||||
class EpisodeTarget {
|
||||
final int anilistId;
|
||||
final int? anilistEp;
|
||||
const EpisodeTarget(this.anilistId, this.anilistEp);
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) => other is EpisodeTarget && other.anilistId == anilistId && other.anilistEp == anilistEp;
|
||||
@override
|
||||
int get hashCode => Object.hash(anilistId, anilistEp);
|
||||
@override
|
||||
String toString() => 'EpisodeTarget($anilistId, $anilistEp)';
|
||||
}
|
||||
|
||||
/// The library's L2: see the file comment. Made by the composition root.
|
||||
class ShowMatcher {
|
||||
ShowMatcher({
|
||||
required this.store,
|
||||
required this.aniList,
|
||||
this.flow,
|
||||
this.spacing = const Duration(milliseconds: 800),
|
||||
this.retryNoneAfter = const Duration(days: 3),
|
||||
DateTime Function()? now,
|
||||
Future<void> Function(Duration)? delay,
|
||||
}) : _now = now ?? DateTime.now,
|
||||
_delay = delay ?? Future<void>.delayed;
|
||||
|
||||
final LibraryStore store;
|
||||
final AniListLookup aniList;
|
||||
final FlowLookup? flow;
|
||||
|
||||
/// Between two AniList requests.
|
||||
final Duration spacing;
|
||||
|
||||
/// A search that found nothing is asked again after this.
|
||||
final Duration retryNoneAfter;
|
||||
final DateTime Function() _now;
|
||||
final Future<void> Function(Duration) _delay;
|
||||
|
||||
/// Bumps whenever something new is known (a match, an entry, a pin).
|
||||
final revision = signal(0);
|
||||
|
||||
/// Shows being matched right now, by show key.
|
||||
final busy = signal<Set<String>>(const {});
|
||||
|
||||
// Files seen in scans this run, for refs of files put up.
|
||||
final _byPath = <String, LocalEpisode>{};
|
||||
final _entries = <int, AniListEntry>{};
|
||||
final _flows = <int, FlowShow?>{};
|
||||
final _jobs = <String, Future<void> Function()>{};
|
||||
final _failed = <String, DateTime>{};
|
||||
bool _pumping = false;
|
||||
DateTime? _lastRequest;
|
||||
DateTime? _pausedUntil;
|
||||
|
||||
/// Loads the cache (once); reads before it finishes see nothing.
|
||||
Future<void> ensureLoaded() => store.ensureLoaded().then((_) => _bump());
|
||||
|
||||
// ------------------------------------------------------------- keys
|
||||
|
||||
static String matchKey(String showKey, int? season, String? seasonName) =>
|
||||
'$showKey|${season ?? ''}|${normalizeSeries(seasonName ?? '')}';
|
||||
|
||||
static String _movieKey(LocalEpisode e) => '${e.showKey}|movie|${e.movieNumber ?? ''}';
|
||||
|
||||
String _keyOf(LocalEpisode e) =>
|
||||
e.kind == LocalKind.movie ? _movieKey(e) : matchKey(e.showKey, e.season, e.seasonName);
|
||||
|
||||
// ------------------------------------------------------------- reads
|
||||
|
||||
/// Remembers [library]'s files, so [refForPath] knows their show.
|
||||
void remember(LocalLibrary library) {
|
||||
for (final e in library.files) {
|
||||
_byPath[e.path] = e;
|
||||
}
|
||||
}
|
||||
|
||||
AniListEntry? entry(int id) {
|
||||
final known = _entries[id];
|
||||
if (known != null) return known;
|
||||
final raw = store.get('entry:$id');
|
||||
final e = raw is Map ? AniListEntry.fromJson(raw['e']) : null;
|
||||
if (e != null) _entries[id] = e;
|
||||
return e;
|
||||
}
|
||||
|
||||
/// The user's pin for [showKey] (everywhere), if any.
|
||||
int? showPin(String showKey) => _int(store.get('pin:show:$showKey'));
|
||||
|
||||
/// The pin of [folder] or the nearest folder above it, if any.
|
||||
({String folder, int id})? folderPin(String folder) {
|
||||
var f = folder;
|
||||
while (f.isNotEmpty) {
|
||||
final id = _int(store.get('pin:folder:$f'));
|
||||
if (id != null) return (folder: f, id: id);
|
||||
final cut = f.lastIndexOf(RegExp(r'[/\\]'));
|
||||
if (cut <= 0) break;
|
||||
f = f.substring(0, cut);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/// "These files are episodes N..": added to every file's number of
|
||||
/// [showKey]'s season [season].
|
||||
int offset(String showKey, int? season) => _int(store.get('offset:$showKey|${season ?? ''}')) ?? 0;
|
||||
|
||||
/// The entry [e]'s season (or movie, or the show of an extra) is: a
|
||||
/// folder pin, else the show's pin, else the search's answer.
|
||||
SeasonMatch? seasonMatch(LocalEpisode e) {
|
||||
if (e.kind == LocalKind.other) return null;
|
||||
final fp = folderPin(e.folder);
|
||||
if (fp != null) {
|
||||
final en = entry(fp.id);
|
||||
return en == null ? null : SeasonMatch(en, pinned: PinScope.folder);
|
||||
}
|
||||
final sp = showPin(e.showKey);
|
||||
if (sp != null) {
|
||||
final en = entry(sp);
|
||||
return en == null ? null : SeasonMatch(en, pinned: PinScope.show);
|
||||
}
|
||||
final auto = store.get('auto:${_keyOf(e)}');
|
||||
final id = auto is Map ? _int(auto['id']) : null;
|
||||
if (id == null) {
|
||||
// An extra of no matched season: the show's first one.
|
||||
if (e.kind == LocalKind.extra && e.season != null) {
|
||||
return seasonMatch(LocalEpisode(
|
||||
file: e.file, parsed: e.parsed, kind: LocalKind.extra, showKey: e.showKey, title: e.title, extra: e.extra));
|
||||
}
|
||||
return null;
|
||||
}
|
||||
final en = entry(id);
|
||||
return en == null ? null : SeasonMatch(en);
|
||||
}
|
||||
|
||||
/// What the show is as a whole: its first season's match (else a
|
||||
/// movie's or an extra's).
|
||||
SeasonMatch? showMatch(LocalShow show) {
|
||||
for (final e in [
|
||||
for (final s in show.seasons) s.episodes.first,
|
||||
...show.movies,
|
||||
...show.extras,
|
||||
]) {
|
||||
final m = seasonMatch(e);
|
||||
if (m != null) return m;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/// The proper name of [show]: its match's title, else as found.
|
||||
String titleOf(LocalShow show) => showMatch(show)?.entry.title ?? show.title;
|
||||
|
||||
/// The AniList entry and episode [e] is; for extras and movies only the
|
||||
/// entry. Null before a match.
|
||||
EpisodeTarget? target(LocalEpisode e) {
|
||||
final m = seasonMatch(e);
|
||||
if (m == null) return null;
|
||||
if (e.kind != LocalKind.episode) return EpisodeTarget(m.entry.id, null);
|
||||
final n = e.episode! + offset(e.showKey, e.season);
|
||||
if (n < 1) return EpisodeTarget(m.entry.id, null);
|
||||
final fs = _flowOf(m.entry.id);
|
||||
if (fs != null && e.season != null) {
|
||||
final runs = fs.runs.where((r) => r.season == e.season && r.firstEpisode <= n).toList()
|
||||
..sort((a, b) => b.firstEpisode.compareTo(a.firstEpisode));
|
||||
if (runs.isNotEmpty) return EpisodeTarget(runs.first.anilistId, n - runs.first.offset);
|
||||
}
|
||||
// Along the run: past an entry's episodes, its sequel's.
|
||||
var cur = m.entry;
|
||||
var k = n;
|
||||
for (var hop = 0; hop < 30; hop++) {
|
||||
final count = cur.episodes;
|
||||
final next = cur.sequel;
|
||||
if (count == null || k <= count || next == null) break;
|
||||
k -= count;
|
||||
final nx = entry(next);
|
||||
if (nx == null) {
|
||||
_wantEntry(next);
|
||||
return EpisodeTarget(next, k);
|
||||
}
|
||||
cur = nx;
|
||||
}
|
||||
return EpisodeTarget(cur.id, k);
|
||||
}
|
||||
|
||||
/// AniList's title of [e]'s episode, when it knows one.
|
||||
String? episodeTitle(LocalEpisode e) {
|
||||
final t = target(e);
|
||||
final ep = t?.anilistEp;
|
||||
if (t == null || ep == null) return null;
|
||||
return entry(t.anilistId)?.episodeTitles[ep];
|
||||
}
|
||||
|
||||
/// What [e] is on the wire (`media.ref`): the match when there is one,
|
||||
/// else what L1 read (kind, title, numbers).
|
||||
MediaRef refFor(LocalEpisode e) {
|
||||
final m = seasonMatch(e);
|
||||
final t = target(e);
|
||||
final n = e.episode == null ? null : e.episode! + offset(e.showKey, e.season);
|
||||
final tvdb = m == null ? null : _flowOf(m.entry.id)?.tvdbId;
|
||||
return MediaRef(
|
||||
kind: switch (e.kind) {
|
||||
LocalKind.episode => 'episode',
|
||||
LocalKind.extra => 'extra',
|
||||
LocalKind.movie || LocalKind.other => 'movie',
|
||||
},
|
||||
title: m?.entry.title ?? e.title,
|
||||
anilistId: t?.anilistId ?? m?.entry.id,
|
||||
anilistEp: t?.anilistEp,
|
||||
tvdbId: tvdb,
|
||||
season: e.kind == LocalKind.episode ? e.season : null,
|
||||
ep: e.kind == LocalKind.episode && e.season != null ? n : null,
|
||||
absolute: e.kind == LocalKind.episode && e.season == null ? n : null,
|
||||
);
|
||||
}
|
||||
|
||||
/// [refFor] the file at [path]: as seen in a scan this run, else read
|
||||
/// from its name and folder alone.
|
||||
MediaRef refForPath(String path) =>
|
||||
refFor(_byPath[path] ?? readLocalFile(LocalFile(path, 0, DateTime.fromMillisecondsSinceEpoch(0))));
|
||||
|
||||
/// The library's reading of [path] (null: not seen in a scan this run).
|
||||
LocalEpisode? episodeAt(String path) => _byPath[path];
|
||||
|
||||
// ------------------------------------------------------------- matching
|
||||
|
||||
/// Matches [show]'s seasons and movies that have no answer yet, in the
|
||||
/// background (see the file comment).
|
||||
void want(LocalShow show) {
|
||||
final seen = <String>{};
|
||||
for (final e in [
|
||||
for (final s in show.seasons) s.episodes.first,
|
||||
...show.movies,
|
||||
if (show.seasons.isEmpty) ...show.extras.take(1),
|
||||
]) {
|
||||
final key = _keyOf(e);
|
||||
if (!seen.add(key)) continue;
|
||||
final pinned = folderPin(e.folder)?.id ?? showPin(e.showKey);
|
||||
if (pinned != null) {
|
||||
if (entry(pinned) == null) _wantEntry(pinned);
|
||||
continue;
|
||||
}
|
||||
_wantMatch(key, show, e);
|
||||
}
|
||||
final m = showMatch(show);
|
||||
if (m != null) _wantFlow(m.entry.id);
|
||||
}
|
||||
|
||||
/// [want] for each standalone movie of [library] and each of [shows].
|
||||
void wantMovie(LocalEpisode movie) {
|
||||
if (movie.kind != LocalKind.movie) return;
|
||||
_wantMatch(_movieKey(movie), null, movie);
|
||||
}
|
||||
|
||||
void _wantMatch(String key, LocalShow? show, LocalEpisode e) {
|
||||
final auto = store.get('auto:$key');
|
||||
if (auto is Map) {
|
||||
final id = _int(auto['id']);
|
||||
if (id != null) {
|
||||
if (entry(id) == null) _wantEntry(id);
|
||||
return;
|
||||
}
|
||||
final at = _int(auto['at']) ?? 0;
|
||||
if (_now().difference(DateTime.fromMillisecondsSinceEpoch(at)) < retryNoneAfter) return;
|
||||
}
|
||||
if (_recentlyFailed(key)) return;
|
||||
_enqueue('match:$key', () => _match(key, show?.title ?? e.title, e));
|
||||
_setBusy(e.showKey, true);
|
||||
}
|
||||
|
||||
void _wantEntry(int id) {
|
||||
if (_recentlyFailed('entry:$id')) return;
|
||||
_enqueue('entry:$id', () async {
|
||||
final en = await _ask(() => aniList.anime(id));
|
||||
if (en != null) _keepEntry(en);
|
||||
});
|
||||
}
|
||||
|
||||
void _wantFlow(int id) {
|
||||
final f = flow;
|
||||
if (f == null || _flows.containsKey(id)) return;
|
||||
final stored = store.get('flow:$id');
|
||||
if (stored is Map) {
|
||||
_flows[id] = _flowFromJson(stored);
|
||||
final at = _int(stored['at']) ?? 0;
|
||||
if (_now().difference(DateTime.fromMillisecondsSinceEpoch(at)) < const Duration(days: 1)) return;
|
||||
} else {
|
||||
_flows[id] = null;
|
||||
}
|
||||
unawaited(f.forAniList(id).then((fs) {
|
||||
if (fs == null) return;
|
||||
_flows[id] = fs;
|
||||
store.put('flow:$id', {
|
||||
'at': _now().millisecondsSinceEpoch,
|
||||
'tvdb': fs.tvdbId,
|
||||
'runs': [
|
||||
for (final r in fs.runs) [r.season, r.anilistId, r.firstEpisode, r.offset]
|
||||
],
|
||||
});
|
||||
_bump();
|
||||
}).catchError((Object e) => debugPrint('library: animeflow lookup $id: $e')));
|
||||
}
|
||||
|
||||
FlowShow? _flowOf(int id) {
|
||||
if (!_flows.containsKey(id)) {
|
||||
final stored = store.get('flow:$id');
|
||||
if (stored is Map) _flows[id] = _flowFromJson(stored);
|
||||
}
|
||||
return _flows[id];
|
||||
}
|
||||
|
||||
static FlowShow? _flowFromJson(Map j) {
|
||||
final runs = <AniListRun>[];
|
||||
final raw = j['runs'];
|
||||
if (raw is List) {
|
||||
for (final r in raw) {
|
||||
if (r is List && r.length == 4 && r.every((v) => v is int)) {
|
||||
runs.add(AniListRun(
|
||||
season: r[0] as int, anilistId: r[1] as int, firstEpisode: r[2] as int, offset: r[3] as int));
|
||||
}
|
||||
}
|
||||
}
|
||||
return runs.isEmpty ? null : FlowShow(runs, tvdbId: _int(j['tvdb']));
|
||||
}
|
||||
|
||||
/// One search for the season [e] stands for; keeps the best entry (or
|
||||
/// "none") under [key].
|
||||
Future<void> _match(String key, String title, LocalEpisode e) async {
|
||||
try {
|
||||
final plain = title.replaceAll(RegExp(r'[._]+'), ' ').replaceAll(RegExp(r'\s+'), ' ').trim();
|
||||
final movie = e.kind == LocalKind.movie;
|
||||
final season = movie ? null : e.season;
|
||||
final name = movie ? null : e.seasonName;
|
||||
final query = [
|
||||
plain,
|
||||
if (name != null) name else if (season != null && season > 1) 'season $season',
|
||||
].join(' ');
|
||||
var hits = await _ask(() => aniList.searchAnime(query));
|
||||
var best = pickMatch(hits, title: plain, season: season, seasonName: name, movie: movie);
|
||||
if (best == null && query != plain) {
|
||||
hits = await _ask(() => aniList.searchAnime(plain));
|
||||
best = pickMatch(hits, title: plain, season: season, seasonName: name, movie: movie);
|
||||
// Found the show but not the season: walk its sequels.
|
||||
if (best != null && season != null && season > 1 && name == null && seasonMarker(best) != season) {
|
||||
var cur = best;
|
||||
for (var hop = 1; hop < season; hop++) {
|
||||
final next = cur.sequel;
|
||||
if (next == null) break;
|
||||
final nx = entry(next) ?? await _ask(() => aniList.anime(next));
|
||||
if (nx == null) break;
|
||||
_keepEntry(nx);
|
||||
cur = nx;
|
||||
}
|
||||
best = cur;
|
||||
}
|
||||
}
|
||||
for (final h in hits) {
|
||||
if (h.id == best?.id) _keepEntry(h);
|
||||
}
|
||||
store.put('auto:$key', {'id': best?.id, 'at': _now().millisecondsSinceEpoch});
|
||||
if (best != null) _wantFlow(best.id);
|
||||
_bump();
|
||||
} finally {
|
||||
_setBusy(e.showKey, false);
|
||||
}
|
||||
}
|
||||
|
||||
/// "Wrong show?": AniList's answers for [query], best first.
|
||||
Future<List<AniListEntry>> search(String query) async {
|
||||
final hits = await _ask(() => aniList.searchAnime(query));
|
||||
return hits;
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------- corrections
|
||||
|
||||
/// Pins [showKey] to [e] everywhere (every folder, every rescan).
|
||||
void pinShow(String showKey, AniListEntry e) {
|
||||
_keepEntry(e);
|
||||
store.put('pin:show:$showKey', e.id);
|
||||
_wantFlow(e.id);
|
||||
_bump();
|
||||
}
|
||||
|
||||
/// Pins every video in [folder] (and below) to [e].
|
||||
void pinFolder(String folder, AniListEntry e) {
|
||||
_keepEntry(e);
|
||||
store.put('pin:folder:$folder', e.id);
|
||||
_wantFlow(e.id);
|
||||
_bump();
|
||||
}
|
||||
|
||||
void unpinShow(String showKey) {
|
||||
store.delete('pin:show:$showKey');
|
||||
_bump();
|
||||
}
|
||||
|
||||
void unpinFolder(String folder) {
|
||||
store.delete('pin:folder:$folder');
|
||||
_bump();
|
||||
}
|
||||
|
||||
/// "These files are episodes N..": [offset] is added to the file
|
||||
/// numbers of [showKey]'s season [season] (0 removes it).
|
||||
void setOffset(String showKey, int? season, int offset) {
|
||||
final key = 'offset:$showKey|${season ?? ''}';
|
||||
offset == 0 ? store.delete(key) : store.put(key, offset);
|
||||
_bump();
|
||||
}
|
||||
|
||||
/// Forgets the automatic match of [show] so it is searched again.
|
||||
void rematch(LocalShow show) {
|
||||
for (final k in store.keys('auto:${show.key}|').toList()) {
|
||||
store.delete(k);
|
||||
}
|
||||
want(show);
|
||||
_bump();
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------- plumbing
|
||||
|
||||
void _keepEntry(AniListEntry e) {
|
||||
_entries[e.id] = e;
|
||||
store.put('entry:${e.id}', {'at': _now().millisecondsSinceEpoch, 'e': e.toJson()});
|
||||
_bump();
|
||||
}
|
||||
|
||||
void _bump() => revision.value++;
|
||||
|
||||
void _setBusy(String showKey, bool on) {
|
||||
final now = {...busy.peek()};
|
||||
if (on ? now.add(showKey) : now.remove(showKey)) busy.value = now;
|
||||
}
|
||||
|
||||
bool _recentlyFailed(String key) {
|
||||
final at = _failed[key];
|
||||
return at != null && _now().difference(at) < const Duration(minutes: 5);
|
||||
}
|
||||
|
||||
void _enqueue(String key, Future<void> Function() job) {
|
||||
if (_jobs.containsKey(key)) return;
|
||||
_jobs[key] = () => job().catchError((Object e) {
|
||||
_failed[key] = _now();
|
||||
debugPrint('library: $key failed: $e');
|
||||
if (e is AniListException && e.isRateLimited) _pausedUntil = _now().add(const Duration(minutes: 1));
|
||||
});
|
||||
if (!_pumping) unawaited(_pump());
|
||||
}
|
||||
|
||||
Future<void> _pump() async {
|
||||
_pumping = true;
|
||||
try {
|
||||
while (_jobs.isNotEmpty) {
|
||||
final key = _jobs.keys.first;
|
||||
final job = _jobs[key]!;
|
||||
await job();
|
||||
_jobs.remove(key);
|
||||
}
|
||||
} finally {
|
||||
_pumping = false;
|
||||
}
|
||||
}
|
||||
|
||||
/// One AniList request, spaced from the last one (and after a rate
|
||||
/// limit's pause).
|
||||
Future<T> _ask<T>(Future<T> Function() request) async {
|
||||
final paused = _pausedUntil;
|
||||
if (paused != null && paused.isAfter(_now())) await _delay(paused.difference(_now()));
|
||||
final last = _lastRequest;
|
||||
if (last != null) {
|
||||
final wait = spacing - _now().difference(last);
|
||||
if (wait > Duration.zero) await _delay(wait);
|
||||
}
|
||||
_lastRequest = _now();
|
||||
return request();
|
||||
}
|
||||
|
||||
@visibleForTesting
|
||||
bool get idle => _jobs.isEmpty && !_pumping;
|
||||
|
||||
static int? _int(Object? v) => v is int ? v : null;
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ scoring
|
||||
|
||||
/// The season number an entry's names carry (`Season 2`, `2nd Season`,
|
||||
/// `Part 2` is not one); null when none does.
|
||||
int? seasonMarker(AniListEntry e) {
|
||||
for (final n in e.names) {
|
||||
final s = _seasonOf(normalizeSeries(n));
|
||||
if (s != null) return s;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
final _seasonWords = RegExp(r'\b(?:season (\d{1,2})|(\d{1,2})(?:st|nd|rd|th) season|s(\d{1,2}))\b');
|
||||
final _roman = {'ii': 2, 'iii': 3, 'iv': 4, 'v': 5, 'vi': 6};
|
||||
|
||||
int? _seasonOf(String normalized) {
|
||||
final m = _seasonWords.firstMatch(normalized);
|
||||
if (m != null) return int.parse(m.group(1) ?? m.group(2) ?? m.group(3)!);
|
||||
final last = normalized.split(' ').last;
|
||||
return _roman[last];
|
||||
}
|
||||
|
||||
/// How alike two normalized titles are, 0..1.
|
||||
double titleSimilarity(String a, String b) {
|
||||
if (a.isEmpty || b.isEmpty) return 0;
|
||||
if (a == b) return 1;
|
||||
final short = a.length <= b.length ? a : b;
|
||||
final long = a.length <= b.length ? b : a;
|
||||
final ratio = short.length / long.length;
|
||||
if (long.startsWith('$short ')) return 0.8 + 0.2 * ratio;
|
||||
if (long.contains(' $short ') || long.endsWith(' $short')) return 0.6 + 0.2 * ratio;
|
||||
// Dice over letter pairs, spaces ignored.
|
||||
Set<String> pairs(String s) {
|
||||
final t = s.replaceAll(' ', '');
|
||||
return {for (var i = 0; i + 1 < t.length; i++) t.substring(i, i + 2)};
|
||||
}
|
||||
|
||||
final pa = pairs(a), pb = pairs(b);
|
||||
if (pa.isEmpty || pb.isEmpty) return 0;
|
||||
return 2 * pa.intersection(pb).length / (pa.length + pb.length);
|
||||
}
|
||||
|
||||
/// Scores [e] as the answer for a show called [title] (its season
|
||||
/// [season] or its season named [seasonName]; a [movie]); [rank]: its
|
||||
/// place in AniList's own order.
|
||||
double scoreMatch(AniListEntry e,
|
||||
{required String title, int? season, String? seasonName, bool movie = false, int rank = 0}) {
|
||||
final q = normalizeSeries(title);
|
||||
final full = seasonName == null ? q : normalizeSeries('$title $seasonName');
|
||||
var best = 0.0;
|
||||
for (final n in e.names) {
|
||||
final nn = normalizeSeries(n);
|
||||
final s = [titleSimilarity(nn, q), titleSimilarity(nn, full)].reduce((a, b) => a > b ? a : b);
|
||||
if (s > best) best = s;
|
||||
}
|
||||
var score = best;
|
||||
if (seasonName != null) {
|
||||
final sub = normalizeSeries(seasonName);
|
||||
if (e.names.any((n) => normalizeSeries(n).contains(sub))) score += 0.25;
|
||||
} else if (!movie) {
|
||||
final wanted = season ?? 1;
|
||||
final marker = seasonMarker(e);
|
||||
if (marker != null && marker == wanted) {
|
||||
score += 0.2;
|
||||
} else if (marker != null) {
|
||||
score -= 0.25;
|
||||
} else if (wanted > 1) {
|
||||
score -= 0.1;
|
||||
}
|
||||
}
|
||||
if (movie) {
|
||||
score += e.isMovie ? 0.15 : -0.25;
|
||||
} else {
|
||||
score += e.isSeries ? 0.05 : (e.isMovie ? -0.3 : -0.1);
|
||||
}
|
||||
return score - rank * 0.02;
|
||||
}
|
||||
|
||||
/// The best of [hits] for a show called [title], if any is good enough.
|
||||
AniListEntry? pickMatch(List<AniListEntry> hits,
|
||||
{required String title, int? season, String? seasonName, bool movie = false}) {
|
||||
AniListEntry? best;
|
||||
var bestScore = 0.55;
|
||||
for (var i = 0; i < hits.length; i++) {
|
||||
final s = scoreMatch(hits[i], title: title, season: season, seasonName: seasonName, movie: movie, rank: i);
|
||||
if (s > bestScore) {
|
||||
best = hits[i];
|
||||
bestScore = s;
|
||||
}
|
||||
}
|
||||
return best;
|
||||
}
|
||||
@@ -7,11 +7,11 @@ import 'package:flutter/foundation.dart';
|
||||
import 'package:signals/signals_flutter.dart';
|
||||
import 'package:sync_relay_core/core.dart';
|
||||
|
||||
import '../files/name_parser.dart';
|
||||
import '../files/thumbnails.dart';
|
||||
import '../jellyfin/accounts.dart';
|
||||
import '../jellyfin/format.dart' show pickFor;
|
||||
import '../jellyfin/session_media.dart';
|
||||
import '../library/media_refs.dart';
|
||||
import '../screens/home/picked_media.dart';
|
||||
import '../session/app_events.dart' show AddedToUpNext;
|
||||
import '../session/media_art.dart';
|
||||
@@ -164,15 +164,22 @@ class QueueController {
|
||||
if (bytes.length <= RelayConstants.maxPosterBytes) thumb = bytes;
|
||||
} catch (_) {}
|
||||
}
|
||||
final ep = [
|
||||
if (parsed.season != null) 'S${parsed.season}',
|
||||
if (parsed.episode != null) 'E${parsed.episode}',
|
||||
].join();
|
||||
// The library's name for it when it knows the show (what friends
|
||||
// see in Up next), else the release name's.
|
||||
final what = coord.fileRef?.call(path);
|
||||
final ep = what != null && what.kind == 'episode'
|
||||
? (refEpisodeLine(what) ?? '').replaceAll(' ', '')
|
||||
: [
|
||||
if (parsed.season != null) 'S${parsed.season}',
|
||||
if (parsed.episode != null) 'E${parsed.episode}',
|
||||
if (parsed.episode == null) ?parsed.extra,
|
||||
].join();
|
||||
final title = what != null && what.title.isNotEmpty ? what.title : parsed.series.trim();
|
||||
return (
|
||||
item: QueueItem(
|
||||
media: HelloMedia(name: name, size: await file.length(), duration: 0),
|
||||
media: HelloMedia(name: name, size: await file.length(), duration: 0, ref: what),
|
||||
how: QueueHow.file,
|
||||
title: parsed.series.trim().isEmpty ? name : parsed.series.trim(),
|
||||
title: title.isEmpty ? name : title,
|
||||
sub: ep,
|
||||
duration: (shot?.duration.inMilliseconds ?? 0) / 1000,
|
||||
key: _newKey(),
|
||||
@@ -187,7 +194,7 @@ class QueueController {
|
||||
if (ref != null) {
|
||||
return (
|
||||
item: QueueItem(
|
||||
media: HelloMedia(name: linked.name, size: 0, duration: 0, jellyfin: ref),
|
||||
media: linked,
|
||||
how: QueueHow.jellyfin,
|
||||
title: ref.title.isEmpty ? linked.name : ref.title,
|
||||
sub: ref.subtitle ?? '',
|
||||
@@ -234,7 +241,8 @@ class QueueController {
|
||||
size: 0,
|
||||
duration: 0,
|
||||
url: how == QueueHow.through ? null : url,
|
||||
relay: how == QueueHow.server ? const MediaRelay(fetch: true) : null),
|
||||
relay: how == QueueHow.server ? const MediaRelay(fetch: true) : null,
|
||||
ref: linkMediaRef(title)),
|
||||
how: how,
|
||||
title: title,
|
||||
sub: site,
|
||||
|
||||
@@ -9,7 +9,6 @@ import 'package:sync_relay_core/core.dart';
|
||||
|
||||
import '../controls/controls_probe.dart' show ControlsHold;
|
||||
import '../controls/minimal/style.dart';
|
||||
import '../files/scanner.dart' show FileKind, kindOf;
|
||||
import '../jellyfin/images.dart';
|
||||
import '../jellyfin/session_media.dart' show EpisodeLink;
|
||||
import '../screens/home/picked_media.dart';
|
||||
|
||||
@@ -8,8 +8,10 @@ import 'package:sync_relay_core/core.dart';
|
||||
|
||||
|
||||
// AniList airing schedule (public GraphQL, no login): every episode airing
|
||||
// in a time range, with covers, banners and popularity; and a title search
|
||||
// (cover + banner of a show, for the media art of files and links).
|
||||
// in a time range, with covers, banners and popularity; a title search
|
||||
// (cover + banner of a show, for the media art of files and links); and
|
||||
// the library's lookups (search with synonyms and relations, one entry by
|
||||
// id; see ARCHITECTURE.md "Media identity").
|
||||
//
|
||||
// AniList allows ~90 requests/min (sometimes degraded to 30), so the raw
|
||||
// pages are cached: in memory for 10 min (tab switches) and on disk for an
|
||||
@@ -105,6 +107,177 @@ class AniListMedia {
|
||||
}
|
||||
}
|
||||
|
||||
/// An anime as the library matches it ([AniListClient.searchAnime],
|
||||
/// [AniListClient.anime]): titles and synonyms to match on, its format
|
||||
/// and length, art, and the next TV entry of its run ([sequel]) to number
|
||||
/// across split seasons and cours. JSON round-trips for the library's
|
||||
/// cache ([toJson]).
|
||||
class AniListEntry {
|
||||
final int id;
|
||||
final String romaji;
|
||||
final String english;
|
||||
final String native;
|
||||
final List<String> synonyms;
|
||||
/// `TV`, `TV_SHORT`, `MOVIE`, `OVA`, `ONA`, `SPECIAL`, `MUSIC`; '' unknown.
|
||||
final String format;
|
||||
/// Null while it airs with no announced count.
|
||||
final int? episodes;
|
||||
final int? year;
|
||||
final String? cover;
|
||||
final String? coverSmall;
|
||||
final String? banner;
|
||||
/// The next TV / TV_SHORT / ONA entry (`SEQUEL` relation).
|
||||
final int? sequel;
|
||||
final int? prequel;
|
||||
/// Episode titles AniList knows (its streaming episodes), by number.
|
||||
final Map<int, String> episodeTitles;
|
||||
|
||||
const AniListEntry({
|
||||
required this.id,
|
||||
this.romaji = '',
|
||||
this.english = '',
|
||||
this.native = '',
|
||||
this.synonyms = const [],
|
||||
this.format = '',
|
||||
this.episodes,
|
||||
this.year,
|
||||
this.cover,
|
||||
this.coverSmall,
|
||||
this.banner,
|
||||
this.sequel,
|
||||
this.prequel,
|
||||
this.episodeTitles = const {},
|
||||
});
|
||||
|
||||
/// English title, else romaji.
|
||||
String get title => english.isNotEmpty ? english : (romaji.isNotEmpty ? romaji : native);
|
||||
|
||||
/// Every name it goes by, for matching.
|
||||
List<String> get names => [
|
||||
for (final t in [english, romaji, native, ...synonyms])
|
||||
if (t.trim().isNotEmpty) t,
|
||||
];
|
||||
|
||||
bool get isMovie => format == 'MOVIE';
|
||||
|
||||
/// A run of episodes a season or cour is made of.
|
||||
bool get isSeries => format == 'TV' || format == 'TV_SHORT' || format == 'ONA';
|
||||
|
||||
static const graphQLFields = 'id title{romaji english native} synonyms format episodes seasonYear '
|
||||
'coverImage{extraLarge large} bannerImage '
|
||||
'relations{edges{relationType node{id format type}}} streamingEpisodes{title}';
|
||||
|
||||
static final _streamingTitle = RegExp(r'^\s*(?:Episode|Ep\.?)\s*(\d{1,4})\s*[-–:]\s*(.+)$', caseSensitive: false);
|
||||
|
||||
/// From an AniList `Media` object; null when it has no id.
|
||||
static AniListEntry? fromGraphQL(Object? m) {
|
||||
if (m is! Map || m['id'] is! int) return null;
|
||||
String str(Object? v) => v is String ? v : '';
|
||||
final t = m['title'] is Map ? m['title'] as Map : const {};
|
||||
final cov = m['coverImage'] is Map ? m['coverImage'] as Map : const {};
|
||||
final large = cov['large'] is String ? cov['large'] as String : null;
|
||||
int? related(String type) {
|
||||
final edges = m['relations'] is Map ? (m['relations'] as Map)['edges'] : null;
|
||||
if (edges is! List) return null;
|
||||
for (final e in edges) {
|
||||
if (e is! Map || e['relationType'] != type) continue;
|
||||
final node = e['node'];
|
||||
if (node is Map && node['id'] is int && node['type'] != 'MANGA' &&
|
||||
const {'TV', 'TV_SHORT', 'ONA'}.contains(node['format'])) {
|
||||
return node['id'] as int;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
final titles = <int, String>{};
|
||||
final streaming = m['streamingEpisodes'];
|
||||
if (streaming is List) {
|
||||
for (final s in streaming) {
|
||||
final match = s is Map && s['title'] is String ? _streamingTitle.firstMatch(s['title'] as String) : null;
|
||||
if (match != null) titles.putIfAbsent(int.parse(match.group(1)!), () => match.group(2)!.trim());
|
||||
}
|
||||
}
|
||||
return AniListEntry(
|
||||
id: m['id'] as int,
|
||||
romaji: str(t['romaji']),
|
||||
english: str(t['english']),
|
||||
native: str(t['native']),
|
||||
synonyms: [
|
||||
if (m['synonyms'] is List)
|
||||
for (final s in m['synonyms'] as List)
|
||||
if (s is String && s.trim().isNotEmpty) s.trim(),
|
||||
],
|
||||
format: str(m['format']),
|
||||
episodes: m['episodes'] is int ? m['episodes'] as int : null,
|
||||
year: m['seasonYear'] is int ? m['seasonYear'] as int : null,
|
||||
cover: cov['extraLarge'] is String ? cov['extraLarge'] as String : large,
|
||||
coverSmall: large,
|
||||
banner: m['bannerImage'] is String ? m['bannerImage'] as String : null,
|
||||
sequel: related('SEQUEL'),
|
||||
prequel: related('PREQUEL'),
|
||||
episodeTitles: titles,
|
||||
);
|
||||
}
|
||||
|
||||
Map<String, Object?> toJson() => {
|
||||
'id': id,
|
||||
'romaji': romaji,
|
||||
'english': english,
|
||||
'native': native,
|
||||
'synonyms': synonyms,
|
||||
'format': format,
|
||||
'episodes': episodes,
|
||||
'year': year,
|
||||
'cover': cover,
|
||||
'coverSmall': coverSmall,
|
||||
'banner': banner,
|
||||
'sequel': sequel,
|
||||
'prequel': prequel,
|
||||
'episodeTitles': {for (final e in episodeTitles.entries) '${e.key}': e.value},
|
||||
};
|
||||
|
||||
static AniListEntry? fromJson(Object? j) {
|
||||
if (j is! Map || j['id'] is! int) return null;
|
||||
String str(Object? v) => v is String ? v : '';
|
||||
String? opt(Object? v) => v is String ? v : null;
|
||||
int? n(Object? v) => v is int ? v : null;
|
||||
final titles = j['episodeTitles'];
|
||||
return AniListEntry(
|
||||
id: j['id'] as int,
|
||||
romaji: str(j['romaji']),
|
||||
english: str(j['english']),
|
||||
native: str(j['native']),
|
||||
synonyms: [if (j['synonyms'] is List) ...(j['synonyms'] as List).whereType<String>()],
|
||||
format: str(j['format']),
|
||||
episodes: n(j['episodes']),
|
||||
year: n(j['year']),
|
||||
cover: opt(j['cover']),
|
||||
coverSmall: opt(j['coverSmall']),
|
||||
banner: opt(j['banner']),
|
||||
sequel: n(j['sequel']),
|
||||
prequel: n(j['prequel']),
|
||||
episodeTitles: {
|
||||
if (titles is Map)
|
||||
for (final e in titles.entries)
|
||||
if (int.tryParse('${e.key}') case final k? when e.value is String) k: e.value as String,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
String toString() => 'AniListEntry($id "$title" $format ${episodes ?? '?'} eps)';
|
||||
}
|
||||
|
||||
/// What the library asks AniList ([AniListClient]; tests fake it).
|
||||
abstract interface class AniListLookup {
|
||||
/// Anime matching [query], AniList's best first.
|
||||
Future<List<AniListEntry>> searchAnime(String query);
|
||||
|
||||
/// One anime by id; null when AniList has none.
|
||||
Future<AniListEntry?> anime(int id);
|
||||
}
|
||||
|
||||
/// "#e4a15d" -> opaque [Color]; anything else -> null.
|
||||
Color? parseHexColor(Object? s) {
|
||||
if (s is! String || s.length != 7 || !s.startsWith('#')) return null;
|
||||
@@ -142,7 +315,7 @@ class _Cached {
|
||||
const _Cached(this.fetched, this.from, this.to, this.raw);
|
||||
}
|
||||
|
||||
class AniListClient {
|
||||
class AniListClient implements AniListLookup {
|
||||
AniListClient({
|
||||
String url = aniListDefaultUrl,
|
||||
HttpClient? http,
|
||||
@@ -306,6 +479,49 @@ coverImage{extraLarge large} bannerImage}}''';
|
||||
}
|
||||
}
|
||||
|
||||
static final _listQuery = 'query(\$search:String){Page(perPage:8){media(search:\$search,type:ANIME,'
|
||||
'isAdult:false,sort:SEARCH_MATCH){${AniListEntry.graphQLFields}}}}';
|
||||
static final _byIdQuery = 'query(\$id:Int){Media(id:\$id,type:ANIME){${AniListEntry.graphQLFields}}}';
|
||||
|
||||
/// Up to 8 anime matching [query], AniList's best first (the library's
|
||||
/// matching and its "Wrong show?" search). Not cached here: the library
|
||||
/// keeps what it needs.
|
||||
@override
|
||||
Future<List<AniListEntry>> searchAnime(String query) async {
|
||||
final q = query.trim();
|
||||
if (q.isEmpty) return const [];
|
||||
final (data, _, _) = await _graphql(_listQuery, {'search': q});
|
||||
final page = data?['Page'];
|
||||
final media = page is Map ? page['media'] : null;
|
||||
return [if (media is List) for (final m in media) ?AniListEntry.fromGraphQL(m)];
|
||||
}
|
||||
|
||||
@override
|
||||
Future<AniListEntry?> anime(int id) async {
|
||||
try {
|
||||
final (data, _, _) = await _graphql(_byIdQuery, {'id': id});
|
||||
return AniListEntry.fromGraphQL(data?['Media']);
|
||||
} on AniListException catch (e) {
|
||||
if (e.statusCode == 404) return null;
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
|
||||
static const byIdArtQuery = r'''
|
||||
query($id:Int){Media(id:$id,type:ANIME){id title{romaji english} coverImage{extraLarge large} bannerImage}}''';
|
||||
|
||||
/// Cover and banner of AniList entry [id] (the media art of a file the
|
||||
/// library matched); null when there is none.
|
||||
Future<AniListMedia?> mediaById(int id) async {
|
||||
try {
|
||||
final (data, _, _) = await _graphql(byIdArtQuery, {'id': id});
|
||||
return AniListMedia.fromJson(data?['Media']);
|
||||
} on AniListException catch (e) {
|
||||
if (e.statusCode == 404) return null;
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
|
||||
static String _errorText(Object? j) {
|
||||
final errs = j is Map ? j['errors'] : null;
|
||||
if (errs is! List || errs.isEmpty) return '';
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import 'package:sync_relay_core/core.dart' show MediaRef;
|
||||
|
||||
/// What the media picker selected. Null selection = nothing (hosting
|
||||
/// without media is allowed).
|
||||
sealed class PickedMedia {
|
||||
@@ -72,6 +74,9 @@ final class JellyfinMedia extends PickedMedia {
|
||||
/// "Series · S1E6" for episodes, the year for movies; may be empty.
|
||||
final String subtitle;
|
||||
final Duration? runtime;
|
||||
/// What it is (`media.ref`), when it was picked from the item itself
|
||||
/// (with its provider ids); not stored, not part of equality.
|
||||
final MediaRef? ref;
|
||||
const JellyfinMedia({
|
||||
required this.serverId,
|
||||
required this.baseUrl,
|
||||
@@ -80,6 +85,7 @@ final class JellyfinMedia extends PickedMedia {
|
||||
required this.title,
|
||||
this.subtitle = '',
|
||||
this.runtime,
|
||||
this.ref,
|
||||
});
|
||||
|
||||
@override
|
||||
|
||||
@@ -7,7 +7,6 @@ import 'package:flutter/foundation.dart';
|
||||
import 'package:image/image.dart' as img;
|
||||
import 'package:sync_relay_core/core.dart';
|
||||
|
||||
import '../files/name_parser.dart';
|
||||
import '../schedule/anilist.dart';
|
||||
|
||||
// Art for media this client swapped in (`art`, see PROTOCOL.md "Media
|
||||
@@ -58,6 +57,11 @@ Future<ArtBytes?> aniListArt(AniListClient aniList, String name,
|
||||
if (!RegExp(r'\p{L}', unicode: true).hasMatch(series)) return null;
|
||||
final match = await aniList.searchMedia(series);
|
||||
if (match == null) return null;
|
||||
return _artOf(match, fetch);
|
||||
}
|
||||
|
||||
Future<ArtBytes> _artOf(AniListMedia match,
|
||||
Future<Uint8List?> Function(Uri url, Map<String, String> headers, int maxBytes) fetch) async {
|
||||
Future<Uint8List?> get(String? url, int maxBytes) async {
|
||||
final uri = url == null ? null : Uri.tryParse(url);
|
||||
return uri == null ? null : fetch(uri, const {}, maxBytes);
|
||||
@@ -71,6 +75,14 @@ Future<ArtBytes?> aniListArt(AniListClient aniList, String name,
|
||||
return (poster: poster, banner: banner);
|
||||
}
|
||||
|
||||
/// Cover and banner of AniList entry [id] (a file the library matched);
|
||||
/// null when AniList has none.
|
||||
Future<ArtBytes?> aniListArtById(AniListClient aniList, int id,
|
||||
{Future<Uint8List?> Function(Uri url, Map<String, String> headers, int maxBytes) fetch = fetchImage}) async {
|
||||
final match = await aniList.mediaById(id);
|
||||
return match == null ? null : _artOf(match, fetch);
|
||||
}
|
||||
|
||||
/// A web video's thumbnail at [url] as both cover and banner (the same
|
||||
/// bytes, so the same hash: what tells clients it is a thumbnail, see
|
||||
/// `NowPlaying.thumbnail`); shrunk to a JPEG under the cover cap when
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import 'package:sync_relay_core/core.dart' show hasMediaExtension;
|
||||
import 'package:sync_relay_core/core.dart' show MediaRef, hasMediaExtension, parseReleaseName;
|
||||
|
||||
import '../files/name_parser.dart';
|
||||
import '../library/media_refs.dart' show refEpisodeLine;
|
||||
import '../jellyfin/session_media.dart';
|
||||
import 'now_playing.dart';
|
||||
|
||||
@@ -26,12 +26,29 @@ NowPlaying? nowPlayingOf(Map<String, Object?>? media) {
|
||||
if (web) {
|
||||
return NowPlaying(title: name, posterHash: poster, bannerHash: banner, thumbnail: true);
|
||||
}
|
||||
final what = _mediaRef(media['ref']);
|
||||
final (title, episode) = ref != null
|
||||
? jellyfinTitle(ref.title.isNotEmpty ? ref.title : name, ref.subtitle)
|
||||
: fileTitle(name);
|
||||
: what != null && what.title.isNotEmpty
|
||||
? refTitle(what, name)
|
||||
: fileTitle(name);
|
||||
return NowPlaying(title: title, episode: episode, posterHash: poster, bannerHash: banner);
|
||||
}
|
||||
|
||||
MediaRef? _mediaRef(Object? j) {
|
||||
try {
|
||||
return MediaRef.fromJson(j);
|
||||
} catch (_) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// Files and links that say what they are (`media.ref`, from the
|
||||
/// sender's library): its proper title and `S3 E13` / `E108`; an extra
|
||||
/// keeps the name's own marker (OP1, NCED2).
|
||||
(String, String?) refTitle(MediaRef ref, String name) =>
|
||||
(ref.title, refEpisodeLine(ref) ?? (ref.kind == 'extra' ? fileTitle(name).$2 : null));
|
||||
|
||||
/// [media] (`state.media`, not Jellyfin) is a web page's video, whose art
|
||||
/// will be its thumbnail: a shared http(s) link that is no media file (the
|
||||
/// everyone-opens-it mode), or a relayed one (passed on by a member or
|
||||
|
||||
@@ -12,6 +12,7 @@ import '../jellyfin/accounts.dart';
|
||||
import '../jellyfin/progress.dart';
|
||||
import '../jellyfin/session_media.dart';
|
||||
import '../jellyfin/track_prefs.dart';
|
||||
import '../library/media_refs.dart';
|
||||
import '../playback/media_marks.dart';
|
||||
import '../playback/playback_controller.dart';
|
||||
import '../platform/ports/link_resolver.dart';
|
||||
@@ -118,6 +119,9 @@ class SessionCoordinator {
|
||||
/// Set by the hosting app: makes a local file the hosted one (File swap);
|
||||
/// null when this app is not the host.
|
||||
Future<({String name, int size})?> Function(String path)? hostFile;
|
||||
/// What a local file is (`media.ref`, from the library: `ShowMatcher`),
|
||||
/// set by the composition root; null: files go up without one.
|
||||
MediaRef? Function(String path)? fileRef;
|
||||
/// The app's yt-dlp and link probes (tests pass their own).
|
||||
final LinkResolver ytDlp;
|
||||
/// The link to the server (socket, reconnects, clock offset, its
|
||||
@@ -1449,8 +1453,11 @@ class SessionCoordinator {
|
||||
final uri = thumb == null ? null : Uri.tryParse(thumb);
|
||||
bytes = uri == null ? null : await thumbnailArt(uri);
|
||||
} else {
|
||||
// The library's match names the AniList entry; else a title search.
|
||||
final known = ref == null ? media.ref?.anilistId : null;
|
||||
final name = ref == null ? media.name : jellyfinTitle(ref.title, ref.subtitle).$1;
|
||||
bytes = await aniListArt(_aniList ??= AniListClient(), name);
|
||||
final aniList = _aniList ??= AniListClient();
|
||||
bytes = await (known != null ? aniListArtById(aniList, known) : null) ?? await aniListArt(aniList, name);
|
||||
}
|
||||
final poster = bytes?.poster, banner = bytes?.banner;
|
||||
if (poster == null && banner == null) return;
|
||||
@@ -1491,8 +1498,9 @@ class SessionCoordinator {
|
||||
// A page is named by its title, never its URL tail ("1", "watch").
|
||||
final uri = Uri.tryParse(url) ?? Uri();
|
||||
final name = probe?.title ?? (probe != null || hasMediaExtension(uri) || uri.host.isEmpty ? media.name : uri.host);
|
||||
final what = linkMediaRef(name);
|
||||
if (relay == LinkRelay.direct) {
|
||||
_swap(HelloMedia(name: name, size: 0, duration: 0, url: url), queueId: queueId);
|
||||
_swap(HelloMedia(name: name, size: 0, duration: 0, url: url, ref: what), queueId: queueId);
|
||||
return null;
|
||||
}
|
||||
if (relay == LinkRelay.me) {
|
||||
@@ -1526,8 +1534,8 @@ class SessionCoordinator {
|
||||
await check?.close();
|
||||
}
|
||||
_pendingOrigin = _LinkOrigin(url, probe, y);
|
||||
_swap(HelloMedia(
|
||||
name: name, size: size, duration: 0, relay: MediaRelay(tracks: specs)), link: url, queueId: queueId);
|
||||
_swap(HelloMedia(name: name, size: size, duration: 0, relay: MediaRelay(tracks: specs), ref: what),
|
||||
link: url, queueId: queueId);
|
||||
return null;
|
||||
}
|
||||
// The server reads it with its own yt-dlp; only a page this app knows
|
||||
@@ -1537,7 +1545,12 @@ class SessionCoordinator {
|
||||
return probe.relayProblem;
|
||||
}
|
||||
_swap(HelloMedia(
|
||||
name: name, size: max(0, probe?.size ?? 0), duration: 0, url: url, relay: const MediaRelay(fetch: true)),
|
||||
name: name,
|
||||
size: max(0, probe?.size ?? 0),
|
||||
duration: 0,
|
||||
url: url,
|
||||
relay: const MediaRelay(fetch: true),
|
||||
ref: what),
|
||||
queueId: queueId);
|
||||
return null;
|
||||
}
|
||||
@@ -1563,18 +1576,23 @@ class SessionCoordinator {
|
||||
/// when sent.
|
||||
Future<String?> swapFile(String path, {String? queueId}) async {
|
||||
markInput();
|
||||
final what = fileRef?.call(path);
|
||||
final host = hostFile;
|
||||
if (host != null) {
|
||||
final file = await host(path);
|
||||
if (file == null) return 'file not found — check the path';
|
||||
_swap(HelloMedia(name: file.name, size: file.size, duration: 0), queueId: queueId);
|
||||
_swap(HelloMedia(name: file.name, size: file.size, duration: 0, ref: what), queueId: queueId);
|
||||
return null;
|
||||
}
|
||||
final file = File(path);
|
||||
if (!await file.exists()) return 'file not found — check the path';
|
||||
_pendingOrigin = _FileOrigin(path);
|
||||
_swap(HelloMedia(
|
||||
name: file.uri.pathSegments.last, size: await file.length(), duration: 0, relay: const MediaRelay()),
|
||||
name: file.uri.pathSegments.last,
|
||||
size: await file.length(),
|
||||
duration: 0,
|
||||
relay: const MediaRelay(),
|
||||
ref: what),
|
||||
queueId: queueId);
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -8,7 +8,7 @@ import 'package:signals/signals.dart';
|
||||
import 'package:sync_relay_core/core.dart';
|
||||
|
||||
import '../jellyfin/accounts.dart';
|
||||
import '../jellyfin/session_media.dart' show relayCredentialFor;
|
||||
import '../jellyfin/session_media.dart' show relayCredentialsFor;
|
||||
import '../state/connect_prefs.dart';
|
||||
import '../state/server_isolate.dart';
|
||||
import 'invite.dart';
|
||||
@@ -60,7 +60,7 @@ class YourServer {
|
||||
_interfaces = interfaces ?? machineInterfaces;
|
||||
|
||||
final ConnectStore forms;
|
||||
/// Linked Jellyfin servers (default: the app's): the relay login.
|
||||
/// Linked Jellyfin servers (default: the app's): the relay logins.
|
||||
final JellyfinAccounts? jellyfin;
|
||||
final Future<String> Function() _ytDlpDir;
|
||||
final String? Function() _logDir;
|
||||
@@ -156,8 +156,8 @@ class YourServer {
|
||||
port: want,
|
||||
secret: form.hostSecret,
|
||||
// Guests without an account watch Jellyfin items through the
|
||||
// server, with this app's own sign-in.
|
||||
jellyfin: relayCredentialFor(_accounts),
|
||||
// server, with this app's own sign-in on the item's server.
|
||||
jellyfin: relayCredentialsFor(_accounts),
|
||||
);
|
||||
final invite = await inviteFor(bound, password: form.hostSecret.isNotEmpty);
|
||||
shared.value = invite;
|
||||
|
||||
@@ -159,9 +159,10 @@ class ServerIsolate {
|
||||
|
||||
/// Opens the server to the network ([SyncServer.share]): a listener on
|
||||
/// every interface at [port], the password [secret], the Jellyfin relay
|
||||
/// login [jellyfin]. Returns the bound port; throws a [StateError] with
|
||||
/// logins [jellyfin] (one per Jellyfin server). Returns the bound port;
|
||||
/// throws a [StateError] with
|
||||
/// the server's reason (a port in use) when it cannot.
|
||||
Future<int> share({required int port, required String secret, JellyfinCredential? jellyfin,
|
||||
Future<int> share({required int port, required String secret, List<JellyfinCredential>? jellyfin,
|
||||
Duration timeout = const Duration(seconds: 10)}) async {
|
||||
if (_exited.isCompleted) throw StateError('the server is not running');
|
||||
final reply = ReceivePort('server share');
|
||||
@@ -170,7 +171,7 @@ class ServerIsolate {
|
||||
't': 'share',
|
||||
'port': port,
|
||||
'secret': secret,
|
||||
'jellyfin': ?jellyfin?.toMap(),
|
||||
if (jellyfin != null) 'jellyfin': [for (final c in jellyfin) c.toMap()],
|
||||
'reply': reply.sendPort,
|
||||
});
|
||||
final msg = await reply.first.timeout(timeout);
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
// restructure: rewrite in step 4 (Library + Media identity): series
|
||||
// grouping on the page follows L1/L2.
|
||||
import 'dart:async';
|
||||
import 'dart:io';
|
||||
import 'dart:typed_data';
|
||||
@@ -12,21 +10,54 @@ import 'package:starsync/src/files/scanner.dart';
|
||||
import 'package:starsync/src/files/thumbnails.dart';
|
||||
import 'package:starsync/src/files/ui/file_tiles.dart';
|
||||
import 'package:starsync/src/files/ui/files_page.dart';
|
||||
import 'package:starsync/src/files/ui/library_view.dart';
|
||||
import 'package:starsync/src/library/library_store.dart';
|
||||
import 'package:starsync/src/library/show_matcher.dart';
|
||||
import 'package:starsync/src/schedule/anilist.dart';
|
||||
|
||||
import 'test_graph.dart';
|
||||
|
||||
/// This file's composition root (see test_graph.dart).
|
||||
final _graph = testGraph();
|
||||
|
||||
// The Files page over a real (tiny) folder tree: the scan runs in-line
|
||||
// instead of in an isolate and thumbnails come from a fake loader, so
|
||||
// nothing here decodes video.
|
||||
// The Files tab as a library over a real (tiny) generated tree with the
|
||||
// names of a real anime folder: the scan runs in-line instead of in an
|
||||
// isolate, thumbnails come from a fake loader and AniList is a fake, so
|
||||
// nothing here decodes video or goes online.
|
||||
|
||||
final _tmp = Platform.environment['WT_TEST_TMP'] ?? Directory.systemTemp.path;
|
||||
|
||||
late Directory tmp;
|
||||
late String anime;
|
||||
late FolderStore store;
|
||||
late List<String> thumbRequests;
|
||||
late int scans;
|
||||
late ShowMatcher matcher;
|
||||
late FakeAniList aniList;
|
||||
|
||||
const b1 = '[Judas] Bleach 001-055 [BD 1080p][HEVC x265 10bit]';
|
||||
const b2 = '[Judas] Bleach 056-111 [BD 1080p][HEVC x265 10bit]';
|
||||
const b3 = '[Judas] Bleach 112-139 [BD 1080p][HEVC x265 10bit][Dual-Audio][Eng-Subs]';
|
||||
const sao = '[Judas] Sword Art Online (Season 3 - Alicization) [BD 1080p][HEVC x265 10bit][Eng-Subs]';
|
||||
const kimetsu =
|
||||
'[Erai-raws] Kimetsu no Yaiba - Mugenjou Hen - Movie 01 [1080p CR WEBRip HEVC AAC][MultiSub][8E47462C].mkv';
|
||||
|
||||
String bleach(int n) => '$anime/${n < 112 ? b2 : b3}/[Judas] Bleach - ${n.toString().padLeft(3, '0')}.mkv';
|
||||
|
||||
const _tybw = AniListEntry(
|
||||
id: 41467, romaji: 'BLEACH: Sennen Kessen-hen', english: 'Bleach: Thousand-Year Blood War', format: 'TV', episodes: 13);
|
||||
|
||||
class FakeAniList implements AniListLookup {
|
||||
final searches = <String>[];
|
||||
@override
|
||||
Future<List<AniListEntry>> searchAnime(String query) async {
|
||||
searches.add(query);
|
||||
return query.toLowerCase().contains('thousand') ? const [_tybw] : const [];
|
||||
}
|
||||
|
||||
@override
|
||||
Future<AniListEntry?> anime(int id) async => null;
|
||||
}
|
||||
|
||||
void touch(String path) => File(path)
|
||||
..createSync(recursive: true)
|
||||
@@ -56,6 +87,7 @@ Future<void> pumpPage(
|
||||
Future<String?> Function(String path)? onPlay,
|
||||
ThumbnailLoader? thumbnails,
|
||||
Future<String?> Function()? pickFolder,
|
||||
String? nowPlayingPath,
|
||||
}) async {
|
||||
tester.view.physicalSize = size;
|
||||
tester.view.devicePixelRatio = 1;
|
||||
@@ -69,45 +101,62 @@ Future<void> pumpPage(
|
||||
thumbnails: thumbnails ?? fakeThumb,
|
||||
scan: inlineScan,
|
||||
store: store,
|
||||
matcher: matcher,
|
||||
pickFolder: pickFolder,
|
||||
nowPlayingPath: nowPlayingPath,
|
||||
),
|
||||
),
|
||||
)));
|
||||
await frames(tester);
|
||||
}
|
||||
|
||||
Future<void> openFolder(WidgetTester tester, String name) async {
|
||||
Future<void> openPlace(WidgetTester tester, String name) async {
|
||||
await tester.tap(find.text(name).first);
|
||||
await frames(tester);
|
||||
}
|
||||
|
||||
/// Series start collapsed: a click on the header lists the episodes.
|
||||
Future<void> openSeries(WidgetTester tester, [String title = 'Show']) async {
|
||||
await tester.tap(find.descendant(of: find.byType(SeriesHeader), matching: find.text(title)));
|
||||
/// Scrolls the main list until [f] is on screen.
|
||||
Future<void> reveal(WidgetTester tester, Finder f) async {
|
||||
await tester.scrollUntilVisible(f, 150,
|
||||
scrollable: find.descendant(of: find.byType(CustomScrollView), matching: find.byType(Scrollable)).first);
|
||||
await frames(tester, 3);
|
||||
}
|
||||
|
||||
/// A show's card in the library opens its page.
|
||||
Future<void> openShow(WidgetTester tester, String title) async {
|
||||
final card = find.descendant(of: find.byType(PosterCard), matching: find.text(title));
|
||||
await reveal(tester, card);
|
||||
await tester.tap(card);
|
||||
await frames(tester);
|
||||
}
|
||||
|
||||
void main() {
|
||||
setUp(() async {
|
||||
tmp = Directory.systemTemp.createTempSync('files_page_');
|
||||
anime = '${tmp.path}/anime';
|
||||
for (final f in [
|
||||
'[Grp] Show - 01.mkv',
|
||||
'[Grp] Show - 02.mkv',
|
||||
'[Grp] Show - 03.mkv',
|
||||
'[Grp] Show - NCOP1.mkv',
|
||||
'[Grp] Show - 01.srt',
|
||||
'Some Movie (2019).mkv',
|
||||
'notes.txt',
|
||||
'extras/cover.jpg',
|
||||
]) {
|
||||
touch('$anime/$f');
|
||||
tmp = Directory(_tmp).createTempSync('files_page_');
|
||||
anime = '${tmp.path}/Binux';
|
||||
for (final j in ['App', 'Audio', 'Video']) {
|
||||
touch('$anime/$b1/$j#D4aIyEEtZEWR9gBorzBWiMh_.lnk');
|
||||
}
|
||||
for (final n in [56, 57, 58, 59, 60, 61, 62, 63, 64, 65, 108, 109, 110, 111, 112, 113]) {
|
||||
touch(bleach(n));
|
||||
}
|
||||
for (var n = 1; n <= 3; n++) {
|
||||
touch('$anime/$sao/[Judas] Sword Art Online S3 - 0$n.mkv');
|
||||
}
|
||||
for (final x in ['Opening 1', 'Ending 1']) {
|
||||
touch('$anime/$sao/[Judas] Sword Art Online S3 - $x.mkv');
|
||||
}
|
||||
touch('$anime/$sao/[Judas] Sword Art Online S3 - 01.srt');
|
||||
touch('$anime/[Judas] Re.Zero - S04E17.mkv');
|
||||
touch('$anime/$kimetsu');
|
||||
touch('$anime/notes.txt');
|
||||
// An in-memory box: the store loads and writes through it, with no
|
||||
// file I/O left pending in the widget tests' fake-async zone.
|
||||
await Hive.openBox(filesBoxName, bytes: Uint8List(0));
|
||||
store = FolderStore();
|
||||
await store.ensureLoaded();
|
||||
aniList = FakeAniList();
|
||||
matcher = ShowMatcher(store: MemoryLibraryStore(), aniList: aniList, delay: (_) async {});
|
||||
thumbRequests = [];
|
||||
scans = 0;
|
||||
resetFilesPageSession();
|
||||
@@ -118,102 +167,145 @@ void main() {
|
||||
tmp.deleteSync(recursive: true);
|
||||
});
|
||||
|
||||
testWidgets('sidebar names folders by their last segment, header follows', (tester) async {
|
||||
store.addFolder('$anime/');
|
||||
await pumpPage(tester, pickFolder: () async => '${tmp.path}/movies');
|
||||
expect(find.text('Folders'), findsOneWidget);
|
||||
expect(find.text('anime'), findsOneWidget);
|
||||
expect(find.text('Recent'), findsNWidgets(2)); // sidebar + header
|
||||
testWidgets('the Library first: empty until a folder is added, then its shows', (tester) async {
|
||||
await pumpPage(tester, pickFolder: () async => anime);
|
||||
expect(find.text('Library'), findsNWidgets(2)); // sidebar + header
|
||||
expect(find.text('Your library is empty'), findsOneWidget);
|
||||
await tester.tap(find.text('Add folder').first);
|
||||
await frames(tester);
|
||||
expect(store.folders.value, [anime]);
|
||||
expect(find.text('Binux'), findsNWidgets(2)); // sidebar + header
|
||||
expect(scans, 1);
|
||||
// Back to the Library: the same scan, now grouped into shows.
|
||||
await openPlace(tester, 'Library');
|
||||
expect(scans, 1);
|
||||
expect(find.text('3 shows'), findsOneWidget); // sidebar
|
||||
expect(find.byType(PosterCard), findsNWidgets(4));
|
||||
expect(find.text('Shows'), findsOneWidget);
|
||||
expect(find.text('Movies'), findsOneWidget);
|
||||
|
||||
await openPlace(tester, 'Recent');
|
||||
expect(find.text('Nothing played yet'), findsOneWidget);
|
||||
expect(scans, 0, reason: 'folders are scanned when opened');
|
||||
|
||||
await openFolder(tester, 'anime');
|
||||
expect(find.text('anime'), findsNWidgets(2)); // sidebar + header
|
||||
expect(find.text('$anime/'), findsOneWidget); // full path under it
|
||||
expect(find.text('Recent'), findsOneWidget);
|
||||
expect(find.text('5 videos'), findsOneWidget); // sidebar subtitle
|
||||
expect(scans, 1);
|
||||
|
||||
// Back and forth: the scan is kept.
|
||||
await openFolder(tester, 'Recent');
|
||||
await openFolder(tester, 'anime');
|
||||
expect(scans, 1);
|
||||
|
||||
// Add folder: picked, stored, opened.
|
||||
Directory('${tmp.path}/movies').createSync();
|
||||
await tester.tap(find.text('Add folder'));
|
||||
await tester.tap(find.byTooltip('Remove folder'));
|
||||
await frames(tester);
|
||||
expect(store.folders.value, ['$anime/', '${tmp.path}/movies']);
|
||||
expect(find.text('movies'), findsNWidgets(2));
|
||||
expect(find.text('No videos here'), findsOneWidget);
|
||||
|
||||
await tester.tap(find.byTooltip('Remove folder').last);
|
||||
await frames(tester);
|
||||
expect(store.folders.value, ['$anime/']);
|
||||
expect(find.text('Recent'), findsNWidgets(2));
|
||||
expect(store.folders.value, isEmpty);
|
||||
});
|
||||
|
||||
testWidgets('episodes group into series, the rest is a card grid', (tester) async {
|
||||
testWidgets('shows, a movie, counts and what is hidden', (tester) async {
|
||||
store.addFolder(anime);
|
||||
await pumpPage(tester);
|
||||
await openFolder(tester, 'anime');
|
||||
expect(find.byType(SeriesHeader), findsOneWidget);
|
||||
expect(find.text('Show'), findsOneWidget);
|
||||
expect(find.text('3 episodes · 1 extra · Grp'), findsOneWidget);
|
||||
// Collapsed until clicked; open for the rest of the session.
|
||||
expect(find.text('E01'), findsNothing);
|
||||
await openSeries(tester);
|
||||
for (final l in ['E01', 'E02', 'E03', 'NCOP1']) {
|
||||
expect(find.text(l), findsOneWidget);
|
||||
for (final t in ['Bleach', 'Re.Zero', 'Sword Art Online']) {
|
||||
expect(find.descendant(of: find.byType(PosterCard), matching: find.text(t)), findsOneWidget);
|
||||
}
|
||||
expect(find.text('[Grp] Show - 02.mkv'), findsOneWidget);
|
||||
expect(find.byType(VideoCard), findsOneWidget);
|
||||
expect(find.text('Some Movie (2019)'), findsOneWidget);
|
||||
expect(find.text('Other videos'), findsOneWidget);
|
||||
// Durations come from the thumbnail.
|
||||
expect(find.text('24:00 · 1 KB'), findsNWidgets(4));
|
||||
expect(find.text('24:00'), findsOneWidget); // card badge
|
||||
expect(find.text('16 episodes'), findsOneWidget); // Bleach
|
||||
expect(find.text('Kimetsu no Yaiba - Mugenjou Hen'), findsOneWidget);
|
||||
expect(find.text('Movie 1 · 1 KB'), findsOneWidget);
|
||||
expect(find.text('23 videos in 3 shows · 4 other files hidden · 1 subtitle file'), findsOneWidget);
|
||||
expect(find.byType(VideoCard), findsNothing, reason: 'nothing is left over');
|
||||
|
||||
await openFolder(tester, 'Recent');
|
||||
await openFolder(tester, 'anime');
|
||||
expect(find.text('E01'), findsOneWidget);
|
||||
await openSeries(tester);
|
||||
expect(find.text('E01'), findsNothing);
|
||||
expect(find.byType(VideoCard), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('hidden files are counted and some can be shown', (tester) async {
|
||||
store.addFolder(anime);
|
||||
await pumpPage(tester);
|
||||
await openFolder(tester, 'anime');
|
||||
expect(find.text('5 videos in 1 series · 2 other files hidden · 1 subtitle file'),
|
||||
findsOneWidget);
|
||||
expect(find.text('notes.txt'), findsNothing);
|
||||
// The folder view says which files were left out.
|
||||
await openPlace(tester, 'Binux');
|
||||
await tester.tap(find.text('Show some'));
|
||||
await frames(tester);
|
||||
expect(find.text('notes.txt'), findsOneWidget);
|
||||
expect(find.text('cover.jpg'), findsOneWidget);
|
||||
expect(find.text('not a video'), findsNWidgets(2));
|
||||
await tester.tap(find.text('Hide them'));
|
||||
await frames(tester);
|
||||
expect(find.text('notes.txt'), findsNothing);
|
||||
expect(find.text('App#D4aIyEEtZEWR9gBorzBWiMh_.lnk'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('rescan picks up new files', (tester) async {
|
||||
testWidgets('a show page: season with its name, episodes, extras', (tester) async {
|
||||
store.addFolder(anime);
|
||||
await pumpPage(tester);
|
||||
await openFolder(tester, 'anime');
|
||||
await openSeries(tester);
|
||||
touch('$anime/[Grp] Show - 04.mkv');
|
||||
expect(find.text('E04'), findsNothing);
|
||||
await tester.tap(find.text('Rescan'));
|
||||
await openShow(tester, 'Sword Art Online');
|
||||
expect(find.text('Season 3 · Alicization'), findsOneWidget);
|
||||
for (final e in ['Episode 1', 'Episode 2', 'Episode 3']) {
|
||||
expect(find.text(e), findsOneWidget);
|
||||
}
|
||||
expect(find.text('Extras'), findsOneWidget);
|
||||
expect(find.text('OP1'), findsOneWidget);
|
||||
expect(find.text('ED1'), findsOneWidget);
|
||||
expect(find.text('Play: S3 E1'), findsOneWidget);
|
||||
expect(find.text('Not matched on AniList'), findsOneWidget);
|
||||
// The open show stays open for the session; Back returns.
|
||||
await openPlace(tester, 'Recent');
|
||||
await openPlace(tester, 'Library');
|
||||
expect(find.text('Season 3 · Alicization'), findsOneWidget);
|
||||
await tester.tap(find.text('Library').last);
|
||||
await frames(tester);
|
||||
expect(scans, 2);
|
||||
expect(find.text('E04'), findsOneWidget);
|
||||
expect(find.text('4 episodes · 1 extra · Grp'), findsOneWidget);
|
||||
expect(find.byType(PosterCard), findsNWidgets(4));
|
||||
});
|
||||
|
||||
testWidgets('a play goes to onPlay and into Recent', (tester) async {
|
||||
testWidgets('next episode knows what is missing: start at 56, then skip to 108', (tester) async {
|
||||
store.addFolder(anime);
|
||||
final played = <String>[];
|
||||
await pumpPage(tester, onPlay: (p) async {
|
||||
played.add(p);
|
||||
return null;
|
||||
});
|
||||
await openShow(tester, 'Bleach');
|
||||
expect(find.text('1–55 aren\'t in your folders — start at 56?'), findsOneWidget);
|
||||
await tester.tap(find.text('Start at 56'));
|
||||
await frames(tester);
|
||||
expect(played, [bleach(56)]);
|
||||
await reveal(tester, find.text('66–107 aren\'t in your folders')); // in the list
|
||||
|
||||
// Watched up to 65: the next one is 108, and the page says why.
|
||||
await reveal(tester, find.text('Episode 65'));
|
||||
await tester.tap(find.text('Episode 65'));
|
||||
await frames(tester);
|
||||
expect(store.recent.value.first, bleach(65));
|
||||
await tester.drag(find.byType(CustomScrollView), const Offset(0, 3000));
|
||||
await frames(tester, 3);
|
||||
expect(find.text('66–107 aren\'t in your folders — skip to 108?'), findsOneWidget);
|
||||
await tester.tap(find.text('Skip to 108'));
|
||||
await frames(tester);
|
||||
expect(played.last, bleach(108));
|
||||
expect(find.text('Next: 109'), findsOneWidget);
|
||||
|
||||
// The card says it too.
|
||||
await tester.tap(find.text('Library').last);
|
||||
await frames(tester);
|
||||
expect(find.text('Next: 109'), findsOneWidget);
|
||||
|
||||
// Recent names the show and episode.
|
||||
await openPlace(tester, 'Recent');
|
||||
expect(find.text('Bleach · E108'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('Wrong show? pins the right AniList entry and the names follow', (tester) async {
|
||||
store.addFolder(anime);
|
||||
await pumpPage(tester);
|
||||
await openShow(tester, 'Bleach');
|
||||
await tester.tap(find.text('Wrong show?'));
|
||||
await frames(tester);
|
||||
await tester.enterText(find.byType(TextField), 'Bleach Thousand-Year Blood War');
|
||||
await tester.testTextInput.receiveAction(TextInputAction.done);
|
||||
await frames(tester);
|
||||
expect(aniList.searches.last, 'Bleach Thousand-Year Blood War');
|
||||
await tester.tap(find.text('Bleach: Thousand-Year Blood War'));
|
||||
await frames(tester);
|
||||
expect(matcher.showPin('bleach'), 41467);
|
||||
expect(find.text('Bleach: Thousand-Year Blood War'), findsOneWidget); // the page's title
|
||||
expect(find.text('pinned'), findsOneWidget);
|
||||
expect(find.textContaining('files say "Bleach"'), findsOneWidget);
|
||||
await tester.tap(find.text('Library').last);
|
||||
await frames(tester);
|
||||
expect(find.descendant(of: find.byType(PosterCard), matching: find.text('Bleach: Thousand-Year Blood War')),
|
||||
findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('episode numbers: the first file is episode N', (tester) async {
|
||||
store.addFolder(anime);
|
||||
await pumpPage(tester);
|
||||
await openShow(tester, 'Sword Art Online');
|
||||
await tester.tap(find.text('Episode numbers…'));
|
||||
await frames(tester);
|
||||
await tester.enterText(find.byKey(const ValueKey('offset:3')), '25');
|
||||
await tester.tap(find.text('Save'));
|
||||
await frames(tester);
|
||||
expect(matcher.offset('sword art online', 3), 24);
|
||||
expect(find.text('Episode 25'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('a play goes to onPlay and into Recent; failures are shown, not kept', (tester) async {
|
||||
store.addFolder(anime);
|
||||
final played = <String>[];
|
||||
String? reply;
|
||||
@@ -222,66 +314,39 @@ void main() {
|
||||
return reply;
|
||||
});
|
||||
expect(find.textContaining('Keep the app open while it plays'), findsOneWidget);
|
||||
await openFolder(tester, 'anime');
|
||||
await openSeries(tester);
|
||||
|
||||
await tester.tap(find.text('E02'));
|
||||
await frames(tester);
|
||||
expect(played, ['$anime/[Grp] Show - 02.mkv']);
|
||||
expect(store.recent.value, ['$anime/[Grp] Show - 02.mkv']);
|
||||
|
||||
reply = 'Nobody can play mkv here';
|
||||
await tester.tap(find.text('Some Movie (2019)'));
|
||||
await reveal(tester, find.text('Kimetsu no Yaiba - Mugenjou Hen'));
|
||||
await tester.tap(find.text('Kimetsu no Yaiba - Mugenjou Hen'));
|
||||
await frames(tester);
|
||||
expect(played, hasLength(2));
|
||||
await tester.drag(find.byType(CustomScrollView), const Offset(0, 3000));
|
||||
await frames(tester);
|
||||
expect(played, ['$anime/$kimetsu']);
|
||||
expect(find.text('Nobody can play mkv here'), findsOneWidget);
|
||||
expect(store.recent.value, hasLength(1), reason: 'failed plays are not recent');
|
||||
|
||||
await openFolder(tester, 'Recent');
|
||||
expect(find.text('Show · E02'), findsOneWidget);
|
||||
expect(find.text('[Grp] Show - 02.mkv'), findsOneWidget);
|
||||
expect(store.recent.value, isEmpty);
|
||||
});
|
||||
|
||||
testWidgets('now playing is marked', (tester) async {
|
||||
testWidgets('now playing is marked on its show and row', (tester) async {
|
||||
store.addFolder(anime);
|
||||
tester.view.physicalSize = const Size(1280, 800);
|
||||
tester.view.devicePixelRatio = 1;
|
||||
addTearDown(tester.view.reset);
|
||||
await tester.pumpWidget(scoped(_graph, MaterialApp(
|
||||
home: Scaffold(
|
||||
body: FilesPage(
|
||||
relayed: false,
|
||||
onPlay: (_) async => null,
|
||||
nowPlayingPath: '$anime/[Grp] Show - 03.mkv',
|
||||
thumbnails: fakeThumb,
|
||||
scan: inlineScan,
|
||||
store: store,
|
||||
),
|
||||
),
|
||||
)));
|
||||
await frames(tester);
|
||||
await openFolder(tester, 'anime');
|
||||
await openSeries(tester);
|
||||
await pumpPage(tester, nowPlayingPath: '$anime/$sao/[Judas] Sword Art Online S3 - 02.mkv');
|
||||
await openShow(tester, 'Sword Art Online');
|
||||
expect(find.text('now playing'), findsOneWidget);
|
||||
expect(find.byIcon(Icons.graphic_eq), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('narrow: folders become chips above the list', (tester) async {
|
||||
testWidgets('narrow: places become chips above the list', (tester) async {
|
||||
store.addFolder('$anime/');
|
||||
await pumpPage(tester, size: const Size(600, 800));
|
||||
expect(find.text('Folders'), findsNothing);
|
||||
expect(find.widgetWithText(InputChip, 'anime'), findsOneWidget);
|
||||
expect(find.widgetWithText(ChoiceChip, 'Library'), findsOneWidget);
|
||||
expect(find.widgetWithText(ChoiceChip, 'Recent'), findsOneWidget);
|
||||
await tester.tap(find.widgetWithText(InputChip, 'anime'));
|
||||
await frames(tester);
|
||||
expect(find.text('$anime/'), findsOneWidget);
|
||||
await openSeries(tester);
|
||||
expect(find.text('E01'), findsOneWidget);
|
||||
// The chip's x removes the folder, like the sidebar's.
|
||||
expect(find.widgetWithText(InputChip, 'Binux'), findsOneWidget);
|
||||
await openShow(tester, 'Re.Zero');
|
||||
expect(find.text('Season 4'), findsOneWidget);
|
||||
expect(find.text('16 isn\'t in your folders'), findsNothing);
|
||||
expect(find.text('1–16 aren\'t in your folders — start at 17?'), findsOneWidget);
|
||||
await tester.tap(find.byTooltip('Remove folder'));
|
||||
await frames(tester);
|
||||
expect(store.folders.value, isEmpty);
|
||||
expect(find.widgetWithText(InputChip, 'anime'), findsNothing);
|
||||
expect(tester.takeException(), isNull);
|
||||
});
|
||||
|
||||
@@ -289,53 +354,52 @@ void main() {
|
||||
testWidgets('no overflow at ${size.width.toInt()}x${size.height.toInt()}', (tester) async {
|
||||
store.addFolder(anime);
|
||||
for (var i = 0; i < 7; i++) {
|
||||
touch('$anime/A Movie With A Rather Long Title Indeed Part $i (2020).mkv');
|
||||
touch('$anime/clips/A Clip With A Rather Long Title Indeed Part $i.mkv');
|
||||
}
|
||||
store.addRecent('$anime/[Grp] Show - 01.mkv');
|
||||
store.addRecent(bleach(56));
|
||||
await pumpPage(tester, size: size, relayed: true);
|
||||
expect(tester.takeException(), isNull);
|
||||
await tester.tap(find.text('anime').first);
|
||||
await tester.drag(find.byType(CustomScrollView), const Offset(0, -2000));
|
||||
await frames(tester);
|
||||
await openSeries(tester);
|
||||
expect(find.text('E01'), findsOneWidget);
|
||||
expect(tester.takeException(), isNull);
|
||||
await tester.tap(find.text('Show some'));
|
||||
await tester.drag(find.byType(CustomScrollView), const Offset(0, 4000));
|
||||
await frames(tester);
|
||||
await openShow(tester, 'Bleach');
|
||||
await reveal(tester, find.text('Episode 56'));
|
||||
expect(tester.takeException(), isNull);
|
||||
await tester.drag(find.byType(CustomScrollView), const Offset(0, -2000));
|
||||
await frames(tester);
|
||||
expect(tester.takeException(), isNull);
|
||||
});
|
||||
}
|
||||
|
||||
testWidgets('thumbnails load for visible tiles only, and cancel when scrolled away',
|
||||
(tester) async {
|
||||
testWidgets('thumbnails load for visible rows only, and cancel when scrolled away', (tester) async {
|
||||
final big = '${tmp.path}/big';
|
||||
for (var i = 1; i <= 120; i++) {
|
||||
touch('$big/Long Show - ${i.toString().padLeft(3, '0')}.mkv');
|
||||
}
|
||||
store.addFolder(big);
|
||||
final gates = <String, bool Function()>{};
|
||||
await pumpPage(tester, size: const Size(1100, 700),
|
||||
thumbnails: (path, {cancelled}) {
|
||||
await pumpPage(tester, size: const Size(1100, 700), thumbnails: (path, {cancelled}) {
|
||||
thumbRequests.add(path);
|
||||
gates[path] = cancelled!;
|
||||
return Completer<Thumbnail?>().future; // never finishes
|
||||
});
|
||||
await openFolder(tester, 'big');
|
||||
await openSeries(tester, 'Long Show');
|
||||
expect(find.text('E01'), findsOneWidget);
|
||||
await openShow(tester, 'Long Show');
|
||||
expect(find.text('Episode 1'), findsOneWidget);
|
||||
final first = thumbRequests.toSet();
|
||||
expect(first, isNotEmpty);
|
||||
expect(first.length, lessThan(30));
|
||||
expect(first, contains('$big/Long Show - 001.mkv'));
|
||||
expect(gates['$big/Long Show - 001.mkv']!(), isFalse);
|
||||
// (Episode 1's frame is also the header's banner, which stays.)
|
||||
expect(first, contains('$big/Long Show - 002.mkv'));
|
||||
expect(gates['$big/Long Show - 002.mkv']!(), isFalse);
|
||||
|
||||
// Fly past the middle: tiles that only flash by never ask.
|
||||
// Fly past the middle: rows that only flash by never ask.
|
||||
await tester.drag(find.byType(CustomScrollView), const Offset(0, -6000));
|
||||
await tester.pump();
|
||||
await tester.drag(find.byType(CustomScrollView), const Offset(0, -6000));
|
||||
await frames(tester);
|
||||
expect(gates['$big/Long Show - 001.mkv']!(), isTrue);
|
||||
expect(gates['$big/Long Show - 002.mkv']!(), isTrue);
|
||||
expect(thumbRequests, contains('$big/Long Show - 120.mkv'));
|
||||
expect(thumbRequests.toSet().length, lessThan(60));
|
||||
});
|
||||
|
||||
@@ -1,10 +1,14 @@
|
||||
// restructure: rewrite in step 4 (Library + Media identity): groupBySeries
|
||||
// becomes L1 local grouping; the scan and classify tests stay.
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:starsync/src/files/name_parser.dart';
|
||||
import 'package:starsync/src/files/scanner.dart';
|
||||
import 'package:sync_relay_core/core.dart' show groupLocal, readLocalFile, EpisodeGap, LocalKind;
|
||||
|
||||
// The Files scan on a real (generated) tree, and the library's L1
|
||||
// grouping over what it found (core's own tests cover the grouping
|
||||
// rules on paths alone).
|
||||
|
||||
final _tmp = Platform.environment['WT_TEST_TMP'] ?? Directory.systemTemp.path;
|
||||
|
||||
late Directory _root;
|
||||
|
||||
@@ -19,11 +23,8 @@ void _file(String rel, {int size = 10}) {
|
||||
}
|
||||
}
|
||||
|
||||
VideoFile _v(String name) =>
|
||||
VideoFile('/x/$name', 1, parseReleaseName(name), DateTime(2020));
|
||||
|
||||
void main() {
|
||||
setUp(() => _root = Directory.systemTemp.createTempSync('files_scan_'));
|
||||
setUp(() => _root = Directory(_tmp).createTempSync('files_scan_'));
|
||||
tearDown(() => _root.deleteSync(recursive: true));
|
||||
|
||||
test('classifies, skips hidden and samples, pairs sidecar subtitles',
|
||||
@@ -69,7 +70,7 @@ void main() {
|
||||
isFalse);
|
||||
|
||||
final ep = scan.videos.firstWhere((v) => v.name.contains('- 02'));
|
||||
expect(ep.parsed.episode, 2);
|
||||
expect(readLocalFile(ep).episode, 2);
|
||||
expect(ep.size, 10);
|
||||
expect(ep.modified.year, DateTime.now().year);
|
||||
});
|
||||
@@ -115,38 +116,45 @@ void main() {
|
||||
expect(kindOf('mkv'), FileKind.other);
|
||||
});
|
||||
|
||||
group('groupBySeries', () {
|
||||
test('groups across spellings, sorts by season then episode', () {
|
||||
final g = groupBySeries([
|
||||
_v('[A] Re:Zero S2 - 02 [1080p].mkv'),
|
||||
_v('[A] Re:Zero - 10 [1080p].mkv'),
|
||||
_v('[B] Re Zero - 02 [720p].mkv'),
|
||||
_v('[A] Re:Zero S2 - 01 [1080p].mkv'),
|
||||
_v('[A] Re:Zero - NCOP1 [1080p].mkv'),
|
||||
_v('Other Show - 03.mkv'),
|
||||
_v('Other Show - 01.mkv'),
|
||||
_v('Movie.2020.1080p.mkv'),
|
||||
_v('vid_2024_final.mp4'),
|
||||
_v('[A] Lonely - OVA.mkv'),
|
||||
]);
|
||||
expect(g.series.map((s) => s.title), ['Other Show', 'Re:Zero']);
|
||||
final re = g.series[1];
|
||||
expect(re.episodes.map((v) => v.parsed.label),
|
||||
['E02', 'E10', 'S02E01', 'S02E02']);
|
||||
expect(re.seasons, [1, 2]);
|
||||
expect(re.extras.single.parsed.extra, 'NCOP1');
|
||||
expect(g.series[0].episodes.map((v) => v.parsed.episode), [1, 3]);
|
||||
expect(g.loose.map((v) => v.name), [
|
||||
'[A] Lonely - OVA.mkv',
|
||||
'Movie.2020.1080p.mkv',
|
||||
'vid_2024_final.mp4',
|
||||
]);
|
||||
});
|
||||
test('a generated tree of the Binux names, scanned and grouped (L1)', () async {
|
||||
const b1 = '[Judas] Bleach 001-055 [BD 1080p][HEVC x265 10bit]';
|
||||
const b2 = '[Judas] Bleach 056-111 [BD 1080p][HEVC x265 10bit]';
|
||||
const b3 = '[Judas] Bleach 112-139 [BD 1080p][HEVC x265 10bit][Dual-Audio][Eng-Subs]';
|
||||
const sao = '[Judas] Sword Art Online (Season 3 - Alicization) [BD 1080p][HEVC x265 10bit][Eng-Subs]';
|
||||
String n3(int n) => n.toString().padLeft(3, '0');
|
||||
// The first Bleach folder holds 1-byte shortcuts only.
|
||||
for (final j in ['Amine', 'App', 'Audio', 'Data', 'Document', 'Download', 'Image', 'Internal storage', 'Recent', 'Video']) {
|
||||
_file('$b1/$j#D4aIyEEtZEWR9gBorzBWiMh_.lnk', size: 1);
|
||||
}
|
||||
for (final n in [56, 57, 58, 59, 60, 61, 62, 63, 64, 65, 108, 109, 110, 111]) {
|
||||
_file('$b2/[Judas] Bleach - ${n3(n)}.mkv');
|
||||
}
|
||||
for (var n = 112; n <= 139; n++) {
|
||||
_file('$b3/[Judas] Bleach - ${n3(n)}.mkv');
|
||||
}
|
||||
for (var n = 1; n <= 24; n++) {
|
||||
_file('$sao/[Judas] Sword Art Online S3 - ${n.toString().padLeft(2, '0')}.mkv');
|
||||
}
|
||||
for (final x in ['Opening 1', 'Opening 2', 'Opening 3', 'Ending 1', 'Ending 2']) {
|
||||
_file('$sao/[Judas] Sword Art Online S3 - $x.mkv');
|
||||
}
|
||||
_file('[Judas] Re.Zero - S04E17.mkv');
|
||||
_file('[Erai-raws] Kimetsu no Yaiba - Mugenjou Hen - Movie 01 [1080p CR WEBRip HEVC AAC][MultiSub][8E47462C].mkv');
|
||||
|
||||
test('empty input', () {
|
||||
final g = groupBySeries(const []);
|
||||
expect(g.series, isEmpty);
|
||||
expect(g.loose, isEmpty);
|
||||
});
|
||||
final scan = await scanFolder(_root.path);
|
||||
expect(scan.videos, hasLength(14 + 28 + 24 + 5 + 2));
|
||||
expect(scan.hiddenCount, 10, reason: 'the shortcuts are not videos');
|
||||
final lib = groupLocal(scan.videos, roots: [_root.path]);
|
||||
expect(lib.shows.map((s) => s.title), ['Bleach', 'Re.Zero', 'Sword Art Online']);
|
||||
final bleach = lib.show('bleach')!;
|
||||
expect(bleach.folders, [_p(b2), _p(b3)]);
|
||||
expect(bleach.seasons.single.gaps, const [EpisodeGap(1, 55), EpisodeGap(66, 107)]);
|
||||
final s = lib.show('sword art online')!;
|
||||
expect([s.seasons.single.number, s.seasons.single.name], [3, 'Alicization']);
|
||||
expect(s.extras.map((e) => e.label), ['OP1', 'OP2', 'OP3', 'ED1', 'ED2']);
|
||||
expect(lib.show('re zero')!.seasons.single.number, 4);
|
||||
expect(lib.movies.single.kind, LocalKind.movie);
|
||||
expect(lib.movies.single.title, 'Kimetsu no Yaiba - Mugenjou Hen');
|
||||
expect(lib.other, isEmpty);
|
||||
});
|
||||
}
|
||||
@@ -193,9 +193,10 @@ void main() {
|
||||
expect(plain.name, 'movie.mkv');
|
||||
});
|
||||
|
||||
test('relay credential: the account token with exactly its own device fields', () async {
|
||||
test('relay credentials: each account token with exactly its own device fields', () async {
|
||||
final (accounts, _) = await linkedAccounts();
|
||||
final cred = relayCredentialFor(accounts, serverId: fixtureServerId)!;
|
||||
final creds = relayCredentialsFor(accounts);
|
||||
final cred = creds.single;
|
||||
final info = accounts.clientInfo;
|
||||
expect(cred.token, 'TEST_ACCESS_TOKEN');
|
||||
expect(cred.serverId, fixtureServerId);
|
||||
@@ -203,13 +204,11 @@ void main() {
|
||||
expect([cred.client, cred.device, cred.deviceId, cred.version],
|
||||
[info.client, info.device, info.deviceId, info.version]);
|
||||
expect(cred.device, 'Test PC');
|
||||
// Another server's item: the default account still relays its own.
|
||||
expect(relayCredentialFor(accounts, serverId: 'other')!.serverId, fixtureServerId);
|
||||
accounts.markExpired(fixtureServerId);
|
||||
expect(relayCredentialFor(accounts, serverId: fixtureServerId), isNull);
|
||||
expect(relayCredentialsFor(accounts), isEmpty);
|
||||
final none = JellyfinAccounts(store: MemoryJellyfinStore());
|
||||
await none.ensureLoaded(validate: false);
|
||||
expect(relayCredentialFor(none), isNull);
|
||||
expect(relayCredentialsFor(none), isEmpty);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:starsync/src/library/media_refs.dart';
|
||||
import 'package:starsync/src/session/now_playing_info.dart';
|
||||
import 'package:sync_relay_core/core.dart';
|
||||
|
||||
// `media.ref` producers other than the library (Jellyfin items, links)
|
||||
// and what receivers make of a ref.
|
||||
|
||||
void main() {
|
||||
group('Jellyfin', () {
|
||||
final series = JellyfinItem.fromJson({
|
||||
'Id': 's',
|
||||
'Name': 'Frieren',
|
||||
'Type': 'Series',
|
||||
'ProviderIds': {'AniList': '154587', 'Tvdb': '424536', 'Imdb': 'tt22248376'},
|
||||
});
|
||||
final episode = JellyfinItem.fromJson({
|
||||
'Id': 'e',
|
||||
'Name': 'The Journey\'s End',
|
||||
'Type': 'Episode',
|
||||
'SeriesName': 'Frieren',
|
||||
'ParentIndexNumber': 1,
|
||||
'IndexNumber': 1,
|
||||
'ProviderIds': {'Tvdb': '9999999'},
|
||||
});
|
||||
|
||||
test('provider ids: the show\'s for an episode, never the episode\'s own', () {
|
||||
expect(series.providerId('anilist'), 154587);
|
||||
expect(jellyfinMediaRef(episode, series: series),
|
||||
const MediaRef(kind: 'episode', title: 'Frieren', anilistId: 154587, tvdbId: 424536, season: 1, ep: 1));
|
||||
expect(jellyfinMediaRef(episode),
|
||||
const MediaRef(kind: 'episode', title: 'Frieren', season: 1, ep: 1));
|
||||
});
|
||||
|
||||
test('movies carry their own ids; specials are extras; folders none', () {
|
||||
final movie = JellyfinItem.fromJson(
|
||||
{'Id': 'm', 'Name': 'Suzume', 'Type': 'Movie', 'ProviderIds': {'AniList': '142770'}});
|
||||
expect(jellyfinMediaRef(movie), const MediaRef(kind: 'movie', title: 'Suzume', anilistId: 142770));
|
||||
final special = JellyfinItem.fromJson(
|
||||
{'Id': 'x', 'Name': 'OVA', 'Type': 'Episode', 'SeriesName': 'Frieren', 'ParentIndexNumber': 0, 'IndexNumber': 1});
|
||||
expect(jellyfinMediaRef(special)!.kind, 'extra');
|
||||
expect(jellyfinMediaRef(series), isNull);
|
||||
});
|
||||
|
||||
test('from a pick\'s lines alone', () {
|
||||
expect(jellyfinPickRef('The Pilot', 'Show · S2E6'), const MediaRef(kind: 'episode', title: 'Show', season: 2, ep: 6));
|
||||
expect(jellyfinPickRef('Movie', '2019'), const MediaRef(kind: 'movie', title: 'Movie'));
|
||||
});
|
||||
});
|
||||
|
||||
test('links: kind and title from the name, best effort', () {
|
||||
expect(linkMediaRef('[SubsPlease] Frieren - 05 (1080p).mkv'),
|
||||
const MediaRef(kind: 'episode', title: 'Frieren', absolute: 5));
|
||||
expect(linkMediaRef('Show.S01E03.mkv'), const MediaRef(kind: 'episode', title: 'Show', season: 1, ep: 3));
|
||||
expect(linkMediaRef('A funny cat'), const MediaRef(kind: 'movie', title: 'A funny cat'));
|
||||
});
|
||||
|
||||
group('receivers', () {
|
||||
test('a ref names the show and the episode; art stays by hash', () {
|
||||
final np = nowPlayingOf({
|
||||
'name': '[Judas] Re.Zero - S04E17.mkv',
|
||||
'relay': {'source': 'm2'},
|
||||
'art': {'poster': 'p', 'banner': 'b'},
|
||||
'ref': {'kind': 'episode', 'title': 'Re:ZERO Season 4', 'anilistId': 189046, 'anilistEp': 17, 'season': 4, 'ep': 17},
|
||||
})!;
|
||||
expect([np.title, np.episode, np.posterHash, np.bannerHash], ['Re:ZERO Season 4', 'S4 E17', 'p', 'b']);
|
||||
final abs = nowPlayingOf({
|
||||
'name': '[Judas] Bleach - 108.mkv',
|
||||
'ref': {'kind': 'episode', 'title': 'Bleach', 'anilistId': 269, 'anilistEp': 108, 'absolute': 108},
|
||||
})!;
|
||||
expect([abs.title, abs.episode], ['Bleach', 'E108']);
|
||||
});
|
||||
|
||||
test('extras keep their marker, movies have no episode line, no ref falls back to the name', () {
|
||||
expect(
|
||||
nowPlayingOf({
|
||||
'name': '[Judas] Sword Art Online S3 - Opening 1.mkv',
|
||||
'ref': {'kind': 'extra', 'title': 'Sword Art Online: Alicization', 'anilistId': 100182},
|
||||
})!
|
||||
.episode,
|
||||
'OP1');
|
||||
final movie = nowPlayingOf({
|
||||
'name': 'x.mkv',
|
||||
'ref': {'kind': 'movie', 'title': 'Demon Slayer: Infinity Castle'},
|
||||
})!;
|
||||
expect([movie.title, movie.episode], ['Demon Slayer: Infinity Castle', null]);
|
||||
final plain = nowPlayingOf({'name': '[SubsPlease] Frieren - 05.mkv'})!;
|
||||
expect([plain.title, plain.episode], ['Frieren', 'E5']);
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,301 @@
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:starsync/src/library/library_store.dart';
|
||||
import 'package:starsync/src/library/show_matcher.dart';
|
||||
import 'package:starsync/src/schedule/anilist.dart';
|
||||
import 'package:starsync/src/schedule/animeflow.dart' show AniListRun;
|
||||
import 'package:sync_relay_core/core.dart';
|
||||
|
||||
// L2: local shows (L1 over the Binux names) matched against a fake
|
||||
// AniList: synonyms and season hints, numbering along split entries,
|
||||
// pins and offsets that win, the cache answering offline, spaced requests.
|
||||
|
||||
const root = '/lib/Binux';
|
||||
const b2 = '$root/[Judas] Bleach 056-111 [BD 1080p][HEVC x265 10bit]';
|
||||
const b3 = '$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]';
|
||||
|
||||
const reS1 = 'Re:Zero kara Hajimeru Isekai Seikatsu';
|
||||
const reEn = 'Re:ZERO -Starting Life in Another World-';
|
||||
|
||||
final entries = <AniListEntry>[
|
||||
const AniListEntry(id: 269, romaji: 'BLEACH', english: 'Bleach', format: 'TV', episodes: 366, year: 2004),
|
||||
const AniListEntry(
|
||||
id: 41467, romaji: 'BLEACH: Sennen Kessen-hen', english: 'Bleach: Thousand-Year Blood War', format: 'TV', episodes: 13),
|
||||
const AniListEntry(
|
||||
id: 21355, romaji: reS1, english: reEn, synonyms: ['Re:Zero', 'ReZero'], format: 'TV', episodes: 25, sequel: 108632),
|
||||
const AniListEntry(
|
||||
id: 108632, romaji: '$reS1 2nd Season', english: '$reEn Season 2', format: 'TV', episodes: 13, sequel: 119661),
|
||||
const AniListEntry(
|
||||
id: 119661, romaji: '$reS1 2nd Season Part 2', english: '$reEn Season 2 Part 2', format: 'TV', episodes: 12, sequel: 163134),
|
||||
const AniListEntry(
|
||||
id: 163134, romaji: '$reS1 3rd Season', english: '$reEn Season 3', format: 'TV', episodes: 16, sequel: 189046),
|
||||
const AniListEntry(id: 189046, romaji: '$reS1 4th Season', english: '$reEn Season 4', format: 'TV'),
|
||||
const AniListEntry(id: 11757, romaji: 'Sword Art Online', format: 'TV', episodes: 25, sequel: 20594),
|
||||
const AniListEntry(id: 20594, romaji: 'Sword Art Online II', format: 'TV', episodes: 24),
|
||||
const AniListEntry(
|
||||
id: 100182,
|
||||
romaji: 'Sword Art Online: Alicization',
|
||||
format: 'TV',
|
||||
episodes: 24,
|
||||
sequel: 108759,
|
||||
episodeTitles: {1: 'Underworld'}),
|
||||
const AniListEntry(
|
||||
id: 108759, romaji: 'Sword Art Online: Alicization - War of Underworld', format: 'TV', episodes: 12, sequel: 114308),
|
||||
const AniListEntry(
|
||||
id: 114308, romaji: 'Sword Art Online: Alicization - War of Underworld 2nd Season', format: 'TV', episodes: 11),
|
||||
const AniListEntry(
|
||||
id: 177937,
|
||||
romaji: 'Kimetsu no Yaiba: Mugen Jou-hen',
|
||||
english: 'Demon Slayer: Kimetsu no Yaiba Infinity Castle',
|
||||
format: 'MOVIE',
|
||||
episodes: 1),
|
||||
const AniListEntry(id: 101922, romaji: 'Kimetsu no Yaiba', english: 'Demon Slayer: Kimetsu no Yaiba', format: 'TV', episodes: 26),
|
||||
];
|
||||
|
||||
/// Answers searches by shared words (AniList's own order roughly), and
|
||||
/// ids from [entries]; counts the requests.
|
||||
class FakeAniList implements AniListLookup {
|
||||
final searches = <String>[];
|
||||
final lookups = <int>[];
|
||||
bool offline = false;
|
||||
|
||||
static Set<String> _words(String s) =>
|
||||
normalizeSeries(s).split(' ').where((w) => w.length > 1 && int.tryParse(w) == null && w != 'season').toSet();
|
||||
|
||||
@override
|
||||
Future<List<AniListEntry>> searchAnime(String query) async {
|
||||
if (offline) throw const AniListException('AniList unreachable');
|
||||
searches.add(query);
|
||||
final q = _words(query);
|
||||
final scored = [
|
||||
for (final e in entries)
|
||||
(e, e.names.map((n) => _words(n).intersection(q).length).reduce((a, b) => a > b ? a : b)),
|
||||
].where((p) => p.$2 > 0).toList()
|
||||
..sort((a, b) => b.$2.compareTo(a.$2));
|
||||
return [for (final p in scored.take(8)) p.$1];
|
||||
}
|
||||
|
||||
@override
|
||||
Future<AniListEntry?> anime(int id) async {
|
||||
if (offline) throw const AniListException('AniList unreachable');
|
||||
lookups.add(id);
|
||||
return entries.where((e) => e.id == id).firstOrNull;
|
||||
}
|
||||
}
|
||||
|
||||
class FakeFlow implements FlowLookup {
|
||||
final Map<int, FlowShow> shows;
|
||||
FakeFlow(this.shows);
|
||||
@override
|
||||
Future<FlowShow?> forAniList(int anilistId) async => shows[anilistId];
|
||||
}
|
||||
|
||||
String three(int n) => n.toString().padLeft(3, '0');
|
||||
|
||||
LocalLibrary binux({List<String> extra = const []}) => groupLocal([
|
||||
for (final p in [
|
||||
for (final n in [56, 57, 58, 59, 60, 61, 62, 63, 64, 65, 108, 109, 110, 111]) '$b2/[Judas] Bleach - ${three(n)}.mkv',
|
||||
for (var n = 112; n <= 139; n++) '$b3/[Judas] Bleach - ${three(n)}.mkv',
|
||||
for (var n = 1; n <= 24; n++) '$sao/[Judas] Sword Art Online S3 - ${n.toString().padLeft(2, '0')}.mkv',
|
||||
'$sao/[Judas] Sword Art Online S3 - Opening 1.mkv',
|
||||
'$root/[Judas] Re.Zero - S04E17.mkv',
|
||||
'$root/[Erai-raws] Kimetsu no Yaiba - Mugenjou Hen - Movie 01 [1080p CR WEBRip HEVC AAC][MultiSub][8E47462C].mkv',
|
||||
...extra,
|
||||
])
|
||||
LocalFile(p, 1 << 30, DateTime(2026)),
|
||||
], roots: [root]);
|
||||
|
||||
void main() {
|
||||
late FakeAniList aniList;
|
||||
late MemoryLibraryStore store;
|
||||
late List<Duration> waits;
|
||||
|
||||
ShowMatcher matcher({FlowLookup? flow, AniListLookup? api}) => ShowMatcher(
|
||||
store: store,
|
||||
aniList: api ?? aniList,
|
||||
flow: flow,
|
||||
delay: (d) async => waits.add(d),
|
||||
);
|
||||
|
||||
Future<void> settle(ShowMatcher m) async {
|
||||
for (var i = 0; i < 200 && !m.idle; i++) {
|
||||
await Future<void>.delayed(Duration.zero);
|
||||
}
|
||||
await Future<void>.delayed(Duration.zero);
|
||||
expect(m.idle, isTrue);
|
||||
}
|
||||
|
||||
Future<ShowMatcher> matched(LocalLibrary lib, {FlowLookup? flow}) async {
|
||||
final m = matcher(flow: flow)..remember(lib);
|
||||
for (final s in lib.shows) {
|
||||
m.want(s);
|
||||
}
|
||||
for (final mv in lib.movies) {
|
||||
m.wantMovie(mv);
|
||||
}
|
||||
await settle(m);
|
||||
return m;
|
||||
}
|
||||
|
||||
LocalEpisode ep(LocalLibrary lib, String show, int n, {int? season}) =>
|
||||
lib.show(show)!.episodes.firstWhere((e) => e.episode == n && (season == null || e.season == season));
|
||||
|
||||
setUp(() {
|
||||
aniList = FakeAniList();
|
||||
store = MemoryLibraryStore();
|
||||
waits = [];
|
||||
});
|
||||
|
||||
test('each show and movie finds its AniList entry, names and numbers follow', () async {
|
||||
final lib = binux();
|
||||
final m = await matched(lib);
|
||||
|
||||
// Bleach: one 366-episode entry, absolute numbers as is.
|
||||
final bleach = lib.show('bleach')!;
|
||||
expect(m.titleOf(bleach), 'Bleach');
|
||||
expect(m.refFor(ep(lib, 'bleach', 108)),
|
||||
const MediaRef(kind: 'episode', title: 'Bleach', anilistId: 269, anilistEp: 108, absolute: 108));
|
||||
|
||||
// Re.Zero S04E17: the season hint finds the 4th season among the
|
||||
// synonyms and spellings ("Re.Zero" is "Re:ZERO -Starting Life…").
|
||||
final re = lib.show('re zero')!;
|
||||
expect(m.showMatch(re)!.entry.id, 189046);
|
||||
expect(m.titleOf(re), '$reEn Season 4');
|
||||
expect(m.refFor(re.episodes.single),
|
||||
MediaRef(kind: 'episode', title: '$reEn Season 4', anilistId: 189046, anilistEp: 17, season: 4, ep: 17));
|
||||
|
||||
// SAO: the folder's "(Season 3 - Alicization)" names the entry; its
|
||||
// extras belong to it too; AniList's episode title shows.
|
||||
final s = lib.show('sword art online')!;
|
||||
expect(m.showMatch(s)!.entry.id, 100182);
|
||||
expect(m.target(ep(lib, 'sword art online', 13)), const EpisodeTarget(100182, 13));
|
||||
expect(m.episodeTitle(ep(lib, 'sword art online', 1)), 'Underworld');
|
||||
expect(m.refFor(s.extras.single), const MediaRef(kind: 'extra', title: 'Sword Art Online: Alicization', anilistId: 100182));
|
||||
|
||||
// The movie is the movie entry, not the TV show.
|
||||
final movie = lib.movies.single;
|
||||
expect(m.seasonMatch(movie)!.entry.id, 177937);
|
||||
expect(m.refFor(movie).kind, 'movie');
|
||||
});
|
||||
|
||||
test('past an entry\'s episodes the numbering goes on along its sequels (split cours)', () async {
|
||||
final lib = binux(extra: [
|
||||
'$sao/[Judas] Sword Art Online S3 - 25.mkv',
|
||||
'$sao/[Judas] Sword Art Online S3 - 38.mkv',
|
||||
]);
|
||||
final m = await matched(lib);
|
||||
// 25 is War of Underworld 1 (before that entry is even fetched: its
|
||||
// id comes with Alicization), 38 its second part's 2.
|
||||
expect(m.target(ep(lib, 'sword art online', 25)), const EpisodeTarget(108759, 1));
|
||||
m.target(ep(lib, 'sword art online', 38));
|
||||
await settle(m);
|
||||
expect(m.target(ep(lib, 'sword art online', 38)), const EpisodeTarget(114308, 2));
|
||||
expect(aniList.lookups, containsAll([108759]));
|
||||
});
|
||||
|
||||
test('AnimeFlow\'s anilist_map wins for numbered seasons and gives the TVDB id', () async {
|
||||
final lib = binux();
|
||||
final flow = FakeFlow({
|
||||
100182: const FlowShow([
|
||||
AniListRun(season: 3, anilistId: 100182, firstEpisode: 1, offset: 0),
|
||||
AniListRun(season: 3, anilistId: 108759, firstEpisode: 25, offset: 24),
|
||||
], tvdbId: 259640),
|
||||
});
|
||||
final m = await matched(lib, flow: flow);
|
||||
await settle(m);
|
||||
final r = m.refFor(ep(lib, 'sword art online', 13));
|
||||
expect([r.anilistId, r.anilistEp, r.tvdbId, r.season, r.ep], [100182, 13, 259640, 3, 13]);
|
||||
});
|
||||
|
||||
test('pins win over the match, per show and per folder, and survive a rescan', () async {
|
||||
final lib = binux();
|
||||
final m = await matched(lib);
|
||||
final tybw = entries.firstWhere((e) => e.id == 41467);
|
||||
final reZero = entries.firstWhere((e) => e.id == 21355);
|
||||
|
||||
m.pinShow('bleach', tybw);
|
||||
expect(m.showMatch(lib.show('bleach')!)!.pinned, PinScope.show);
|
||||
expect(m.target(ep(lib, 'bleach', 60))!.anilistId, 41467);
|
||||
// A folder pin is more specific than the show's.
|
||||
m.pinFolder(b3, reZero);
|
||||
expect(m.target(ep(lib, 'bleach', 120))!.anilistId, isNot(41467));
|
||||
expect(m.seasonMatch(ep(lib, 'bleach', 120))!.pinned, PinScope.folder);
|
||||
expect(m.seasonMatch(ep(lib, 'bleach', 60))!.entry.id, 41467);
|
||||
|
||||
// A rescan (a new library, a new run) over the same store: the pins
|
||||
// hold, with AniList out of reach.
|
||||
final again = binux();
|
||||
final m2 = matcher(api: FakeAniList()..offline = true)..remember(again);
|
||||
for (final s in again.shows) {
|
||||
m2.want(s);
|
||||
}
|
||||
await settle(m2);
|
||||
expect(m2.seasonMatch(ep(again, 'bleach', 60))!.entry.id, 41467);
|
||||
expect(m2.seasonMatch(ep(again, 'bleach', 120))!.entry.id, 21355);
|
||||
|
||||
m2.unpinFolder(b3);
|
||||
m2.unpinShow('bleach');
|
||||
expect(m2.seasonMatch(ep(again, 'bleach', 120))!.entry.id, 269);
|
||||
});
|
||||
|
||||
test('an episode offset: "these files are episodes N.."', () async {
|
||||
final lib = binux();
|
||||
final m = await matched(lib);
|
||||
// The Alicization files are really War of Underworld 1-24 here.
|
||||
m.setOffset('sword art online', 3, 24);
|
||||
final r = m.refFor(ep(lib, 'sword art online', 1));
|
||||
expect([r.ep, r.anilistId, r.anilistEp], [25, 108759, 1]);
|
||||
m.setOffset('sword art online', 3, 0);
|
||||
expect(m.refFor(ep(lib, 'sword art online', 1)).anilistEp, 1);
|
||||
});
|
||||
|
||||
test('cached: one search per season, none again (offline too), requests spaced out', () async {
|
||||
final lib = binux();
|
||||
final m = await matched(lib);
|
||||
final asked = aniList.searches.length;
|
||||
expect(asked, greaterThanOrEqualTo(4));
|
||||
expect(waits.where((w) => w > Duration.zero), isNotEmpty, reason: 'spaced, not a burst');
|
||||
for (final s in lib.shows) {
|
||||
m.want(s);
|
||||
}
|
||||
await settle(m);
|
||||
expect(aniList.searches.length, asked);
|
||||
|
||||
final offline = FakeAniList()..offline = true;
|
||||
final m2 = matcher(api: offline)..remember(lib);
|
||||
for (final s in lib.shows) {
|
||||
m2.want(s);
|
||||
}
|
||||
await settle(m2);
|
||||
expect(offline.searches, isEmpty);
|
||||
expect(m2.refFor(ep(lib, 'bleach', 108)), m.refFor(ep(lib, 'bleach', 108)));
|
||||
expect(m2.titleOf(lib.show('re zero')!), '$reEn Season 4');
|
||||
});
|
||||
|
||||
test('before a match (or offline with nothing cached) a ref still says what L1 read', () async {
|
||||
final lib = binux();
|
||||
final m = matcher(api: FakeAniList()..offline = true)..remember(lib);
|
||||
m.want(lib.show('bleach')!);
|
||||
await settle(m);
|
||||
expect(m.refFor(ep(lib, 'bleach', 56)), const MediaRef(kind: 'episode', title: 'Bleach', absolute: 56));
|
||||
// A file never scanned: its name and folder alone.
|
||||
expect(m.refForPath('$sao/[Judas] Sword Art Online S3 - 07.mkv'),
|
||||
const MediaRef(kind: 'episode', title: 'Sword Art Online', season: 3, ep: 7));
|
||||
});
|
||||
|
||||
test('the ref fits the wire: round-trips and stays under 2 KiB', () async {
|
||||
final long = 'A Very Long Light Novel Title ' * 12;
|
||||
final lib = groupLocal([LocalFile('$root/$long - 05.mkv', 1, DateTime(2026))], roots: [root]);
|
||||
final m = matcher(api: FakeAniList()..offline = true);
|
||||
final ref = m.refFor(lib.shows.single.episodes.single);
|
||||
final media = HelloMedia(name: 'x.mkv', size: 1, duration: 0, ref: ref);
|
||||
final json = jsonEncode(media.toJson());
|
||||
final back = HelloMedia.fromJson(jsonDecode(json) as Map<String, Object?>);
|
||||
expect(back.ref!.kind, 'episode');
|
||||
expect(back.ref!.absolute, 5);
|
||||
expect(utf8.encode(jsonEncode(ref.toJson())).length, lessThan(RelayConstants.maxMediaRefBytes));
|
||||
expect(back.ref!.title.length, lessThanOrEqualTo(200));
|
||||
});
|
||||
}
|
||||
+112
-23
@@ -7,7 +7,7 @@ Plain overview first: [HOW-IT-WORKS.md](HOW-IT-WORKS.md). Wire format:
|
||||
|
||||
| Path | What | Rule |
|
||||
|---|---|---|
|
||||
| `packages/core` | protocol messages, sync engine (`SyncEngine`), constants, config, Range parsing, logging, Jellyfin client, updater, watched rule (`WatchSession`); `testing.dart` (test support for all three packages, not in `core.dart`: `FlakyProxy`) | pure Dart, no Flutter |
|
||||
| `packages/core` | protocol messages, sync engine (`SyncEngine`), constants, config, Range parsing, logging, Jellyfin client, updater, watched rule (`WatchSession`), the library's L1 (`library/`: release and folder names, `groupLocal`); `testing.dart` (test support for all three packages, not in `core.dart`: `FlakyProxy`) | pure Dart, no Flutter |
|
||||
| `packages/server` | Relic HTTP + WebSocket server (`SyncServer`, `ServerHub`, `Session`, media endpoint, relay buffer, Jellyfin relay), the `serve()` entrypoint, `bin/server.dart` (dedicated server) | pure Dart, no Flutter |
|
||||
| `apps/desktop` | Flutter app: UI, `AppGraph` (composition root), `SyncClient`, `Connection`, `SessionCoordinator`, `Navigation`, `YourServer` (`ServerIsolate`), `PlaybackController` (media_kit/mpv), update UI (`UpdateController`) | ships with `starsync-server` |
|
||||
| `tool` | harnesses: `range_logger`, `shaper`, `fake_viewer`, `e2e_check` | dev only |
|
||||
@@ -48,9 +48,10 @@ attach and detach the network listener (below).
|
||||
- **Listeners**: `SyncServer.share(port, secret, jellyfin)` attaches a
|
||||
second listener on every IPv4 interface at the port from Settings →
|
||||
Party & network (default 8080) to the running server (Relic serves the
|
||||
same routes on both), sets the password and the Jellyfin relay login
|
||||
(the default account's, so guests without an account watch Jellyfin
|
||||
items through the server); `unshare` closes it. Nothing restarts for
|
||||
same routes on both), sets the password and the Jellyfin relay logins
|
||||
(one per linked account, `relayCredentialsFor`, so guests without an
|
||||
account watch Jellyfin items through the server with your sign-in on
|
||||
the item's own Jellyfin); `unshare` closes it. Nothing restarts for
|
||||
either: members on the loopback listener keep their sockets, the media
|
||||
and Up next stay. Unsharing sends everyone but the host out: each
|
||||
leaves at once (feed `leave`, reason `closed`) and its socket closes
|
||||
@@ -133,7 +134,9 @@ only stopping sharing sends guests out.
|
||||
(`AppGraph.build`) and constructs and disposes the app's systems: the
|
||||
`Ports`, the sounds (`SynthSfx`), the `Notifier`, the `Connection`, the
|
||||
`SessionCoordinator`, the connect form and recents (`ConnectStore`,
|
||||
`state/connect_prefs.dart`), `YourServer` and `Navigation`; it wires them
|
||||
`state/connect_prefs.dart`), `YourServer`, `Navigation` and the library's
|
||||
matches (`ShowMatcher`, "Library", whose `refForPath` it hands the
|
||||
coordinator as `fileRef`); it wires them
|
||||
through their constructors (and gives `Profile` the form and the push) and
|
||||
has no logic or signals of its own. Widgets reach it through one
|
||||
`AppScope` InheritedWidget (`AppScope.of(context)`, readable in
|
||||
@@ -653,20 +656,22 @@ Gotchas (Jellyfin 10.11):
|
||||
## Jellyfin relay
|
||||
|
||||
For viewers without an account on the item's Jellyfin
|
||||
(`packages/server/lib/src/jellyfin_relay.dart`). The server may hold one
|
||||
`JellyfinCredential` (config key `jellyfin`: `baseUrl`, `serverId`, `token`,
|
||||
`client`, `device`, `deviceId`, `version`; primitives, so it crosses the
|
||||
isolate boundary in the `serve()` map). The app sends it when your server
|
||||
is shared (`share`): its own app-obtained token and that token's device
|
||||
fields; the dedicated server
|
||||
reads it from `--jellyfin <file>` (YAML/JSON with those keys, keeps the
|
||||
(`packages/server/lib/src/jellyfin_relay.dart`). The server holds its
|
||||
logins as `JellyfinCredential`s (`ServerHub.jellyfinLogins`, at most one
|
||||
per Jellyfin `serverId`; each: `baseUrl`, `serverId`, `token`, `client`,
|
||||
`device`, `deviceId`, `version`; primitives, so they cross the isolate
|
||||
boundary). The app sends one per linked account (not known expired, the
|
||||
default first) when your server is shared (`share`): its own
|
||||
app-obtained tokens and their device fields; the dedicated server has one,
|
||||
read from `--jellyfin <file>` (YAML/JSON with those keys, keeps the
|
||||
token off the command line) or a `jellyfin:` map in `--config`. An empty
|
||||
`serverId` is looked up at start (`/System/Info/Public`); if that fails the
|
||||
relay stays off. The token is never logged (`toString` and config parse
|
||||
errors leave it out).
|
||||
`serverId` is looked up at start (`/System/Info/Public`); if that fails
|
||||
that login stays unused. Tokens are never logged (`toString` and config
|
||||
parse errors leave them out).
|
||||
|
||||
- Available (`state.relay`) only while the current media is a ref whose
|
||||
`serverId` equals the credential's; `state.relayNext` likewise for Up
|
||||
- Available (`state.relay`) only while the current media is a ref on a
|
||||
server one of the logins is for (`loginFor(serverId)`, which `/j` then
|
||||
uses); `state.relayNext` likewise for Up
|
||||
next's first item, which `/j` also serves so guests can preload it
|
||||
(see "Getting ready" under "Up next"). Only those two items are
|
||||
relayed, only to a valid viewer token: no open proxy.
|
||||
@@ -1282,6 +1287,73 @@ every `state` and feed), the pure rules in `queue_logic.dart`, the UI in
|
||||
in order, a link in the Link page's mode). All go through
|
||||
`QueueController.add`. None of it offline.
|
||||
|
||||
## Library
|
||||
|
||||
Local videos as shows, device-owned (the three-way truth: library, match
|
||||
corrections and watch history live on the device). Two layers:
|
||||
|
||||
**L1, local identity** (`packages/core/lib/src/library/`, pure and
|
||||
offline): `parseReleaseName` reads a file name (title, group, season,
|
||||
episode, year, extras: OP/ED, Opening/Ending, NCOP/NCED, OVA, specials,
|
||||
PV, Menu, numbered; movies: `Movie N`, `The Movie`, `Gekijouban`, a
|
||||
title with a year), `parseFolderName` a folder name (title, season and
|
||||
its name from a parenthetical read before brackets go, `(Season 3 -
|
||||
Alicization)`, an episode range `001-055`, a season-only or extras
|
||||
folder). `groupLocal(files, roots:)` keeps the videos (anything else,
|
||||
`.lnk` shortcuts included, is counted as ignored) and reads each
|
||||
(`readLocalFile`): the file's own name wins, the folders between it and
|
||||
its watched folder are hints, nearest first (a bare `01.mkv` takes the
|
||||
folder's title, a file under `Season 2` its season). Files group into a
|
||||
`LocalShow` by `normalizeSeries` of their title, across folders (Bleach
|
||||
in three folders is one show), with `LocalSeason`s as numbered (a null
|
||||
number is an absolute run, kept as found), extras and the show's movies;
|
||||
a movie of no show here stands alone, anything else is "other". Each
|
||||
season knows its holes (`EpisodeGap`: before the first episode, between
|
||||
episodes, and up to a folder range's end), so `LocalShow.nextAfter(key)`
|
||||
gives the next episode and the numbers skipped on the way. Keys are
|
||||
stable across rescans (`show|s<season>|e<n>`, extras and movies alike);
|
||||
ordering is deterministic (titles, then numbers, then names). The app's
|
||||
scan (`files/scanner.dart`, `FolderAccess.scan`) only walks and pairs
|
||||
subtitles; the Files page groups its `LocalFile`s.
|
||||
|
||||
**L2, match** (`apps/desktop/lib/src/library/`, `ShowMatcher`, made by
|
||||
the composition root, cached in the Hive box `library`, `LibraryStore`):
|
||||
a show's season (by show key, season and season name) is searched once
|
||||
on AniList (`AniListClient.searchAnime`: titles, synonyms, format,
|
||||
episodes, art, the SEQUEL / PREQUEL TV entries and episode titles) with
|
||||
the season's name or "season N" as hints; `scoreMatch` weighs titles and
|
||||
synonyms (`Re.Zero` finds `Re:ZERO -Starting Life in Another World-`),
|
||||
the season marker in a name (`4th Season`), the format (TV for episodes,
|
||||
MOVIE for movies) and AniList's own order; below a threshold nothing is
|
||||
kept ("none", asked again after 3 days). A season the search misses is
|
||||
the show's first entry walked along its sequels. Episode numbers
|
||||
(plus the user's offset) map onto AniList along the run: past an
|
||||
entry's episode count, its sequel's (a TVDB season AniList splits into
|
||||
cours, SAO S3 25 = War of Underworld 1), or, when signed in to
|
||||
AnimeFlow, its `anilist_map` runs for that season (which also give the
|
||||
TVDB id). Corrections win over everything and survive rescans: a pin of
|
||||
the show everywhere (`pin:show:<key>`) or of a folder and what is below
|
||||
it (`pin:folder:<path>`, more specific), and an episode offset per show
|
||||
and season ("these files are episodes N.."). Requests are lazy (cards
|
||||
and pages on screen ask, `want`), one at a time, 800 ms apart, paused a
|
||||
minute after a 429; failures are retried after 5 minutes; everything
|
||||
answers from the cache offline. Episode and show names, cover and banner
|
||||
come from the matched entry.
|
||||
|
||||
**Media identity** (`media.ref`, [PROTOCOL.md](PROTOCOL.md) "Media
|
||||
identity"): what a media is, independent of where its bytes come from.
|
||||
Producers: a local file, `ShowMatcher.refFor` (L1 kind and numbers, the
|
||||
match's title, AniList entry and episode, the TVDB id from AnimeFlow;
|
||||
L1 only before a match); a Jellyfin item, `jellyfinMediaRef` (kind,
|
||||
series title, season and episode, the AniList / Tvdb provider ids of the
|
||||
show or movie, `ProviderIds` in the item fields; from a pick's lines,
|
||||
`jellyfinPickRef`, when the item was not at hand); a link,
|
||||
`linkMediaRef` (its name read as a release name, else a movie of that
|
||||
title). Swaps (`swapFile`, `swapJellyfin`, `swapLink`) and Up next adds
|
||||
carry it; Up next names a file item by it. Consumers: receivers' now
|
||||
playing title and episode line (`refTitle`), the swapper's art (by the
|
||||
AniList id); later the watch tracking keys on it.
|
||||
|
||||
## App events and platform ports
|
||||
|
||||
**Status.** What the session is like right now is one computed value,
|
||||
@@ -1558,10 +1630,24 @@ panel) and chat messages (Copy message, and a link in it).
|
||||
that fails although the kept preview looked fine, the preview is
|
||||
probed again and shows why, with "Put it up anyway" when it is a
|
||||
problem. Files
|
||||
is `FilesPage` (`files/`): watched folders (`FolderStore`) scanned in
|
||||
an isolate, videos grouped by series; series start collapsed, a header
|
||||
click opens one and open ones stay open for the app session
|
||||
(`showFilesFolder` opens a folder from outside). With nobody else
|
||||
is `FilesPage` (`files/`), a library over the watched folders
|
||||
(`FolderStore`, each scanned in an isolate when first needed): places
|
||||
on the left (chips when narrow): Library (every folder together, the
|
||||
default), Recent, then each folder (`showFilesFolder` opens one from
|
||||
outside). A place is poster cards (2:3, like the Jellyfin browser's;
|
||||
the AniList cover, else a frame of the first episode) for its Shows and
|
||||
Movies, then the Other videos as 16:9 cards; a show card says how many
|
||||
episodes, or the next one after the last played here. A click on a show
|
||||
opens its page (`files/ui/library_view.dart`, kept open per place for
|
||||
the app session, Back returns): a banner header (AniList banner, else a
|
||||
frame) with the proper name, what the files are (seasons, episodes,
|
||||
extras, group, folders), the match ("AniList: …", "files say …",
|
||||
pinned), the gap prompt ("66–107 aren't in your folders — skip to
|
||||
108?") above the next-episode button, Wrong show? and Episode
|
||||
numbers…; then season chips (more than one season), the season's
|
||||
episodes as rows ("12. Name" with AniList's episode title, else
|
||||
"Episode 12"; one click plays, right-click queues) with the holes as
|
||||
muted rows, Extras and the show's Movies. With nobody else
|
||||
in the server (`alone` on the Library, Files and Schedule, D9) the
|
||||
buttons say Play, Link has no relay modes and a link goes up direct,
|
||||
Files has no relay notice; a put-up before your server is up waits for
|
||||
@@ -2220,10 +2306,13 @@ testers land back on stable. `0.0.0-dev` (a dev run) checks nothing.
|
||||
shows its swap landed (`media_art.dart`: Jellyfin series/item images with
|
||||
its own account; a web link's yt-dlp thumbnail, the one the Library's
|
||||
Recent grid shows, as poster and banner both, shrunk to a JPEG under the
|
||||
poster cap when larger; else an AniList title search on the parsed
|
||||
poster cap when larger; else the AniList entry `media.ref` names (a
|
||||
matched local file), else an AniList title search on the parsed
|
||||
release name) and sends `art`, so guests without Jellyfin get covers too.
|
||||
`coordinator.nowPlaying` derives title, episode line and art hashes from
|
||||
`state.media` (`now_playing_info.dart`). A web video
|
||||
`state.media` (`now_playing_info.dart`); a non-Jellyfin media with a
|
||||
`media.ref` is named by it (its title, `S3 E13` / `E108`, an extra's
|
||||
own marker), see "Library". A web video
|
||||
(`NowPlaying.thumbnail`) shows the media name as is, no episode line, a
|
||||
16:9 cover in the controls (a neutral placeholder until the art is up)
|
||||
and none on the start card. Art up: poster and banner of one hash mark
|
||||
|
||||
+5
-2
@@ -79,7 +79,9 @@ Three suites, run from each package's folder:
|
||||
|
||||
- `cd packages/core && dart test` (a few seconds): protocol round-trips,
|
||||
the sync engine (clock, gate, presence and grace, correction, end of
|
||||
file), the `PresencePolicy` table, queue model,
|
||||
file), the `PresencePolicy` table, queue model, the library's L1
|
||||
(release and folder names, `groupLocal` over a real anime folder's
|
||||
names: shows across folders, holes, extras, a movie),
|
||||
relay pieces, updater, logging redaction, watch rule, yt-dlp and
|
||||
Jellyfin client parsing against captured fixtures.
|
||||
- `cd packages/server && dart test` (well under a minute): live servers
|
||||
@@ -90,7 +92,8 @@ Three suites, run from each package's folder:
|
||||
under the same members and Up next, the host key standing in for the
|
||||
password).
|
||||
- `cd apps/desktop && flutter test test/<file>`: widgets (shell, library,
|
||||
controls, settings, schedule), pure app logic (`StateDiff`, name parser,
|
||||
controls, settings, schedule), pure app logic (`StateDiff`, the library's L2 matching against a
|
||||
fake AniList (`show_matcher_test`), `media.ref` producers,
|
||||
shader list, sound engine with a fake backend, invite addresses, the
|
||||
version gate), `singletons_test` (the `.instance` count, see
|
||||
ARCHITECTURE "Composition root"; lower its baseline when a use goes) and the real-mpv `*_e2e`
|
||||
|
||||
+4
-2
@@ -354,8 +354,10 @@ media.ref = {"kind":"episode","title":"Show","anilistId":1,"anilistEp":6,
|
||||
is (the server drops non-integer ids and cuts `title` to 200
|
||||
characters). `kind` `episode`, `movie` or `extra` (else no ref); every
|
||||
other field optional; `season` absent for absolute numbering. At most
|
||||
2 KiB of JSON, else `err` `bad`. **Reserved**: nothing produces it yet
|
||||
(step 4).
|
||||
2 KiB of JSON, else `err` `bad`. Apps send it with swaps and Up next
|
||||
adds of files (the sender's library match), Jellyfin items (their
|
||||
provider ids) and links (kind and title only); receivers name the
|
||||
media by it (ARCHITECTURE.md "Library").
|
||||
|
||||
## Avatars
|
||||
|
||||
|
||||
+20
-12
@@ -61,9 +61,6 @@ Problems are numbered; delete a line when its fix ships.
|
||||
the `close:` log lines.
|
||||
- P33 No volume readout on wheel: likely fixed by the controls rework
|
||||
(1.2.1-dev.1; a test covers both skins); confirm in real use.
|
||||
- P34 Local anime folders are opened as plain files (no banner, names,
|
||||
next episode, tracking); test library: the user's
|
||||
`/mnt/torrents/Torrents/Binux/` (several shows and a movie).
|
||||
|
||||
**Restructure** (in order; reviewed against the code). Truth lives in
|
||||
three places: the server (members, presence, gate, transport, queue incl.
|
||||
@@ -71,9 +68,6 @@ the automatic continuation and countdown, relay state), the device
|
||||
(client id, library and folders, match corrections, watch history,
|
||||
settings, notification prefs, what this app is sourcing) and the
|
||||
integrations (Jellyfin played/resume, AniList list).
|
||||
4. Library + Media identity: L1 local grouping (kinds, folder hints,
|
||||
gaps, merge across folders) and L2 match (AniList/TVDB, art, names,
|
||||
manual pin, cache); `MediaRef` in swap/queue media. Fixes P34.
|
||||
5. Media pipeline + player host port: plan → resolve → open → ready,
|
||||
commands queued during a switch, viewer-side recovery when a source
|
||||
drops (no mpv seek loops, play what's buffered), resolver and source
|
||||
@@ -127,12 +121,26 @@ that the invite skips the proxy's 198.18.x here) and of the update band
|
||||
from 1.2.1-dev.3 in real use. No public address lookup or UPnP: the
|
||||
members card only says friends elsewhere need the port forwarded. Your
|
||||
server's `/health` name is the profile name at start (a rename shows
|
||||
after a restart). Guests' Jellyfin relay uses the default account (no
|
||||
longer the put-up item's server). Joining a friend while others are on
|
||||
after a restart). Joining a friend while others are on
|
||||
your server leaves them there without you, unwarned; a join refused
|
||||
after dialling (wrong password) costs your own member there (`bye`, back
|
||||
as a new member). Your server writes its session log even when you are
|
||||
alone.
|
||||
Left of step 4 (library): a human check of the Files library on the
|
||||
real folder (`/mnt/torrents/Torrents/Binux/`: Bleach over three folders
|
||||
with 66–107 missing, SAO S3 Alicization with OP/ED, Re.Zero S04E17, the
|
||||
Kimetsu movie) and of the AniList matches it gets. A Jellyfin swap
|
||||
carries AniList/TVDB ids only when the picked item has them (movies); an
|
||||
episode's show ids need its series item, which picks do not fetch.
|
||||
Receivers name the media by `media.ref` but still get art only as the
|
||||
swapper's bytes (now chosen by the ref's AniList id). Relay logins are
|
||||
sent at Invite: an account linked or signed in again later relays only
|
||||
after Stop sharing and Invite. "Season N" without a name walks AniList's
|
||||
sequels, which can land on the wrong cour where AniList splits seasons;
|
||||
Wrong show? fixes it. The next episode of a local show lives on its page
|
||||
(from Recent); no countdown or "Continues with" for files yet (backlog
|
||||
2). The `library` box keeps every AniList entry seen (no pruning);
|
||||
AniList's episode titles exist only where it lists streaming episodes.
|
||||
Left of step 2: system notifications need a human check (Linux: the
|
||||
notification shows and a click brings the window up, also on Wayland,
|
||||
where focus may be refused without an activation token; Windows: toasts
|
||||
@@ -164,7 +172,6 @@ core and app. Still open on the wire, frozen at the 1.3.0 release:
|
||||
holds it while the item's performer or source is `gone`. Today it is
|
||||
parsed and broadcast but never started; `auto` items and `cancel` are
|
||||
live.
|
||||
- `media.ref` producers (step 4); the server carries it already.
|
||||
- `hello.client` optionally derived per server (HMAC of an install
|
||||
secret and the dialled host:port).
|
||||
- TLS: `/health.tls` is always false; `wss://` only with a CA-valid
|
||||
@@ -178,9 +185,10 @@ core and app. Still open on the wire, frozen at the 1.3.0 release:
|
||||
seeks are shared, and only real ranges (segments, OP/ED chapters) should
|
||||
ever be skipped.
|
||||
|
||||
2. **Files**: next episode from Files (by parsed episode, with the
|
||||
countdown, and under Up next's "Continues with"). Later: tracking for
|
||||
file series.
|
||||
2. **Files**: the next episode of a local show (the library's
|
||||
`nextAfter`, gaps included) at the end of one, with the countdown, and
|
||||
under Up next's "Continues with". Tracking for file series comes with
|
||||
step 6.
|
||||
|
||||
3. **yt-dlp follow-ups** (see ARCHITECTURE "yt-dlp"): check that the
|
||||
bundled Windows libmpv has the ytdl hook (Lua) and plays `edl://` URLs
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
+62
-2
@@ -1,5 +1,5 @@
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:starsync/src/files/name_parser.dart';
|
||||
import 'package:test/test.dart';
|
||||
import 'package:sync_relay_core/core.dart';
|
||||
|
||||
void _expect(
|
||||
String name,
|
||||
@@ -96,6 +96,16 @@ void main() {
|
||||
_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);
|
||||
@@ -142,6 +152,19 @@ void main() {
|
||||
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);
|
||||
});
|
||||
@@ -160,6 +183,43 @@ void main() {
|
||||
});
|
||||
});
|
||||
|
||||
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);
|
||||
@@ -24,9 +24,10 @@ bool _sameItemId(String a, String b) => (normalizeItemId(a) ?? a) == (normalizeI
|
||||
Response _plain(int status) => Response(status, body: Body.fromString('status $status'));
|
||||
|
||||
/// `GET /j/<sid>/<token>/<itemId>`: relays byte ranges of the current
|
||||
/// Jellyfin media from [credential]'s server to a viewer, for guests without
|
||||
/// Jellyfin media from its Jellyfin server to a viewer, with the server's
|
||||
/// login there ([credentialFor] the ref's `serverId`), for guests without
|
||||
/// their own access. Only the active ref's item, or Up next's first one
|
||||
/// ([next], so viewers can get it ready), on the credential's server, only
|
||||
/// ([next], so viewers can get it ready), on a server with a login, only
|
||||
/// for a valid viewer token: no open proxy. Range goes up as sent;
|
||||
/// status, Content-Range, Content-Length, Content-Type and Accept-Ranges
|
||||
/// come back as Jellyfin sent them (a 401/403 from Jellyfin is the server's
|
||||
@@ -38,7 +39,7 @@ Response _plain(int status) => Response(status, body: Body.fromString('status $s
|
||||
void mountJellyfinRelayRoute(
|
||||
RelicApp app,
|
||||
SessionMap sessions,
|
||||
JellyfinCredential? Function() credential,
|
||||
JellyfinCredential? Function(String serverId) credentialFor,
|
||||
HttpClient Function() client, {
|
||||
JellyfinRef? Function()? next,
|
||||
String pattern = '/j/:sid/:token/:itemId',
|
||||
@@ -58,14 +59,13 @@ void mountJellyfinRelayRoute(
|
||||
default:
|
||||
return _plain(401);
|
||||
}
|
||||
final cred = credential();
|
||||
if (cred == null || cred.serverId.isEmpty) return _plain(404);
|
||||
JellyfinRef? onLogin(JellyfinRef? r) => r != null && cred.serverId == r.serverId ? r : null;
|
||||
JellyfinRef? onLogin(JellyfinRef? r) => r != null && credentialFor(r.serverId) != null ? r : null;
|
||||
final current = onLogin(session.media?.jellyfin);
|
||||
final upNext = onLogin(next?.call());
|
||||
if (current == null && upNext == null) return _plain(404);
|
||||
final ref = [current, upNext].firstWhere((r) => r != null && _sameItemId(itemId, r.itemId), orElse: () => null);
|
||||
if (ref == null) return _plain(403);
|
||||
final cred = credentialFor(ref.serverId)!;
|
||||
|
||||
final range = req.headers['range']?.join(',');
|
||||
final HttpClientRequest upReq;
|
||||
|
||||
@@ -61,7 +61,12 @@ Future<void> serve(Map<String, Object?> config, {SendPort? events}) async {
|
||||
final port = await server.share(
|
||||
port: (msg['port'] as num?)?.toInt() ?? 0,
|
||||
secret: (msg['secret'] as String?) ?? '',
|
||||
jellyfin: JellyfinCredential.fromMap(msg['jellyfin']));
|
||||
jellyfin: msg['jellyfin'] is List
|
||||
? [
|
||||
for (final c in msg['jellyfin'] as List)
|
||||
if (JellyfinCredential.fromMap(c) case final cred?) cred,
|
||||
]
|
||||
: null);
|
||||
reply.send({'ok': true, 'port': port});
|
||||
} catch (e) {
|
||||
reply.send({'ok': false, 'message': '$e'});
|
||||
|
||||
@@ -23,16 +23,31 @@ class ServerHub {
|
||||
/// The `/sync` socket of each member that has one, by token; a `gone`
|
||||
/// member has none.
|
||||
final Map<String, RelicWebSocket> sockets = {};
|
||||
/// The server's Jellyfin login for the `/j/...` relay; null = no relay.
|
||||
JellyfinCredential? jellyfin;
|
||||
/// The server's Jellyfin logins for the `/j/...` relay, at most one per
|
||||
/// Jellyfin server (`serverId`); empty = no relay. The dedicated
|
||||
/// server has its configured one; the app's own server one for each
|
||||
/// linked account, so an item is relayed with its own server's login.
|
||||
List<JellyfinCredential> jellyfinLogins;
|
||||
|
||||
/// The first login (the dedicated server's only one); null = none.
|
||||
JellyfinCredential? get jellyfin => jellyfinLogins.isEmpty ? null : jellyfinLogins.first;
|
||||
|
||||
/// The login on Jellyfin server [serverId]; null when there is none.
|
||||
JellyfinCredential? loginFor(String serverId) {
|
||||
for (final c in jellyfinLogins) {
|
||||
if (c.serverId.isNotEmpty && c.serverId == serverId) return c;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
/// Reads web pages for "the server fetches it"; null = files only.
|
||||
final YtDlp? ytDlp;
|
||||
/// Random per server boot (`welcome.instance`).
|
||||
final String instance = _randomId();
|
||||
|
||||
ServerHub(this.sessions, this.expectedSecret,
|
||||
{SyncEngine? engine, this.hostKey = '', this.jellyfin, this.ytDlp})
|
||||
: engine = engine ?? SyncEngine() {
|
||||
{SyncEngine? engine, this.hostKey = '', JellyfinCredential? jellyfin, this.ytDlp})
|
||||
: jellyfinLogins = [if (jellyfin != null) jellyfin],
|
||||
engine = engine ?? SyncEngine() {
|
||||
this.engine
|
||||
..onGone = _memberGone
|
||||
..onLeft = _memberLeft;
|
||||
@@ -106,20 +121,18 @@ class ServerHub {
|
||||
|
||||
bool get _sourceGone => engine.media?.relay?.sourceGone ?? false;
|
||||
|
||||
/// The current media is a Jellyfin ref on the server [jellyfin] logs in to.
|
||||
/// The current media is a Jellyfin ref on a server one of
|
||||
/// [jellyfinLogins] logs in to.
|
||||
bool get relayAvailable => _onLogin(session.media?.jellyfin);
|
||||
|
||||
/// Up next's first item is a Jellyfin ref on that server: `/j` serves it
|
||||
/// too, so viewers without access get it ready.
|
||||
/// Up next's first item is a Jellyfin ref on such a server: `/j` serves
|
||||
/// it too, so viewers without access get it ready.
|
||||
bool get relayNextAvailable => _onLogin(nextJellyfinRef);
|
||||
|
||||
/// Up next's first item's Jellyfin ref (null: none, or not a ref).
|
||||
JellyfinRef? get nextJellyfinRef => engine.queue.items.firstOrNull?.media.jellyfin;
|
||||
|
||||
bool _onLogin(JellyfinRef? ref) {
|
||||
final cred = jellyfin;
|
||||
return ref != null && cred != null && cred.serverId.isNotEmpty && cred.serverId == ref.serverId;
|
||||
}
|
||||
bool _onLogin(JellyfinRef? ref) => ref != null && loginFor(ref.serverId) != null;
|
||||
|
||||
/// What the swapper reads when the server cannot fetch a link ([why]
|
||||
/// from the relay). A yt-dlp failure on a server without cookies says
|
||||
|
||||
@@ -130,7 +130,7 @@ class SyncServer {
|
||||
mountArtRoute(_app, hub);
|
||||
mountSessionLogRoutes(_app, hub, () => _sessionLog);
|
||||
mountJellyfinRelayRoute(
|
||||
_app, sessions, () => hub.jellyfin, () => _relayClient ??= HttpClient(),
|
||||
_app, sessions, hub.loginFor, () => _relayClient ??= HttpClient(),
|
||||
next: () => hub.nextJellyfinRef, onLog: logRequest);
|
||||
// advertiseHost is only an explicit override; otherwise echo what the client dialled.
|
||||
String authority(String dialledHost) => config.advertiseHost.isNotEmpty
|
||||
@@ -193,32 +193,39 @@ class SyncServer {
|
||||
}
|
||||
|
||||
/// A configured credential without a ServerId gets it from
|
||||
/// `/System/Info/Public` (unauthenticated). On failure the relay stays off.
|
||||
/// `/System/Info/Public` (unauthenticated). On failure that login stays
|
||||
/// unused.
|
||||
Future<void> _resolveJellyfinServerId() async {
|
||||
final cred = hub.jellyfin;
|
||||
if (cred == null) return;
|
||||
if (cred.serverId.isEmpty) {
|
||||
final client = JellyfinClient(baseUrl: cred.baseUrl, clientInfo: cred.clientInfo);
|
||||
try {
|
||||
final info = await client.publicInfo().timeout(const Duration(seconds: 10));
|
||||
hub.jellyfin = cred.withServerId(info.id);
|
||||
} catch (e) {
|
||||
_log.warning('jellyfin relay off: server id lookup failed: $e');
|
||||
return;
|
||||
} finally {
|
||||
client.close();
|
||||
final logins = <JellyfinCredential>[];
|
||||
for (final cred in hub.jellyfinLogins) {
|
||||
var c = cred;
|
||||
if (c.serverId.isEmpty) {
|
||||
final client = JellyfinClient(baseUrl: c.baseUrl, clientInfo: c.clientInfo);
|
||||
try {
|
||||
final info = await client.publicInfo().timeout(const Duration(seconds: 10));
|
||||
c = c.withServerId(info.id);
|
||||
} catch (e) {
|
||||
_log.warning('jellyfin relay off for ${c.baseUrl}: server id lookup failed: $e');
|
||||
} finally {
|
||||
client.close();
|
||||
}
|
||||
}
|
||||
if (c.serverId.isNotEmpty && !logins.any((l) => l.serverId == c.serverId)) {
|
||||
logins.add(c);
|
||||
_log.info('jellyfin relay on for server ${c.serverId}');
|
||||
}
|
||||
}
|
||||
_log.info('jellyfin relay on for server ${hub.jellyfin!.serverId}');
|
||||
hub.jellyfinLogins = logins;
|
||||
}
|
||||
|
||||
/// Opens the running server to the network: a second listener on
|
||||
/// every IPv4 interface at [port] (0: a free one), the password
|
||||
/// [secret] ('' : open) and the Jellyfin relay login [jellyfin]. Members
|
||||
/// [secret] ('' : open) and the Jellyfin relay logins [jellyfin] (one
|
||||
/// per Jellyfin server; null keeps the ones it has). Members
|
||||
/// already in (on the first listener) stay connected; nothing restarts.
|
||||
/// Shared already: the listener moves to [port] when it differs. Returns
|
||||
/// the bound port; throws when it cannot bind.
|
||||
Future<int> share({required int port, required String secret, JellyfinCredential? jellyfin}) async {
|
||||
Future<int> share({required int port, required String secret, List<JellyfinCredential>? jellyfin}) async {
|
||||
if (_server == null) throw StateError('the server is not running');
|
||||
final was = _shared;
|
||||
if (was != null && (port == 0 || port == was.port)) {
|
||||
@@ -247,9 +254,9 @@ class SyncServer {
|
||||
return listener.port;
|
||||
}
|
||||
|
||||
void _applyShareSettings(String secret, JellyfinCredential? jellyfin) {
|
||||
void _applyShareSettings(String secret, List<JellyfinCredential>? jellyfin) {
|
||||
session.secret = secret;
|
||||
if (jellyfin != null) hub.jellyfin = jellyfin;
|
||||
if (jellyfin != null) hub.jellyfinLogins = [...jellyfin];
|
||||
}
|
||||
|
||||
/// Closes the network listener [share] opened: everyone but the host
|
||||
|
||||
@@ -422,6 +422,31 @@ void main() {
|
||||
});
|
||||
});
|
||||
|
||||
test('several logins (your server shared): an item goes through its own server\'s login', () async {
|
||||
await startServer(null);
|
||||
// Two linked accounts; the default one is on another Jellyfin.
|
||||
const other = JellyfinCredential(
|
||||
baseUrl: 'http://127.0.0.1:1',
|
||||
serverId: 'otherserver',
|
||||
token: 'OTHER-TOKEN',
|
||||
client: 'Watch Together',
|
||||
device: 'host pc',
|
||||
deviceId: 'dev-1',
|
||||
version: '0.9.0',
|
||||
);
|
||||
await server.share(port: 0, secret: '', jellyfin: [other, cred()]);
|
||||
expect(server.hub.jellyfinLogins.map((c) => c.serverId), ['otherserver', _serverId]);
|
||||
final (a, _) = await join('alice');
|
||||
final state = jsonDecode(await swapToRef(a, _item)) as Map;
|
||||
expect(state['relay'], true);
|
||||
final (_, w) = await join('bob');
|
||||
final r = await relayGet(w.token, _item, range: 'bytes=0-9');
|
||||
expect(r.statusCode, 206);
|
||||
expect(await readAll(r), bytes.sublist(0, 10));
|
||||
expect(jf.auth.single, contains('Token="$_token"'));
|
||||
expect(jf.auth.single, isNot(contains('OTHER-TOKEN')));
|
||||
});
|
||||
|
||||
test('a credential without serverId looks it up at start', () async {
|
||||
await startServer(cred(serverId: ''));
|
||||
expect(server.hub.jellyfin!.serverId, _serverId);
|
||||
|
||||
Reference in new issue
Block a user