Restructure step 4: local library and media identity
ci / core (push) Successful in 29s
ci / server (push) Successful in 1m8s
ci / updater-windows (push) Failing after 6m2s

- L1 (core, offline): release names with kinds (episode, movie, extra:
  OP/ED/Opening/Ending/NC/PV/OVA…), folder hints (season and name from
  parentheticals, ranges), one show across folders, gaps and next with
  skipped numbers, junk ignored, stable keys.
- L2 (app, device-owned cache): AniList match with synonyms and
  sequel walking, episode mapping across cours (AnimeFlow runs when
  signed in), art and episode titles, "Wrong show?" pins per show or
  folder, episode offsets; lazy, spaced requests, works offline.
- Files tab as a library: poster cards, show pages with banner, gap
  prompt, extras, movies, Wrong show? and Episode numbers.
- media.ref produced for local files, Jellyfin (provider ids) and links,
  carried in swaps and Up next; receivers name media from it.
- Hosting relays each Jellyfin item through its own server's login.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
foxyzandClaude Opus 5.5 committed 2026-10-15 23:34:39 +03:00
1 parent 4d00b7455e
commit bf9fc193b3
44 files changed
+4308 -849

No files matched your search

+18 -4
View File
@@ -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 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 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. 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 - **Files**: your own library from the video folders you add on this
(click a series to list its episodes), or any file through Browse. The others stream it from you. 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 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. 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 - Friends without an account watch through the server: whoever put the
item up passes it on automatically, with their own sign-in, which never 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 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, session). They show "via server" in the member list (click the avatars). On your
or a dedicated server has its own login (`--jellyfin <file>`, see 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 [docs/ARCHITECTURE.md](docs/ARCHITECTURE.md), Jellyfin relay), the server
fetches it itself instead. fetches it itself instead.
- If nobody can pass it on (the person who put it up left), the app says - If nobody can pass it on (the person who put it up left), the app says
+2
View File
@@ -65,6 +65,8 @@ Future<void> main(List<String> args) async {
} catch (e, st) { } catch (e, st) {
debugPrint('load connect form failed: $e\n$st'); debugPrint('load connect form failed: $e\n$st');
} }
// The library's matches, pins and offsets (device-owned).
unawaited(graph.library.ensureLoaded());
MediaKit.ensureInitialized(); MediaKit.ensureInitialized();
// yt-dlp for web page links: downloaded on first run, kept current. // yt-dlp for web page links: downloaded on first run, kept current.
ports.links.warmUp(); ports.links.warmUp();
+18
View File
@@ -1,9 +1,13 @@
import 'package:flutter/widgets.dart'; import 'package:flutter/widgets.dart';
import '../jellyfin/accounts.dart'; import '../jellyfin/accounts.dart';
import '../library/library_store.dart';
import '../library/show_matcher.dart';
import '../platform/app_log.dart' show appLogDir; import '../platform/app_log.dart' show appLogDir;
import '../platform/desktop/desktop_ports.dart'; import '../platform/desktop/desktop_ports.dart';
import '../platform/ports/ports.dart'; import '../platform/ports/ports.dart';
import '../schedule/anilist.dart';
import '../schedule/animeflow.dart';
import '../screens/home/health_probe.dart'; import '../screens/home/health_probe.dart';
import '../session/connection.dart' show Connection; import '../session/connection.dart' show Connection;
import '../session/identity.dart' show ClientIdentity, Profile; import '../session/identity.dart' show ClientIdentity, Profile;
@@ -44,6 +48,9 @@ class AppGraph {
/// Home (your server) or a friend's. /// Home (your server) or a friend's.
final Navigation navigation; final Navigation navigation;
/// The library's matches (L2): local shows to AniList, pins, offsets.
final ShowMatcher library;
AppGraph._({ AppGraph._({
required this.ports, required this.ports,
required this.sfx, required this.sfx,
@@ -53,6 +60,7 @@ class AppGraph {
required this.forms, required this.forms,
required this.yourServer, required this.yourServer,
required this.navigation, required this.navigation,
required this.library,
}); });
/// The desktop app's graph. Tests pass their own [ports], [identity] /// The desktop app's graph. Tests pass their own [ports], [identity]
@@ -63,6 +71,7 @@ class AppGraph {
HealthGetter healthGet = defaultHealthGet, HealthGetter healthGet = defaultHealthGet,
ServerStarter? serverStarter, ServerStarter? serverStarter,
JellyfinAccounts? jellyfin, JellyfinAccounts? jellyfin,
ShowMatcher? library,
}) { }) {
final p = ports ?? desktopPorts(); final p = ports ?? desktopPorts();
final sfx = SynthSfx( final sfx = SynthSfx(
@@ -81,6 +90,14 @@ class AppGraph {
final connection = Connection(notify: notifier.notify, identity: identity ?? ClientIdentity.stored()); final connection = Connection(notify: notifier.notify, identity: identity ?? ClientIdentity.stored());
final coordinator = final coordinator =
SessionCoordinator(notifier: notifier, connection: connection, ytDlp: p.links, jellyfin: jellyfin); 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 forms = ConnectStore();
final yourServer = YourServer( final yourServer = YourServer(
forms: forms, forms: forms,
@@ -119,6 +136,7 @@ class AppGraph {
forms: forms, forms: forms,
yourServer: yourServer, yourServer: yourServer,
navigation: navigation, navigation: navigation,
library: matcher,
); );
} }
-196
View File
@@ -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();
+9 -114
View File
@@ -1,16 +1,13 @@
import 'dart:io'; import 'dart:io';
import 'dart:isolate'; 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, export 'package:sync_relay_core/core.dart' show FileKind, LocalFile, kindOf, subtitleExtensions, videoExtensions;
// sidecar subtitles, everything else counted and hidden. The walk is
// synchronous I/O, so it runs in its own isolate.
const videoExtensions = { // Folder scan for the Files source: the videos (grouped into shows by
'mkv', 'mp4', 'webm', 'avi', 'mov', 'm4v', 'ts', 'm2ts', 'wmv', 'flv', // // core's `groupLocal`, L1), sidecar subtitles, everything else counted
}; // and hidden. The walk is synchronous I/O, so it runs in its own isolate.
const subtitleExtensions = {'ass', 'ssa', 'srt', 'vtt', 'sup'};
/// Files looked at per scan; past this the result is [FolderScan.truncated]. /// Files looked at per scan; past this the result is [FolderScan.truncated].
const maxScannedFiles = 20000; const maxScannedFiles = 20000;
@@ -18,27 +15,8 @@ const maxScannedFiles = 20000;
/// Samples and trailers under this size are hidden next to the real thing. /// Samples and trailers under this size are hidden next to the real thing.
const _sampleMaxBytes = 300 << 20; 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 { class FolderScan {
final List<VideoFile> videos; final List<LocalFile> videos;
/// Non-video files and samples (subtitles and dot-entries not counted). /// Non-video files and samples (subtitles and dot-entries not counted).
final int hiddenCount; final int hiddenCount;
/// Up to 6 of the hidden file names, for "and N other files" hints. /// 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. /// The walk behind [scanFolder]; blocks, so call it from an isolate.
FolderScan scanFolderSync(String dir, {int maxFiles = maxScannedFiles}) { FolderScan scanFolderSync(String dir, {int maxFiles = maxScannedFiles}) {
final videos = <VideoFile>[]; final videos = <LocalFile>[];
final subs = <String>[]; final subs = <String>[];
final examples = <String>[]; final examples = <String>[];
var hidden = 0, seen = 0; var hidden = 0, seen = 0;
@@ -123,8 +101,7 @@ FolderScan scanFolderSync(String dir, {int maxFiles = maxScannedFiles}) {
if (examples.length < 6) examples.add(name); if (examples.length < 6) examples.add(name);
continue; continue;
} }
videos.add(VideoFile(e.path, size, parseReleaseName(name), videos.add(LocalFile(e.path, size, stat?.modified ?? DateTime.fromMillisecondsSinceEpoch(0)));
stat?.modified ?? DateTime.fromMillisecondsSinceEpoch(0)));
} }
} }
return FolderScan( 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, /// Subtitles in the same folder whose name is the video's base name,
/// optionally followed by `.lang` / `.forced` style suffixes. /// 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>>{}; final byStem = <String, List<String>>{};
for (final s in subs) { for (final s in subs) {
final stem = _stripExtension(s); 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) { String _baseName(String path) {
final i = path.lastIndexOf(RegExp(r'[/\\]')); final i = path.lastIndexOf(RegExp(r'[/\\]'));
return i < 0 ? path : path.substring(i + 1); 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) { String _stripExtension(String path) {
final dot = path.lastIndexOf('.'); final dot = path.lastIndexOf('.');
final sep = path.lastIndexOf(RegExp(r'[/\\]')); final sep = path.lastIndexOf(RegExp(r'[/\\]'));
+8 -76
View File
@@ -2,13 +2,13 @@ import 'dart:io';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:sync_relay_core/core.dart';
import '../../shell/avatar.dart'; import '../../shell/avatar.dart';
import '../name_parser.dart';
import '../scanner.dart';
import 'thumb.dart'; import 'thumb.dart';
// The Files page's rows and cards: a series header, an episode row (also // The Files page's rows and cards: an episode row (also used for Recent),
// used for Recent), a loose-video card, and the empty states. // a loose-video card, and the empty states.
/// What a play control is doing right now. /// What a play control is doing right now.
enum PlayState { idle, starting, playing } 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)`. /// Cleaned title of a loose video: `Title (2019)`.
String cleanTitle(ParsedName p) => p.year == null ? p.series : '${p.series} (${p.year})'; String cleanTitle(ParsedName p) => p.year == null ? p.series : '${p.series} (${p.year})';
class SeriesHeader extends StatelessWidget { /// One file as a row: an episode on a show's page, or a Recent entry
final SeriesGroup group; /// ([indent] off).
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).
class FileRow extends StatelessWidget { class FileRow extends StatelessWidget {
final String path; final String path;
final String title; final String title;
@@ -207,7 +139,7 @@ const videoCardTextHeight = 66.0;
/// A loose video (movie, clip): 16:9 frame with its duration, then the /// A loose video (movie, clip): 16:9 frame with its duration, then the
/// cleaned title and size. /// cleaned title and size.
class VideoCard extends StatelessWidget { class VideoCard extends StatelessWidget {
final VideoFile video; final LocalEpisode video;
final PlayState state; final PlayState state;
final ThumbCache thumbs; final ThumbCache thumbs;
final VoidCallback? onTap; 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)), style: TextStyle(color: scheme.onSurfaceVariant, fontSize: 12)),
], ],
), ),
+311 -133
View File
@@ -3,51 +3,57 @@ import 'dart:io';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:signals/signals_flutter.dart'; import 'package:signals/signals_flutter.dart';
import 'package:sync_relay_core/core.dart';
import '../../library/show_matcher.dart';
import '../../shell/motion.dart'; import '../../shell/motion.dart';
import '../../platform/ports/folder_access.dart'; import '../../platform/ports/folder_access.dart';
import '../../app/app_graph.dart'; import '../../app/app_graph.dart';
import '../../widgets/app_menu.dart'; import '../../widgets/app_menu.dart';
import '../folder_store.dart'; import '../folder_store.dart';
import '../name_parser.dart';
import '../scanner.dart'; import '../scanner.dart';
import 'file_tiles.dart'; import 'file_tiles.dart';
import 'library_view.dart';
import 'thumb.dart'; import 'thumb.dart';
export 'thumb.dart' show ThumbnailLoader; export 'thumb.dart' show ThumbnailLoader;
// The Files source: watched folders on the left (chips on top when // The Files source as a library: the watched folders on the left (chips
// narrow), the open one on the right grouped into series and loose // on top when narrow): the Library (every folder together), Recent, then
// videos, or Recent. A folder is scanned when first opened and on Rescan; // each folder. A library is shows and movies as poster cards, grouped by
// scans are kept for the app session. Series start collapsed; the ones // the library's L1 and named by its L2 match, plus the videos that are
// opened stay open for the app session. Playing goes through [onPlay]. // 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. /// Below this width the sidebar turns into chips above the list.
const filesNarrowWidth = 700.0; const filesNarrowWidth = 700.0;
/// The place that shows every watched folder together.
const libraryPlace = '\u0000library';
/// Scans kept for the app session, by folder. /// Scans kept for the app session, by folder.
final _scans = <String, FolderScan>{}; final _scans = <String, FolderScan>{};
final _running = <String, Future<FolderScan>>{}; final _running = <String, Future<FolderScan>>{};
String? _lastOpen; String? _lastOpen = libraryPlace;
/// Series opened this session, by `folder\nseries key`. /// The show open in each place (by show key) for the app session.
final _expanded = <String>{}; final _openShows = <String, String>{};
final _showRequests = ValueNotifier(0); final _showRequests = ValueNotifier(0);
/// Opens [dir] (null: Recent; [libraryPlace]: the library) in the Files
/// Opens [dir] (null: Recent) in the Files page: now if one is showing, /// page: now if one is showing, else when it next opens.
/// else when it next opens.
void showFilesFolder(String? dir) { void showFilesFolder(String? dir) {
_lastOpen = dir; _lastOpen = dir;
_showRequests.value++; _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 @visibleForTesting
void resetFilesPageSession() { void resetFilesPageSession() {
_scans.clear(); _scans.clear();
_running.clear(); _running.clear();
_expanded.clear(); _openShows.clear();
_lastOpen = null; _lastOpen = libraryPlace;
} }
class FilesPage extends StatefulWidget { class FilesPage extends StatefulWidget {
@@ -66,6 +72,8 @@ class FilesPage extends StatefulWidget {
final Future<FolderScan> Function(String dir)? scan; final Future<FolderScan> Function(String dir)? scan;
/// Defaults to [FolderStore.instance]. /// Defaults to [FolderStore.instance].
final FolderStore? store; final FolderStore? store;
/// Defaults to the app's (`AppGraph.library`).
final ShowMatcher? matcher;
/// Default: the system folder / file dialogs. /// Default: the system folder / file dialogs.
final Future<String?> Function()? pickFolder; final Future<String?> Function()? pickFolder;
final Future<String?> Function()? pickVideo; final Future<String?> Function()? pickVideo;
@@ -80,6 +88,7 @@ class FilesPage extends StatefulWidget {
this.thumbnails, this.thumbnails,
this.scan, this.scan,
this.store, this.store,
this.matcher,
this.pickFolder, this.pickFolder,
this.pickVideo, this.pickVideo,
}); });
@@ -88,52 +97,56 @@ class FilesPage extends StatefulWidget {
State<FilesPage> createState() => _FilesPageState(); 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 { class _Library {
final GroupedVideos grouped; final List<FolderScan> scans;
final int subtitleFiles; final LocalLibrary lib;
final Map<String, LocalEpisode> byPath;
_Library(FolderScan scan) _Library(this.scans, List<String> roots)
: grouped = groupBySeries(scan.videos), : lib = groupLocal([for (final s in scans) ...s.videos], roots: roots),
subtitleFiles = {for (final l in scan.subtitles.values) ...l}.length; byPath = {} {
for (final e in lib.files) {
byPath[e.path] = e;
}
}
/// Series rows in order: a header (video null), then, for the series int get videos => scans.fold(0, (n, s) => n + s.videos.length);
/// [open] says, its episodes and extras. int get hidden => scans.fold(0, (n, s) => n + s.hiddenCount);
List<(SeriesGroup, VideoFile?)> rows(bool Function(SeriesGroup g) open) => [ int get subtitleFiles => {
for (final g in grouped.series) ...[ for (final s in scans)
(g, null), for (final l in s.subtitles.values) ...l
if (open(g)) }.length;
for (final v in [...g.episodes, ...g.extras]) (g, v), bool get truncated => scans.any((s) => s.truncated);
],
];
} }
final _libraries = Expando<_Library>();
class _FilesPageState extends State<FilesPage> { class _FilesPageState extends State<FilesPage> {
FolderAccess get _folders => AppScope.of(context).ports.folders; FolderAccess get _folders => AppScope.of(context).ports.folders;
ShowMatcher get _matcher => widget.matcher ?? AppScope.of(context).library;
late final ThumbCache _thumbs = late final ThumbCache _thumbs =
widget.thumbnails == null ? ThumbCache.shared : ThumbCache(widget.thumbnails!); widget.thumbnails == null ? ThumbCache.shared : ThumbCache(widget.thumbnails!);
FolderStore get _store => widget.store ?? FolderStore.instance; FolderStore get _store => widget.store ?? FolderStore.instance;
/// Null: Recent. /// Null: Recent; [libraryPlace]: every folder.
String? _open = _lastOpen; String? _open = _lastOpen;
bool _showHidden = false; bool _showHidden = false;
String? _starting; String? _starting;
String? _error; String? _error;
final _libs = <String, _Library>{};
@override @override
void initState() { void initState() {
super.initState(); super.initState();
_showRequests.addListener(_shown); _showRequests.addListener(_shown);
unawaited(_matcher.ensureLoaded());
_store.ensureLoaded().then((_) { _store.ensureLoaded().then((_) {
if (!mounted) return; if (!mounted) return;
final open = _open; final open = _open;
if (open != null && !_store.folders.value.contains(open)) { if (open != null && open != libraryPlace && !_store.folders.value.contains(open)) {
_select(null); _select(libraryPlace);
} else if (open != null) { } else {
_scan(open); _scanFor(open);
} }
}); });
} }
@@ -148,19 +161,32 @@ class _FilesPageState extends State<FilesPage> {
Future<FolderScan> _runScan(String dir) => (widget.scan ?? _folders.scan)(dir); 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(() { setState(() {
_open = _lastOpen = dir; _open = _lastOpen = place;
_showHidden = false; _showHidden = false;
}); });
if (dir != null && !_scans.containsKey(dir)) _scan(dir); _scanFor(place);
} }
Future<void> _scan(String dir, {bool rescan = false}) async { Future<void> _scan(String dir, {bool rescan = false}) async {
var f = _running[dir]; var f = _running[dir];
if (f == null || rescan) { if (f == null || rescan) {
f = _running[dir] = _runScan(dir); 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(() {}); if (mounted) setState(() {});
FolderScan result; FolderScan result;
@@ -176,6 +202,26 @@ class _FilesPageState extends State<FilesPage> {
if (mounted) setState(() {}); 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 { Future<void> _addFolder() async {
String? dir; String? dir;
try { try {
@@ -207,7 +253,8 @@ class _FilesPageState extends State<FilesPage> {
void _removeFolder(String dir) { void _removeFolder(String dir) {
_store.removeFolder(dir); _store.removeFolder(dir);
_scans.remove(dir); _scans.remove(dir);
if (_open == dir) _select(null); if (_open == dir) _select(libraryPlace);
setState(() {});
} }
Future<void> _play(String path) async { Future<void> _play(String path) async {
@@ -271,6 +318,22 @@ class _FilesPageState extends State<FilesPage> {
child: Builder( child: Builder(
builder: (context) => GestureDetector(onSecondaryTap: () => _menu(context, path), child: child))); 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 @override
Widget build(BuildContext context) => LayoutBuilder( Widget build(BuildContext context) => LayoutBuilder(
builder: (context, box) { builder: (context, box) {
@@ -278,11 +341,13 @@ class _FilesPageState extends State<FilesPage> {
return SignalBuilder(builder: (context) { return SignalBuilder(builder: (context) {
final folders = _store.folders.value; final folders = _store.folders.value;
final recent = _store.recent.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( final main = fadeThrough(
KeyedSubtree( KeyedSubtree(
key: ValueKey(open ?? ''), key: ValueKey('${open ?? ''}\n${showKey ?? ''}'),
child: _main(context, narrow, folders, recent, open), child: _main(context, narrow, folders, recent, open, showKey),
), ),
); );
if (narrow) return main; if (narrow) return main;
@@ -302,12 +367,23 @@ class _FilesPageState extends State<FilesPage> {
Widget _sidebar(BuildContext context, List<String> folders, String? open) { Widget _sidebar(BuildContext context, List<String> folders, String? open) {
final scheme = Theme.of(context).colorScheme; final scheme = Theme.of(context).colorScheme;
final shape = RoundedRectangleBorder(borderRadius: BorderRadius.circular(10)); 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( return ListView(
padding: const EdgeInsets.fromLTRB(20, 20, 8, 20), padding: const EdgeInsets.fromLTRB(20, 20, 8, 20),
children: [ children: [
const Text('Folders', style: TextStyle(fontWeight: FontWeight.w600)),
const SizedBox(height: 8),
ListTile( 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, dense: true,
selected: open == null, selected: open == null,
shape: shape, shape: shape,
@@ -315,6 +391,9 @@ class _FilesPageState extends State<FilesPage> {
title: const Text('Recent'), title: const Text('Recent'),
onTap: () => _select(null), onTap: () => _select(null),
), ),
const SizedBox(height: 12),
const Text('Folders', style: TextStyle(fontWeight: FontWeight.w600)),
const SizedBox(height: 8),
for (final f in folders) for (final f in folders)
ListTile( ListTile(
key: ValueKey('folder:$f'), key: ValueKey('folder:$f'),
@@ -323,10 +402,7 @@ class _FilesPageState extends State<FilesPage> {
shape: shape, shape: shape,
leading: const Icon(Icons.folder_outlined, size: 18), leading: const Icon(Icons.folder_outlined, size: 18),
title: Text(baseName(f), maxLines: 1, overflow: TextOverflow.ellipsis), title: Text(baseName(f), maxLines: 1, overflow: TextOverflow.ellipsis),
subtitle: switch (_folderStatus(f)) { subtitle: status(_folderStatus(f)),
final s? => Text(s, style: TextStyle(color: scheme.onSurfaceVariant, fontSize: 11)),
null => null,
},
trailing: IconButton( trailing: IconButton(
tooltip: 'Remove folder', tooltip: 'Remove folder',
icon: const Icon(Icons.close, size: 16), icon: const Icon(Icons.close, size: 16),
@@ -364,18 +440,14 @@ class _FilesPageState extends State<FilesPage> {
return n == null ? null : '$n ${n == 1 ? 'video' : 'videos'}'; return n == null ? null : '$n ${n == 1 ? 'video' : 'videos'}';
} }
Widget _main( String? _libraryStatus(List<String> folders) {
BuildContext context, if (folders.any(_running.containsKey)) return 'scanning…';
bool narrow, if (folders.isEmpty || folders.any((f) => !_scans.containsKey(f))) return null;
List<String> folders, final n = _libraryOf(libraryPlace)?.lib.shows.length ?? 0;
List<String> recent, return '$n ${n == 1 ? 'show' : 'shows'}';
String? open, }
) {
final scan = open == null ? null : _scans[open]; List<Widget> _notices() => [
final scanning = open != null && _running.containsKey(open);
final top = <Widget>[
_header(context, open),
if (narrow) _chips(folders, open),
if (!widget.alone && widget.relayed) const _RelayNotice(), if (!widget.alone && widget.relayed) const _RelayNotice(),
AnimatedSize( AnimatedSize(
duration: easeTime, duration: easeTime,
@@ -386,11 +458,59 @@ class _FilesPageState extends State<FilesPage> {
: _ErrorBanner(_error!, onClose: () => setState(() => _error = null)), : _ErrorBanner(_error!, onClose: () => setState(() => _error = null)),
), ),
]; ];
Widget _main(
BuildContext context,
bool narrow,
List<String> folders,
List<String> recent,
String? open,
String? showKey,
) {
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),
..._notices(),
];
final slivers = <Widget>[ final slivers = <Widget>[
SliverList.list(children: top), SliverList.list(children: top),
if (open == null) if (open == null)
..._recent(context, recent) ..._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( const SliverToBoxAdapter(
child: Padding( child: Padding(
padding: EdgeInsets.only(top: 60), padding: EdgeInsets.only(top: 60),
@@ -398,7 +518,7 @@ class _FilesPageState extends State<FilesPage> {
), ),
) )
else else
..._folder(context, open, scan, scanning), ..._library(context, open, lib, scanning),
]; ];
return CustomScrollView( return CustomScrollView(
slivers: [ slivers: [
@@ -412,25 +532,30 @@ class _FilesPageState extends State<FilesPage> {
Widget _header(BuildContext context, String? open) { Widget _header(BuildContext context, String? open) {
final scheme = Theme.of(context).colorScheme; 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( return Padding(
padding: const EdgeInsets.only(bottom: 14), padding: const EdgeInsets.only(bottom: 14),
child: Row( child: Row(
children: [ children: [
Icon(open == null ? Icons.history : Icons.folder, color: scheme.primary), Icon(icon, color: scheme.primary),
const SizedBox(width: 10), const SizedBox(width: 10),
Expanded( Expanded(
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
Text( Text(
open == null ? 'Recent' : baseName(open), title,
maxLines: 1, maxLines: 1,
overflow: TextOverflow.ellipsis, overflow: TextOverflow.ellipsis,
style: Theme.of(context).textTheme.headlineSmall, style: Theme.of(context).textTheme.headlineSmall,
), ),
if (open != null) if (sub != null)
Text( Text(
open, sub,
maxLines: 1, maxLines: 1,
overflow: TextOverflow.ellipsis, overflow: TextOverflow.ellipsis,
style: TextStyle(color: scheme.onSurfaceVariant, fontSize: 12), style: TextStyle(color: scheme.onSurfaceVariant, fontSize: 12),
@@ -449,6 +574,11 @@ class _FilesPageState extends State<FilesPage> {
spacing: 8, spacing: 8,
runSpacing: 8, runSpacing: 8,
children: [ children: [
ChoiceChip(
label: const Text('Library'),
selected: open == libraryPlace,
onSelected: (_) => _select(libraryPlace),
),
ChoiceChip( ChoiceChip(
label: const Text('Recent'), label: const Text('Recent'),
selected: open == null, selected: open == null,
@@ -506,6 +636,7 @@ class _FilesPageState extends State<FilesPage> {
_RecentRow( _RecentRow(
key: ValueKey(path), key: ValueKey(path),
path: path, path: path,
matcher: _matcher,
state: _stateOf(path), state: _stateOf(path),
thumbs: _thumbs, thumbs: _thumbs,
onTap: _tapOf(path), 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 scheme = Theme.of(context).colorScheme;
final lib = _libraries[scan] ??= _Library(scan); final l = lib.lib;
final series = lib.grouped.series.length; final folderScan = place == libraryPlace ? null : _scans[place];
String count(int n, String one, String many) => '$n ${n == 1 ? one : many}'; String count(int n, String one, String many) => '$n ${n == 1 ? one : many}';
final summary = [ final summary = [
count(scan.videos.length, 'video', 'videos') + (series > 0 ? ' in $series series' : ''), count(lib.videos, 'video', 'videos') +
if (scan.hiddenCount > 0) (l.shows.isEmpty ? '' : ' in ${count(l.shows.length, 'show', 'shows')}'),
'${count(scan.hiddenCount, 'other file', 'other files')} hidden', if (lib.hidden > 0) '${count(lib.hidden, 'other file', 'other files')} hidden',
if (lib.subtitleFiles > 0) count(lib.subtitleFiles, 'subtitle file', 'subtitle files'), 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(' · '); ].join(' · ');
final loose = lib.grouped.loose; final folders = _foldersOf(place);
String seriesId(SeriesGroup g) => '$dir\n${g.key}'; final movies = l.movies;
final rows = lib.rows((g) => _expanded.contains(seriesId(g))); final other = l.other;
return [ return [
SliverToBoxAdapter( SliverToBoxAdapter(
child: Wrap( child: Wrap(
@@ -539,7 +681,7 @@ class _FilesPageState extends State<FilesPage> {
Icon(Icons.filter_alt_outlined, size: 16, color: scheme.onSurfaceVariant), Icon(Icons.filter_alt_outlined, size: 16, color: scheme.onSurfaceVariant),
const SizedBox(width: 6), const SizedBox(width: 6),
Text(summary, style: TextStyle(color: scheme.onSurfaceVariant, fontSize: 12)), Text(summary, style: TextStyle(color: scheme.onSurfaceVariant, fontSize: 12)),
if (scan.hiddenCount > 0) ...[ if (folderScan != null && folderScan.hiddenCount > 0) ...[
const SizedBox(width: 8), const SizedBox(width: 8),
TextButton( TextButton(
onPressed: () => setState(() => _showHidden = !_showHidden), onPressed: () => setState(() => _showHidden = !_showHidden),
@@ -547,16 +689,22 @@ class _FilesPageState extends State<FilesPage> {
), ),
], ],
TextButton.icon( TextButton.icon(
onPressed: scanning ? null : () => _scan(dir, rescan: true), onPressed: scanning
? null
: () {
for (final f in folders) {
_scan(f, rescan: true);
}
},
icon: scanning icon: scanning
? const SizedBox.square( ? const SizedBox.square(dimension: 14, child: CircularProgressIndicator(strokeWidth: 2))
dimension: 14, child: CircularProgressIndicator(strokeWidth: 2))
: const Icon(Icons.refresh, size: 16), : const Icon(Icons.refresh, size: 16),
label: Text(scanning ? 'Scanning…' : 'Rescan'), label: Text(scanning ? 'Scanning…' : 'Rescan'),
), ),
], ],
), ),
), ),
if (folderScan != null)
SliverToBoxAdapter( SliverToBoxAdapter(
child: AnimatedSize( child: AnimatedSize(
duration: easeTime, duration: easeTime,
@@ -566,7 +714,7 @@ class _FilesPageState extends State<FilesPage> {
? const SizedBox(width: double.infinity) ? const SizedBox(width: double.infinity)
: Column( : Column(
children: [ children: [
for (final f in scan.hiddenExamples) for (final f in folderScan.hiddenExamples)
ListTile( ListTile(
dense: true, dense: true,
enabled: false, enabled: false,
@@ -577,19 +725,19 @@ class _FilesPageState extends State<FilesPage> {
style: const TextStyle(fontSize: 12), style: const TextStyle(fontSize: 12),
), ),
), ),
if (scan.hiddenCount > scan.hiddenExamples.length) if (folderScan.hiddenCount > folderScan.hiddenExamples.length)
ListTile( ListTile(
dense: true, dense: true,
enabled: false, enabled: false,
title: Text('and ${scan.hiddenCount - scan.hiddenExamples.length} more'), title: Text('and ${folderScan.hiddenCount - folderScan.hiddenExamples.length} more'),
), ),
], ],
), ),
), ),
), ),
if (scan.videos.isEmpty) if (lib.videos == 0)
SliverToBoxAdapter( SliverToBoxAdapter(
child: _folders.folderExists(dir) child: place == libraryPlace || _folders.folderExists(place)
? const FilesEmpty( ? const FilesEmpty(
Icons.search_off, Icons.search_off,
'No videos here', '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.', '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 if (l.shows.isNotEmpty) ...[
// click opens or closes its episodes. SliverToBoxAdapter(child: _section(context, 'Shows', count: '${l.shows.length}')),
SliverList.builder( SliverGrid.builder(
itemCount: rows.length, gridDelegate: posterGrid(),
itemBuilder: (context, i) { itemCount: l.shows.length,
final (group, v) = rows[i]; itemBuilder: (context, i) => _showCard(place, lib, l.shows[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 (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( SliverToBoxAdapter(
child: Padding( child: _section(context, l.shows.isEmpty && movies.isEmpty ? 'Videos' : 'Other videos'),
padding: const EdgeInsets.only(top: 22, bottom: 10),
child: Text(
series == 0 ? 'Videos' : 'Other videos',
style: Theme.of(context).textTheme.titleMedium,
),
),
), ),
SliverLayoutBuilder(builder: (context, c) { SliverLayoutBuilder(builder: (context, c) {
const gap = 12.0; const gap = 12.0;
@@ -654,9 +781,9 @@ class _FilesPageState extends State<FilesPage> {
crossAxisSpacing: gap, crossAxisSpacing: gap,
mainAxisExtent: cardW * 9 / 16 + videoCardTextHeight, mainAxisExtent: cardW * 9 / 16 + videoCardTextHeight,
), ),
itemCount: loose.length, itemCount: other.length,
itemBuilder: (context, i) { itemBuilder: (context, i) {
final v = loose[i]; final v = other[i];
return _menuable( return _menuable(
v.path, v.path,
VideoCard( 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 /// A Recent entry: `Show · E02` over the file name (the library's names
/// title over its folder; greyed when the file is gone. /// when it knows the show), or the cleaned title over its folder; greyed
/// when the file is gone.
class _RecentRow extends StatefulWidget { class _RecentRow extends StatefulWidget {
final String path; final String path;
final ShowMatcher matcher;
final PlayState state; final PlayState state;
final ThumbCache thumbs; final ThumbCache thumbs;
final VoidCallback? onTap; final VoidCallback? onTap;
@@ -685,6 +860,7 @@ class _RecentRow extends StatefulWidget {
const _RecentRow({ const _RecentRow({
super.key, super.key,
required this.path, required this.path,
required this.matcher,
required this.state, required this.state,
required this.thumbs, required this.thumbs,
required this.onTap, required this.onTap,
@@ -711,12 +887,14 @@ class _RecentRowState extends State<_RecentRow> {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final path = widget.path; 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 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( return FileRow(
path: path, 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)), subtitle: missing ? 'File not found' : (episode ? baseName(path) : parentName(path)),
size: _stat == null || missing ? null : formatSize(_stat!.size), size: _stat == null || missing ? null : formatSize(_stat!.size),
state: widget.state, 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 'package:sync_relay_core/core.dart';
import '../library/media_refs.dart';
import '../screens/home/picked_media.dart'; import '../screens/home/picked_media.dart';
import 'accounts.dart'; import 'accounts.dart';
@@ -89,5 +90,6 @@ JellyfinMedia pickFor(JellyfinAccount account, JellyfinItem item) {
title: item.name, title: item.name,
subtitle: subtitle, subtitle: subtitle,
runtime: item.runTime, runtime: item.runTime,
ref: jellyfinMediaRef(item),
); );
} }
@@ -7,6 +7,7 @@ import '../screens/home/picked_media.dart';
import '../session/party.dart' show isPresent; import '../session/party.dart' show isPresent;
import 'accounts.dart'; import 'accounts.dart';
import 'format.dart'; import 'format.dart';
import '../library/media_refs.dart';
// Jellyfin media in a session: turning picks and pasted links into refs // Jellyfin media in a session: turning picks and pasted links into refs
// (the wire carries only `{serverId, itemId, ...}`), the hosted server's // (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, name: m.title.isEmpty ? 'Jellyfin item' : m.title,
size: 0, size: 0,
duration: 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 pick a ref stands for on a linked server (for highlighting it in
/// the browser); null when this install has no account there. /// 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. /// is one.
Future<HelloMedia> mediaForLink(JellyfinAccounts accounts, String url, Future<HelloMedia> mediaForLink(JellyfinAccounts accounts, String url,
{Duration timeout = const Duration(seconds: 6)}) async { {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); final link = parseJellyfinLink(url);
if (link == null) return plain; if (link == null) return plain;
await accounts.ensureLoaded(); await accounts.ensureLoaded();
@@ -103,9 +106,11 @@ Future<HelloMedia> mediaForLink(JellyfinAccounts accounts, String url,
); );
final account = accounts.account(serverId); final account = accounts.account(serverId);
final client = accounts.clientFor(serverId); final client = accounts.clientFor(serverId);
MediaRef? what;
if (account != null && client != null) { if (account != null && client != null) {
try { try {
final pick = pickFor(account, await client.item(link.itemId).timeout(timeout)); final pick = pickFor(account, await client.item(link.itemId).timeout(timeout));
what = pick.ref;
ref = refForPick(JellyfinMedia( ref = refForPick(JellyfinMedia(
serverId: serverId, serverId: serverId,
baseUrl: account.baseUrl, baseUrl: account.baseUrl,
@@ -120,7 +125,11 @@ Future<HelloMedia> mediaForLink(JellyfinAccounts accounts, String url,
} }
} }
return HelloMedia( 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 /// 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'; return uri.host.isNotEmpty ? uri.host : 'link';
} }
/// The hosted server's relay login: the account for [serverId] (the /// The hosted server's relay logins: one per linked Jellyfin account
/// hosted item's server), else the default account, when its token is not /// whose token is not known to be expired (the default account first),
/// known to be expired. The device fields are exactly the ones this app /// so `/j` relays an item with its own server's login. The device fields
/// sends with that token ([JellyfinAccounts.clientInfo]): Jellyfin rewrites /// are exactly the ones this app sends with those tokens
/// a token's device from the request header, so the relay must not differ. /// ([JellyfinAccounts.clientInfo]): Jellyfin rewrites a token's device
JellyfinCredential? relayCredentialFor(JellyfinAccounts accounts, {String? serverId}) { /// from the request header, so the relay must not differ.
bool usable(JellyfinAccount? a) => List<JellyfinCredential> relayCredentialsFor(JellyfinAccounts accounts) {
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;
final info = accounts.clientInfo; final info = accounts.clientInfo;
return JellyfinCredential( final first = accounts.defaultAccount;
baseUrl: a!.baseUrl, 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, serverId: a.serverId,
token: a.token, token: a.token,
client: info.client, client: info.client,
device: info.device, device: info.device,
deviceId: info.deviceId, deviceId: info.deviceId,
version: info.version, version: info.version,
); ),
];
} }
/// `/j/...` URL of the current Jellyfin media on the host this client /// `/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:signals/signals_flutter.dart';
import 'package:sync_relay_core/core.dart'; import 'package:sync_relay_core/core.dart';
import '../files/name_parser.dart';
import '../files/thumbnails.dart'; import '../files/thumbnails.dart';
import '../jellyfin/accounts.dart'; import '../jellyfin/accounts.dart';
import '../jellyfin/format.dart' show pickFor; import '../jellyfin/format.dart' show pickFor;
import '../jellyfin/session_media.dart'; import '../jellyfin/session_media.dart';
import '../library/media_refs.dart';
import '../screens/home/picked_media.dart'; import '../screens/home/picked_media.dart';
import '../session/app_events.dart' show AddedToUpNext; import '../session/app_events.dart' show AddedToUpNext;
import '../session/media_art.dart'; import '../session/media_art.dart';
@@ -164,15 +164,22 @@ class QueueController {
if (bytes.length <= RelayConstants.maxPosterBytes) thumb = bytes; if (bytes.length <= RelayConstants.maxPosterBytes) thumb = bytes;
} catch (_) {} } catch (_) {}
} }
final ep = [ // 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.season != null) 'S${parsed.season}',
if (parsed.episode != null) 'E${parsed.episode}', if (parsed.episode != null) 'E${parsed.episode}',
if (parsed.episode == null) ?parsed.extra,
].join(); ].join();
final title = what != null && what.title.isNotEmpty ? what.title : parsed.series.trim();
return ( return (
item: QueueItem( 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, how: QueueHow.file,
title: parsed.series.trim().isEmpty ? name : parsed.series.trim(), title: title.isEmpty ? name : title,
sub: ep, sub: ep,
duration: (shot?.duration.inMilliseconds ?? 0) / 1000, duration: (shot?.duration.inMilliseconds ?? 0) / 1000,
key: _newKey(), key: _newKey(),
@@ -187,7 +194,7 @@ class QueueController {
if (ref != null) { if (ref != null) {
return ( return (
item: QueueItem( item: QueueItem(
media: HelloMedia(name: linked.name, size: 0, duration: 0, jellyfin: ref), media: linked,
how: QueueHow.jellyfin, how: QueueHow.jellyfin,
title: ref.title.isEmpty ? linked.name : ref.title, title: ref.title.isEmpty ? linked.name : ref.title,
sub: ref.subtitle ?? '', sub: ref.subtitle ?? '',
@@ -234,7 +241,8 @@ class QueueController {
size: 0, size: 0,
duration: 0, duration: 0,
url: how == QueueHow.through ? null : url, 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, how: how,
title: title, title: title,
sub: site, sub: site,
-1
View File
@@ -9,7 +9,6 @@ import 'package:sync_relay_core/core.dart';
import '../controls/controls_probe.dart' show ControlsHold; import '../controls/controls_probe.dart' show ControlsHold;
import '../controls/minimal/style.dart'; import '../controls/minimal/style.dart';
import '../files/scanner.dart' show FileKind, kindOf;
import '../jellyfin/images.dart'; import '../jellyfin/images.dart';
import '../jellyfin/session_media.dart' show EpisodeLink; import '../jellyfin/session_media.dart' show EpisodeLink;
import '../screens/home/picked_media.dart'; import '../screens/home/picked_media.dart';
+219 -3
View File
@@ -8,8 +8,10 @@ import 'package:sync_relay_core/core.dart';
// AniList airing schedule (public GraphQL, no login): every episode airing // AniList airing schedule (public GraphQL, no login): every episode airing
// in a time range, with covers, banners and popularity; and a title search // 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). // (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 // 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 // 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. /// "#e4a15d" -> opaque [Color]; anything else -> null.
Color? parseHexColor(Object? s) { Color? parseHexColor(Object? s) {
if (s is! String || s.length != 7 || !s.startsWith('#')) return null; 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); const _Cached(this.fetched, this.from, this.to, this.raw);
} }
class AniListClient { class AniListClient implements AniListLookup {
AniListClient({ AniListClient({
String url = aniListDefaultUrl, String url = aniListDefaultUrl,
HttpClient? http, 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) { static String _errorText(Object? j) {
final errs = j is Map ? j['errors'] : null; final errs = j is Map ? j['errors'] : null;
if (errs is! List || errs.isEmpty) return ''; 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 /// What the media picker selected. Null selection = nothing (hosting
/// without media is allowed). /// without media is allowed).
sealed class PickedMedia { sealed class PickedMedia {
@@ -72,6 +74,9 @@ final class JellyfinMedia extends PickedMedia {
/// "Series · S1E6" for episodes, the year for movies; may be empty. /// "Series · S1E6" for episodes, the year for movies; may be empty.
final String subtitle; final String subtitle;
final Duration? runtime; 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({ const JellyfinMedia({
required this.serverId, required this.serverId,
required this.baseUrl, required this.baseUrl,
@@ -80,6 +85,7 @@ final class JellyfinMedia extends PickedMedia {
required this.title, required this.title,
this.subtitle = '', this.subtitle = '',
this.runtime, this.runtime,
this.ref,
}); });
@override @override
+13 -1
View File
@@ -7,7 +7,6 @@ import 'package:flutter/foundation.dart';
import 'package:image/image.dart' as img; import 'package:image/image.dart' as img;
import 'package:sync_relay_core/core.dart'; import 'package:sync_relay_core/core.dart';
import '../files/name_parser.dart';
import '../schedule/anilist.dart'; import '../schedule/anilist.dart';
// Art for media this client swapped in (`art`, see PROTOCOL.md "Media // 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; if (!RegExp(r'\p{L}', unicode: true).hasMatch(series)) return null;
final match = await aniList.searchMedia(series); final match = await aniList.searchMedia(series);
if (match == null) return null; 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 { Future<Uint8List?> get(String? url, int maxBytes) async {
final uri = url == null ? null : Uri.tryParse(url); final uri = url == null ? null : Uri.tryParse(url);
return uri == null ? null : fetch(uri, const {}, maxBytes); return uri == null ? null : fetch(uri, const {}, maxBytes);
@@ -71,6 +75,14 @@ Future<ArtBytes?> aniListArt(AniListClient aniList, String name,
return (poster: poster, banner: banner); 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 /// 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 /// bytes, so the same hash: what tells clients it is a thumbnail, see
/// `NowPlaying.thumbnail`); shrunk to a JPEG under the cover cap when /// `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 '../jellyfin/session_media.dart';
import 'now_playing.dart'; import 'now_playing.dart';
@@ -26,12 +26,29 @@ NowPlaying? nowPlayingOf(Map<String, Object?>? media) {
if (web) { if (web) {
return NowPlaying(title: name, posterHash: poster, bannerHash: banner, thumbnail: true); return NowPlaying(title: name, posterHash: poster, bannerHash: banner, thumbnail: true);
} }
final what = _mediaRef(media['ref']);
final (title, episode) = ref != null final (title, episode) = ref != null
? jellyfinTitle(ref.title.isNotEmpty ? ref.title : name, ref.subtitle) ? jellyfinTitle(ref.title.isNotEmpty ? ref.title : name, ref.subtitle)
: what != null && what.title.isNotEmpty
? refTitle(what, name)
: fileTitle(name); : fileTitle(name);
return NowPlaying(title: title, episode: episode, posterHash: poster, bannerHash: banner); 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 /// [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 /// 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 /// 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/progress.dart';
import '../jellyfin/session_media.dart'; import '../jellyfin/session_media.dart';
import '../jellyfin/track_prefs.dart'; import '../jellyfin/track_prefs.dart';
import '../library/media_refs.dart';
import '../playback/media_marks.dart'; import '../playback/media_marks.dart';
import '../playback/playback_controller.dart'; import '../playback/playback_controller.dart';
import '../platform/ports/link_resolver.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); /// Set by the hosting app: makes a local file the hosted one (File swap);
/// null when this app is not the host. /// null when this app is not the host.
Future<({String name, int size})?> Function(String path)? hostFile; 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). /// The app's yt-dlp and link probes (tests pass their own).
final LinkResolver ytDlp; final LinkResolver ytDlp;
/// The link to the server (socket, reconnects, clock offset, its /// The link to the server (socket, reconnects, clock offset, its
@@ -1449,8 +1453,11 @@ class SessionCoordinator {
final uri = thumb == null ? null : Uri.tryParse(thumb); final uri = thumb == null ? null : Uri.tryParse(thumb);
bytes = uri == null ? null : await thumbnailArt(uri); bytes = uri == null ? null : await thumbnailArt(uri);
} else { } 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; 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; final poster = bytes?.poster, banner = bytes?.banner;
if (poster == null && banner == null) return; 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"). // A page is named by its title, never its URL tail ("1", "watch").
final uri = Uri.tryParse(url) ?? Uri(); final uri = Uri.tryParse(url) ?? Uri();
final name = probe?.title ?? (probe != null || hasMediaExtension(uri) || uri.host.isEmpty ? media.name : uri.host); final name = probe?.title ?? (probe != null || hasMediaExtension(uri) || uri.host.isEmpty ? media.name : uri.host);
final what = linkMediaRef(name);
if (relay == LinkRelay.direct) { 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; return null;
} }
if (relay == LinkRelay.me) { if (relay == LinkRelay.me) {
@@ -1526,8 +1534,8 @@ class SessionCoordinator {
await check?.close(); await check?.close();
} }
_pendingOrigin = _LinkOrigin(url, probe, y); _pendingOrigin = _LinkOrigin(url, probe, y);
_swap(HelloMedia( _swap(HelloMedia(name: name, size: size, duration: 0, relay: MediaRelay(tracks: specs), ref: what),
name: name, size: size, duration: 0, relay: MediaRelay(tracks: specs)), link: url, queueId: queueId); link: url, queueId: queueId);
return null; return null;
} }
// The server reads it with its own yt-dlp; only a page this app knows // The server reads it with its own yt-dlp; only a page this app knows
@@ -1537,7 +1545,12 @@ class SessionCoordinator {
return probe.relayProblem; return probe.relayProblem;
} }
_swap(HelloMedia( _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); queueId: queueId);
return null; return null;
} }
@@ -1563,18 +1576,23 @@ class SessionCoordinator {
/// when sent. /// when sent.
Future<String?> swapFile(String path, {String? queueId}) async { Future<String?> swapFile(String path, {String? queueId}) async {
markInput(); markInput();
final what = fileRef?.call(path);
final host = hostFile; final host = hostFile;
if (host != null) { if (host != null) {
final file = await host(path); final file = await host(path);
if (file == null) return 'file not found — check the 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; return null;
} }
final file = File(path); final file = File(path);
if (!await file.exists()) return 'file not found — check the path'; if (!await file.exists()) return 'file not found — check the path';
_pendingOrigin = _FileOrigin(path); _pendingOrigin = _FileOrigin(path);
_swap(HelloMedia( _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); queueId: queueId);
return null; return null;
} }
@@ -8,7 +8,7 @@ import 'package:signals/signals.dart';
import 'package:sync_relay_core/core.dart'; import 'package:sync_relay_core/core.dart';
import '../jellyfin/accounts.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/connect_prefs.dart';
import '../state/server_isolate.dart'; import '../state/server_isolate.dart';
import 'invite.dart'; import 'invite.dart';
@@ -60,7 +60,7 @@ class YourServer {
_interfaces = interfaces ?? machineInterfaces; _interfaces = interfaces ?? machineInterfaces;
final ConnectStore forms; 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 JellyfinAccounts? jellyfin;
final Future<String> Function() _ytDlpDir; final Future<String> Function() _ytDlpDir;
final String? Function() _logDir; final String? Function() _logDir;
@@ -156,8 +156,8 @@ class YourServer {
port: want, port: want,
secret: form.hostSecret, secret: form.hostSecret,
// Guests without an account watch Jellyfin items through the // Guests without an account watch Jellyfin items through the
// server, with this app's own sign-in. // server, with this app's own sign-in on the item's server.
jellyfin: relayCredentialFor(_accounts), jellyfin: relayCredentialsFor(_accounts),
); );
final invite = await inviteFor(bound, password: form.hostSecret.isNotEmpty); final invite = await inviteFor(bound, password: form.hostSecret.isNotEmpty);
shared.value = invite; shared.value = invite;
@@ -159,9 +159,10 @@ class ServerIsolate {
/// Opens the server to the network ([SyncServer.share]): a listener on /// Opens the server to the network ([SyncServer.share]): a listener on
/// every interface at [port], the password [secret], the Jellyfin relay /// 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. /// 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 { Duration timeout = const Duration(seconds: 10)}) async {
if (_exited.isCompleted) throw StateError('the server is not running'); if (_exited.isCompleted) throw StateError('the server is not running');
final reply = ReceivePort('server share'); final reply = ReceivePort('server share');
@@ -170,7 +171,7 @@ class ServerIsolate {
't': 'share', 't': 'share',
'port': port, 'port': port,
'secret': secret, 'secret': secret,
'jellyfin': ?jellyfin?.toMap(), if (jellyfin != null) 'jellyfin': [for (final c in jellyfin) c.toMap()],
'reply': reply.sendPort, 'reply': reply.sendPort,
}); });
final msg = await reply.first.timeout(timeout); final msg = await reply.first.timeout(timeout);
+224 -160
View File
@@ -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:async';
import 'dart:io'; import 'dart:io';
import 'dart:typed_data'; 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/thumbnails.dart';
import 'package:starsync/src/files/ui/file_tiles.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/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'; import 'test_graph.dart';
/// This file's composition root (see test_graph.dart). /// This file's composition root (see test_graph.dart).
final _graph = testGraph(); final _graph = testGraph();
// The Files page over a real (tiny) folder tree: the scan runs in-line // The Files tab as a library over a real (tiny) generated tree with the
// instead of in an isolate and thumbnails come from a fake loader, so // names of a real anime folder: the scan runs in-line instead of in an
// nothing here decodes video. // 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 Directory tmp;
late String anime; late String anime;
late FolderStore store; late FolderStore store;
late List<String> thumbRequests; late List<String> thumbRequests;
late int scans; 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) void touch(String path) => File(path)
..createSync(recursive: true) ..createSync(recursive: true)
@@ -56,6 +87,7 @@ Future<void> pumpPage(
Future<String?> Function(String path)? onPlay, Future<String?> Function(String path)? onPlay,
ThumbnailLoader? thumbnails, ThumbnailLoader? thumbnails,
Future<String?> Function()? pickFolder, Future<String?> Function()? pickFolder,
String? nowPlayingPath,
}) async { }) async {
tester.view.physicalSize = size; tester.view.physicalSize = size;
tester.view.devicePixelRatio = 1; tester.view.devicePixelRatio = 1;
@@ -69,45 +101,62 @@ Future<void> pumpPage(
thumbnails: thumbnails ?? fakeThumb, thumbnails: thumbnails ?? fakeThumb,
scan: inlineScan, scan: inlineScan,
store: store, store: store,
matcher: matcher,
pickFolder: pickFolder, pickFolder: pickFolder,
nowPlayingPath: nowPlayingPath,
), ),
), ),
))); )));
await frames(tester); 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 tester.tap(find.text(name).first);
await frames(tester); await frames(tester);
} }
/// Series start collapsed: a click on the header lists the episodes. /// Scrolls the main list until [f] is on screen.
Future<void> openSeries(WidgetTester tester, [String title = 'Show']) async { Future<void> reveal(WidgetTester tester, Finder f) async {
await tester.tap(find.descendant(of: find.byType(SeriesHeader), matching: find.text(title))); 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); await frames(tester);
} }
void main() { void main() {
setUp(() async { setUp(() async {
tmp = Directory.systemTemp.createTempSync('files_page_'); tmp = Directory(_tmp).createTempSync('files_page_');
anime = '${tmp.path}/anime'; anime = '${tmp.path}/Binux';
for (final f in [ for (final j in ['App', 'Audio', 'Video']) {
'[Grp] Show - 01.mkv', touch('$anime/$b1/$j#D4aIyEEtZEWR9gBorzBWiMh_.lnk');
'[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');
} }
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 // 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. // file I/O left pending in the widget tests' fake-async zone.
await Hive.openBox(filesBoxName, bytes: Uint8List(0)); await Hive.openBox(filesBoxName, bytes: Uint8List(0));
store = FolderStore(); store = FolderStore();
await store.ensureLoaded(); await store.ensureLoaded();
aniList = FakeAniList();
matcher = ShowMatcher(store: MemoryLibraryStore(), aniList: aniList, delay: (_) async {});
thumbRequests = []; thumbRequests = [];
scans = 0; scans = 0;
resetFilesPageSession(); resetFilesPageSession();
@@ -118,102 +167,145 @@ void main() {
tmp.deleteSync(recursive: true); tmp.deleteSync(recursive: true);
}); });
testWidgets('sidebar names folders by their last segment, header follows', (tester) async { testWidgets('the Library first: empty until a folder is added, then its shows', (tester) async {
store.addFolder('$anime/'); await pumpPage(tester, pickFolder: () async => anime);
await pumpPage(tester, pickFolder: () async => '${tmp.path}/movies'); expect(find.text('Library'), findsNWidgets(2)); // sidebar + header
expect(find.text('Folders'), findsOneWidget); expect(find.text('Your library is empty'), findsOneWidget);
expect(find.text('anime'), findsOneWidget); await tester.tap(find.text('Add folder').first);
expect(find.text('Recent'), findsNWidgets(2)); // sidebar + header 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(find.text('Nothing played yet'), findsOneWidget);
expect(scans, 0, reason: 'folders are scanned when opened'); await tester.tap(find.byTooltip('Remove folder'));
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 frames(tester); await frames(tester);
expect(store.folders.value, ['$anime/', '${tmp.path}/movies']); expect(store.folders.value, isEmpty);
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));
}); });
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); store.addFolder(anime);
await pumpPage(tester); await pumpPage(tester);
await openFolder(tester, 'anime'); for (final t in ['Bleach', 'Re.Zero', 'Sword Art Online']) {
expect(find.byType(SeriesHeader), findsOneWidget); expect(find.descendant(of: find.byType(PosterCard), matching: find.text(t)), 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);
} }
expect(find.text('[Grp] Show - 02.mkv'), findsOneWidget); expect(find.text('16 episodes'), findsOneWidget); // Bleach
expect(find.byType(VideoCard), findsOneWidget); expect(find.text('Kimetsu no Yaiba - Mugenjou Hen'), findsOneWidget);
expect(find.text('Some Movie (2019)'), findsOneWidget); expect(find.text('Movie 1 · 1 KB'), findsOneWidget);
expect(find.text('Other videos'), findsOneWidget); expect(find.text('23 videos in 3 shows · 4 other files hidden · 1 subtitle file'), findsOneWidget);
// Durations come from the thumbnail. expect(find.byType(VideoCard), findsNothing, reason: 'nothing is left over');
expect(find.text('24:00 · 1 KB'), findsNWidgets(4));
expect(find.text('24:00'), findsOneWidget); // card badge
await openFolder(tester, 'Recent'); // The folder view says which files were left out.
await openFolder(tester, 'anime'); await openPlace(tester, 'Binux');
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);
await tester.tap(find.text('Show some')); await tester.tap(find.text('Show some'));
await frames(tester); await frames(tester);
expect(find.text('notes.txt'), findsOneWidget); expect(find.text('notes.txt'), findsOneWidget);
expect(find.text('cover.jpg'), findsOneWidget); expect(find.text('App#D4aIyEEtZEWR9gBorzBWiMh_.lnk'), 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);
}); });
testWidgets('rescan picks up new files', (tester) async { testWidgets('a show page: season with its name, episodes, extras', (tester) async {
store.addFolder(anime); store.addFolder(anime);
await pumpPage(tester); await pumpPage(tester);
await openFolder(tester, 'anime'); await openShow(tester, 'Sword Art Online');
await openSeries(tester); expect(find.text('Season 3 · Alicization'), findsOneWidget);
touch('$anime/[Grp] Show - 04.mkv'); for (final e in ['Episode 1', 'Episode 2', 'Episode 3']) {
expect(find.text('E04'), findsNothing); expect(find.text(e), findsOneWidget);
await tester.tap(find.text('Rescan')); }
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); await frames(tester);
expect(scans, 2); expect(find.byType(PosterCard), findsNWidgets(4));
expect(find.text('E04'), findsOneWidget);
expect(find.text('4 episodes · 1 extra · Grp'), findsOneWidget);
}); });
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); store.addFolder(anime);
final played = <String>[]; final played = <String>[];
String? reply; String? reply;
@@ -222,66 +314,39 @@ void main() {
return reply; return reply;
}); });
expect(find.textContaining('Keep the app open while it plays'), findsOneWidget); 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'; 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); 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(find.text('Nobody can play mkv here'), findsOneWidget);
expect(store.recent.value, hasLength(1), reason: 'failed plays are not recent'); expect(store.recent.value, isEmpty);
await openFolder(tester, 'Recent');
expect(find.text('Show · E02'), findsOneWidget);
expect(find.text('[Grp] Show - 02.mkv'), findsOneWidget);
}); });
testWidgets('now playing is marked', (tester) async { testWidgets('now playing is marked on its show and row', (tester) async {
store.addFolder(anime); store.addFolder(anime);
tester.view.physicalSize = const Size(1280, 800); await pumpPage(tester, nowPlayingPath: '$anime/$sao/[Judas] Sword Art Online S3 - 02.mkv');
tester.view.devicePixelRatio = 1; await openShow(tester, 'Sword Art Online');
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);
expect(find.text('now playing'), findsOneWidget); expect(find.text('now playing'), findsOneWidget);
expect(find.byIcon(Icons.graphic_eq), 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/'); store.addFolder('$anime/');
await pumpPage(tester, size: const Size(600, 800)); await pumpPage(tester, size: const Size(600, 800));
expect(find.text('Folders'), findsNothing); expect(find.text('Folders'), findsNothing);
expect(find.widgetWithText(InputChip, 'anime'), findsOneWidget); expect(find.widgetWithText(ChoiceChip, 'Library'), findsOneWidget);
expect(find.widgetWithText(ChoiceChip, 'Recent'), findsOneWidget); expect(find.widgetWithText(ChoiceChip, 'Recent'), findsOneWidget);
await tester.tap(find.widgetWithText(InputChip, 'anime')); expect(find.widgetWithText(InputChip, 'Binux'), findsOneWidget);
await frames(tester); await openShow(tester, 'Re.Zero');
expect(find.text('$anime/'), findsOneWidget); expect(find.text('Season 4'), findsOneWidget);
await openSeries(tester); expect(find.text('16 isn\'t in your folders'), findsNothing);
expect(find.text('E01'), findsOneWidget); expect(find.text('1–16 aren\'t in your folders — start at 17?'), findsOneWidget);
// The chip's x removes the folder, like the sidebar's.
await tester.tap(find.byTooltip('Remove folder')); await tester.tap(find.byTooltip('Remove folder'));
await frames(tester); await frames(tester);
expect(store.folders.value, isEmpty); expect(store.folders.value, isEmpty);
expect(find.widgetWithText(InputChip, 'anime'), findsNothing);
expect(tester.takeException(), isNull); expect(tester.takeException(), isNull);
}); });
@@ -289,53 +354,52 @@ void main() {
testWidgets('no overflow at ${size.width.toInt()}x${size.height.toInt()}', (tester) async { testWidgets('no overflow at ${size.width.toInt()}x${size.height.toInt()}', (tester) async {
store.addFolder(anime); store.addFolder(anime);
for (var i = 0; i < 7; i++) { 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); await pumpPage(tester, size: size, relayed: true);
expect(tester.takeException(), isNull); 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 frames(tester);
await openSeries(tester);
expect(find.text('E01'), findsOneWidget);
expect(tester.takeException(), isNull); 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 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 tester.drag(find.byType(CustomScrollView), const Offset(0, -2000));
await frames(tester); await frames(tester);
expect(tester.takeException(), isNull); expect(tester.takeException(), isNull);
}); });
} }
testWidgets('thumbnails load for visible tiles only, and cancel when scrolled away', testWidgets('thumbnails load for visible rows only, and cancel when scrolled away', (tester) async {
(tester) async {
final big = '${tmp.path}/big'; final big = '${tmp.path}/big';
for (var i = 1; i <= 120; i++) { for (var i = 1; i <= 120; i++) {
touch('$big/Long Show - ${i.toString().padLeft(3, '0')}.mkv'); touch('$big/Long Show - ${i.toString().padLeft(3, '0')}.mkv');
} }
store.addFolder(big); store.addFolder(big);
final gates = <String, bool Function()>{}; final gates = <String, bool Function()>{};
await pumpPage(tester, size: const Size(1100, 700), await pumpPage(tester, size: const Size(1100, 700), thumbnails: (path, {cancelled}) {
thumbnails: (path, {cancelled}) {
thumbRequests.add(path); thumbRequests.add(path);
gates[path] = cancelled!; gates[path] = cancelled!;
return Completer<Thumbnail?>().future; // never finishes return Completer<Thumbnail?>().future; // never finishes
}); });
await openFolder(tester, 'big'); await openShow(tester, 'Long Show');
await openSeries(tester, 'Long Show'); expect(find.text('Episode 1'), findsOneWidget);
expect(find.text('E01'), findsOneWidget);
final first = thumbRequests.toSet(); final first = thumbRequests.toSet();
expect(first, isNotEmpty); expect(first, isNotEmpty);
expect(first.length, lessThan(30)); expect(first.length, lessThan(30));
expect(first, contains('$big/Long Show - 001.mkv')); // (Episode 1's frame is also the header's banner, which stays.)
expect(gates['$big/Long Show - 001.mkv']!(), isFalse); 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.drag(find.byType(CustomScrollView), const Offset(0, -6000));
await tester.pump(); await tester.pump();
await tester.drag(find.byType(CustomScrollView), const Offset(0, -6000)); await tester.drag(find.byType(CustomScrollView), const Offset(0, -6000));
await frames(tester); 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, contains('$big/Long Show - 120.mkv'));
expect(thumbRequests.toSet().length, lessThan(60)); expect(thumbRequests.toSet().length, lessThan(60));
}); });
+48 -40
View File
@@ -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 'dart:io';
import 'package:flutter_test/flutter_test.dart'; import 'package:flutter_test/flutter_test.dart';
import 'package:starsync/src/files/name_parser.dart';
import 'package:starsync/src/files/scanner.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; 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() { void main() {
setUp(() => _root = Directory.systemTemp.createTempSync('files_scan_')); setUp(() => _root = Directory(_tmp).createTempSync('files_scan_'));
tearDown(() => _root.deleteSync(recursive: true)); tearDown(() => _root.deleteSync(recursive: true));
test('classifies, skips hidden and samples, pairs sidecar subtitles', test('classifies, skips hidden and samples, pairs sidecar subtitles',
@@ -69,7 +70,7 @@ void main() {
isFalse); isFalse);
final ep = scan.videos.firstWhere((v) => v.name.contains('- 02')); 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.size, 10);
expect(ep.modified.year, DateTime.now().year); expect(ep.modified.year, DateTime.now().year);
}); });
@@ -115,38 +116,45 @@ void main() {
expect(kindOf('mkv'), FileKind.other); expect(kindOf('mkv'), FileKind.other);
}); });
group('groupBySeries', () { test('a generated tree of the Binux names, scanned and grouped (L1)', () async {
test('groups across spellings, sorts by season then episode', () { const b1 = '[Judas] Bleach 001-055 [BD 1080p][HEVC x265 10bit]';
final g = groupBySeries([ const b2 = '[Judas] Bleach 056-111 [BD 1080p][HEVC x265 10bit]';
_v('[A] Re:Zero S2 - 02 [1080p].mkv'), const b3 = '[Judas] Bleach 112-139 [BD 1080p][HEVC x265 10bit][Dual-Audio][Eng-Subs]';
_v('[A] Re:Zero - 10 [1080p].mkv'), const sao = '[Judas] Sword Art Online (Season 3 - Alicization) [BD 1080p][HEVC x265 10bit][Eng-Subs]';
_v('[B] Re Zero - 02 [720p].mkv'), String n3(int n) => n.toString().padLeft(3, '0');
_v('[A] Re:Zero S2 - 01 [1080p].mkv'), // The first Bleach folder holds 1-byte shortcuts only.
_v('[A] Re:Zero - NCOP1 [1080p].mkv'), for (final j in ['Amine', 'App', 'Audio', 'Data', 'Document', 'Download', 'Image', 'Internal storage', 'Recent', 'Video']) {
_v('Other Show - 03.mkv'), _file('$b1/$j#D4aIyEEtZEWR9gBorzBWiMh_.lnk', size: 1);
_v('Other Show - 01.mkv'), }
_v('Movie.2020.1080p.mkv'), for (final n in [56, 57, 58, 59, 60, 61, 62, 63, 64, 65, 108, 109, 110, 111]) {
_v('vid_2024_final.mp4'), _file('$b2/[Judas] Bleach - ${n3(n)}.mkv');
_v('[A] Lonely - OVA.mkv'), }
]); for (var n = 112; n <= 139; n++) {
expect(g.series.map((s) => s.title), ['Other Show', 'Re:Zero']); _file('$b3/[Judas] Bleach - ${n3(n)}.mkv');
final re = g.series[1]; }
expect(re.episodes.map((v) => v.parsed.label), for (var n = 1; n <= 24; n++) {
['E02', 'E10', 'S02E01', 'S02E02']); _file('$sao/[Judas] Sword Art Online S3 - ${n.toString().padLeft(2, '0')}.mkv');
expect(re.seasons, [1, 2]); }
expect(re.extras.single.parsed.extra, 'NCOP1'); for (final x in ['Opening 1', 'Opening 2', 'Opening 3', 'Ending 1', 'Ending 2']) {
expect(g.series[0].episodes.map((v) => v.parsed.episode), [1, 3]); _file('$sao/[Judas] Sword Art Online S3 - $x.mkv');
expect(g.loose.map((v) => v.name), [ }
'[A] Lonely - OVA.mkv', _file('[Judas] Re.Zero - S04E17.mkv');
'Movie.2020.1080p.mkv', _file('[Erai-raws] Kimetsu no Yaiba - Mugenjou Hen - Movie 01 [1080p CR WEBRip HEVC AAC][MultiSub][8E47462C].mkv');
'vid_2024_final.mp4',
]);
});
test('empty input', () { final scan = await scanFolder(_root.path);
final g = groupBySeries(const []); expect(scan.videos, hasLength(14 + 28 + 24 + 5 + 2));
expect(g.series, isEmpty); expect(scan.hiddenCount, 10, reason: 'the shortcuts are not videos');
expect(g.loose, isEmpty); 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);
}); });
} }
+5 -6
View File
@@ -193,9 +193,10 @@ void main() {
expect(plain.name, 'movie.mkv'); 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 (accounts, _) = await linkedAccounts();
final cred = relayCredentialFor(accounts, serverId: fixtureServerId)!; final creds = relayCredentialsFor(accounts);
final cred = creds.single;
final info = accounts.clientInfo; final info = accounts.clientInfo;
expect(cred.token, 'TEST_ACCESS_TOKEN'); expect(cred.token, 'TEST_ACCESS_TOKEN');
expect(cred.serverId, fixtureServerId); expect(cred.serverId, fixtureServerId);
@@ -203,13 +204,11 @@ void main() {
expect([cred.client, cred.device, cred.deviceId, cred.version], expect([cred.client, cred.device, cred.deviceId, cred.version],
[info.client, info.device, info.deviceId, info.version]); [info.client, info.device, info.deviceId, info.version]);
expect(cred.device, 'Test PC'); 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); accounts.markExpired(fixtureServerId);
expect(relayCredentialFor(accounts, serverId: fixtureServerId), isNull); expect(relayCredentialsFor(accounts), isEmpty);
final none = JellyfinAccounts(store: MemoryJellyfinStore()); final none = JellyfinAccounts(store: MemoryJellyfinStore());
await none.ensureLoaded(validate: false); await none.ensureLoaded(validate: false);
expect(relayCredentialFor(none), isNull); expect(relayCredentialsFor(none), isEmpty);
}); });
}); });
+91
View File
@@ -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']);
});
});
}
+301
View File
@@ -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
View File
@@ -7,7 +7,7 @@ Plain overview first: [HOW-IT-WORKS.md](HOW-IT-WORKS.md). Wire format:
| Path | What | Rule | | 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 | | `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` | | `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 | | `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 - **Listeners**: `SyncServer.share(port, secret, jellyfin)` attaches a
second listener on every IPv4 interface at the port from Settings → second listener on every IPv4 interface at the port from Settings →
Party & network (default 8080) to the running server (Relic serves the Party & network (default 8080) to the running server (Relic serves the
same routes on both), sets the password and the Jellyfin relay login same routes on both), sets the password and the Jellyfin relay logins
(the default account's, so guests without an account watch Jellyfin (one per linked account, `relayCredentialsFor`, so guests without an
items through the server); `unshare` closes it. Nothing restarts for 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 either: members on the loopback listener keep their sockets, the media
and Up next stay. Unsharing sends everyone but the host out: each and Up next stay. Unsharing sends everyone but the host out: each
leaves at once (feed `leave`, reason `closed`) and its socket closes 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 (`AppGraph.build`) and constructs and disposes the app's systems: the
`Ports`, the sounds (`SynthSfx`), the `Notifier`, the `Connection`, the `Ports`, the sounds (`SynthSfx`), the `Notifier`, the `Connection`, the
`SessionCoordinator`, the connect form and recents (`ConnectStore`, `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 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 has no logic or signals of its own. Widgets reach it through one
`AppScope` InheritedWidget (`AppScope.of(context)`, readable in `AppScope` InheritedWidget (`AppScope.of(context)`, readable in
@@ -653,20 +656,22 @@ Gotchas (Jellyfin 10.11):
## Jellyfin relay ## Jellyfin relay
For viewers without an account on the item's Jellyfin For viewers without an account on the item's Jellyfin
(`packages/server/lib/src/jellyfin_relay.dart`). The server may hold one (`packages/server/lib/src/jellyfin_relay.dart`). The server holds its
`JellyfinCredential` (config key `jellyfin`: `baseUrl`, `serverId`, `token`, logins as `JellyfinCredential`s (`ServerHub.jellyfinLogins`, at most one
`client`, `device`, `deviceId`, `version`; primitives, so it crosses the per Jellyfin `serverId`; each: `baseUrl`, `serverId`, `token`, `client`,
isolate boundary in the `serve()` map). The app sends it when your server `device`, `deviceId`, `version`; primitives, so they cross the isolate
is shared (`share`): its own app-obtained token and that token's device boundary). The app sends one per linked account (not known expired, the
fields; the dedicated server default first) when your server is shared (`share`): its own
reads it from `--jellyfin <file>` (YAML/JSON with those keys, keeps the 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 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 `serverId` is looked up at start (`/System/Info/Public`); if that fails
relay stays off. The token is never logged (`toString` and config parse that login stays unused. Tokens are never logged (`toString` and config
errors leave it out). parse errors leave them out).
- Available (`state.relay`) only while the current media is a ref whose - Available (`state.relay`) only while the current media is a ref on a
`serverId` equals the credential's; `state.relayNext` likewise for Up 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 next's first item, which `/j` also serves so guests can preload it
(see "Getting ready" under "Up next"). Only those two items are (see "Getting ready" under "Up next"). Only those two items are
relayed, only to a valid viewer token: no open proxy. 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 in order, a link in the Link page's mode). All go through
`QueueController.add`. None of it offline. `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 ## App events and platform ports
**Status.** What the session is like right now is one computed value, **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 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 probed again and shows why, with "Put it up anyway" when it is a
problem. Files problem. Files
is `FilesPage` (`files/`): watched folders (`FolderStore`) scanned in is `FilesPage` (`files/`), a library over the watched folders
an isolate, videos grouped by series; series start collapsed, a header (`FolderStore`, each scanned in an isolate when first needed): places
click opens one and open ones stay open for the app session on the left (chips when narrow): Library (every folder together, the
(`showFilesFolder` opens a folder from outside). With nobody else 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 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, 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 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 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 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 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. release name) and sends `art`, so guests without Jellyfin get covers too.
`coordinator.nowPlaying` derives title, episode line and art hashes from `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 (`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) 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 and none on the start card. Art up: poster and banner of one hash mark
+5 -2
View File
@@ -79,7 +79,9 @@ Three suites, run from each package's folder:
- `cd packages/core && dart test` (a few seconds): protocol round-trips, - `cd packages/core && dart test` (a few seconds): protocol round-trips,
the sync engine (clock, gate, presence and grace, correction, end of 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 relay pieces, updater, logging redaction, watch rule, yt-dlp and
Jellyfin client parsing against captured fixtures. Jellyfin client parsing against captured fixtures.
- `cd packages/server && dart test` (well under a minute): live servers - `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 under the same members and Up next, the host key standing in for the
password). password).
- `cd apps/desktop && flutter test test/<file>`: widgets (shell, library, - `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 shader list, sound engine with a fake backend, invite addresses, the
version gate), `singletons_test` (the `.instance` count, see version gate), `singletons_test` (the `.instance` count, see
ARCHITECTURE "Composition root"; lower its baseline when a use goes) and the real-mpv `*_e2e` ARCHITECTURE "Composition root"; lower its baseline when a use goes) and the real-mpv `*_e2e`
+4 -2
View File
@@ -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 is (the server drops non-integer ids and cuts `title` to 200
characters). `kind` `episode`, `movie` or `extra` (else no ref); every characters). `kind` `episode`, `movie` or `extra` (else no ref); every
other field optional; `season` absent for absolute numbering. At most other field optional; `season` absent for absolute numbering. At most
2 KiB of JSON, else `err` `bad`. **Reserved**: nothing produces it yet 2 KiB of JSON, else `err` `bad`. Apps send it with swaps and Up next
(step 4). 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 ## Avatars
+20 -12
View File
@@ -61,9 +61,6 @@ Problems are numbered; delete a line when its fix ships.
the `close:` log lines. the `close:` log lines.
- P33 No volume readout on wheel: likely fixed by the controls rework - 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. (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 **Restructure** (in order; reviewed against the code). Truth lives in
three places: the server (members, presence, gate, transport, queue incl. 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, (client id, library and folders, match corrections, watch history,
settings, notification prefs, what this app is sourcing) and the settings, notification prefs, what this app is sourcing) and the
integrations (Jellyfin played/resume, AniList list). 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, 5. Media pipeline + player host port: plan → resolve → open → ready,
commands queued during a switch, viewer-side recovery when a source commands queued during a switch, viewer-side recovery when a source
drops (no mpv seek loops, play what's buffered), resolver and 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 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 members card only says friends elsewhere need the port forwarded. Your
server's `/health` name is the profile name at start (a rename shows server's `/health` name is the profile name at start (a rename shows
after a restart). Guests' Jellyfin relay uses the default account (no after a restart). Joining a friend while others are on
longer the put-up item's server). Joining a friend while others are on
your server leaves them there without you, unwarned; a join refused your server leaves them there without you, unwarned; a join refused
after dialling (wrong password) costs your own member there (`bye`, back 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 as a new member). Your server writes its session log even when you are
alone. 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 Left of step 2: system notifications need a human check (Linux: the
notification shows and a click brings the window up, also on Wayland, notification shows and a click brings the window up, also on Wayland,
where focus may be refused without an activation token; Windows: toasts 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 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 parsed and broadcast but never started; `auto` items and `cancel` are
live. live.
- `media.ref` producers (step 4); the server carries it already.
- `hello.client` optionally derived per server (HMAC of an install - `hello.client` optionally derived per server (HMAC of an install
secret and the dialled host:port). secret and the dialled host:port).
- TLS: `/health.tls` is always false; `wss://` only with a CA-valid - 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 seeks are shared, and only real ranges (segments, OP/ED chapters) should
ever be skipped. ever be skipped.
2. **Files**: next episode from Files (by parsed episode, with the 2. **Files**: the next episode of a local show (the library's
countdown, and under Up next's "Continues with"). Later: tracking for `nextAfter`, gaps included) at the end of one, with the countdown, and
file series. 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 3. **yt-dlp follow-ups** (see ARCHITECTURE "yt-dlp"): check that the
bundled Windows libmpv has the ytdl hook (Lua) and plays `edl://` URLs bundled Windows libmpv has the ytdl hook (Lua) and plays `edl://` URLs
+2
View File
@@ -22,3 +22,5 @@ export 'src/jellyfin/http.dart';
export 'src/jellyfin/links.dart'; export 'src/jellyfin/links.dart';
export 'src/jellyfin/models.dart'; export 'src/jellyfin/models.dart';
export 'src/jellyfin/track_match.dart'; export 'src/jellyfin/track_match.dart';
export 'src/library/local_library.dart';
export 'src/library/release_name.dart';
+1 -1
View File
@@ -183,7 +183,7 @@ class JellyfinClient {
/// Fields requested for list items: enough for cards and detail headers. /// Fields requested for list items: enough for cards and detail headers.
static const listFields = static const listFields =
'PrimaryImageAspectRatio,Overview,ChildCount,RecursiveItemCount,DateCreated'; 'PrimaryImageAspectRatio,Overview,ChildCount,RecursiveItemCount,DateCreated,ProviderIds';
static const _imageParams = { static const _imageParams = {
'enableImageTypes': 'Primary,Backdrop,Thumb,Logo', 'enableImageTypes': 'Primary,Backdrop,Thumb,Logo',
'imageTypeLimit': '1', 'imageTypeLimit': '1',
@@ -232,6 +232,10 @@ class JellyfinItem {
final String? parentLogoItemId; final String? parentLogoItemId;
final String? parentLogoImageTag; 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. /// Present on full item responses (`getItem`) for playable items.
final List<JellyfinMediaSource> mediaSources; final List<JellyfinMediaSource> mediaSources;
/// Streams of the default source; present on `getItem` and when /// Streams of the default source; present on `getItem` and when
@@ -273,6 +277,7 @@ class JellyfinItem {
this.parentThumbImageTag, this.parentThumbImageTag,
this.parentLogoItemId, this.parentLogoItemId,
this.parentLogoImageTag, this.parentLogoImageTag,
this.providerIds = const {},
this.mediaSources = const [], this.mediaSources = const [],
this.mediaStreams = const [], this.mediaStreams = const [],
}); });
@@ -314,12 +319,26 @@ class JellyfinItem {
parentThumbImageTag: _str(j['ParentThumbImageTag']), parentThumbImageTag: _str(j['ParentThumbImageTag']),
parentLogoItemId: _str(j['ParentLogoItemId']), parentLogoItemId: _str(j['ParentLogoItemId']),
parentLogoImageTag: _str(j['ParentLogoImageTag']), 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(), mediaSources: _list(j['MediaSources']).map(JellyfinMediaSource.fromJson).toList(),
mediaStreams: _list(j['MediaStreams']).map(JellyfinMediaStream.fromJson).toList(), mediaStreams: _list(j['MediaStreams']).map(JellyfinMediaStream.fromJson).toList(),
); );
JellyfinItemKind get kind => JellyfinItemKind.parse(type); 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!); Duration? get runTime => runTimeTicks == null ? null : ticksToDuration(runTimeTicks!);
/// "S2E12" for episodes with both numbers, else null. /// "S2E12" for episodes with both numbers, else null.
@@ -0,0 +1,549 @@
import 'release_name.dart';
// L1 of the library (ARCHITECTURE.md "Library"): the videos found in the
// user's folders grouped into shows, offline and deterministic. A file's
// own name wins; the folders above it (up to the watched folder) are
// lower-priority hints for the title, the season, the season's name and
// an episode range. One show spread over several folders (`Bleach
// 001-055`, `Bleach 056-111`) is one show. Numbering is kept as found
// (absolute or per season) and holes in it are known, so "next" can say
// what is missing. Matching to AniList (L2) is the app's.
const videoExtensions = {
'mkv', 'mp4', 'webm', 'avi', 'mov', 'm4v', 'ts', 'm2ts', 'wmv', 'flv', //
};
const subtitleExtensions = {'ass', 'ssa', 'srt', 'vtt', 'sup'};
enum FileKind { video, subtitle, other }
FileKind kindOf(String path) {
final ext = _extension(_baseName(path));
if (videoExtensions.contains(ext)) return FileKind.video;
if (subtitleExtensions.contains(ext)) return FileKind.subtitle;
return FileKind.other;
}
/// A file found in a folder.
class LocalFile {
final String path;
final int size;
final DateTime modified;
const LocalFile(this.path, this.size, this.modified);
String get name => _baseName(path);
/// The folder it is in.
String get folder => _parent(path);
}
/// A run of episode numbers that are not in the folders (`66–107`).
class EpisodeGap {
final int first;
final int last;
const EpisodeGap(this.first, this.last);
int get length => last - first + 1;
/// `66–107`, or `5` for one.
String get label => first == last ? '$first' : '$first–$last';
@override
bool operator ==(Object other) => other is EpisodeGap && other.first == first && other.last == last;
@override
int get hashCode => Object.hash(first, last);
@override
String toString() => 'EpisodeGap($label)';
}
/// What a local file is in its show.
enum LocalKind { episode, movie, extra, other }
/// One video as L1 sees it.
class LocalEpisode {
final LocalFile file;
/// The file name's own reading.
final ParsedName parsed;
final LocalKind kind;
/// Its show ([LocalShow.key]); '' for [LocalKind.other].
final String showKey;
/// Display title of what it belongs to (file name, else folder).
final String title;
/// File's season, else the folder's; null: absolute or none.
final int? season;
/// The season's own name (`Alicization`), from a parenthetical.
final String? seasonName;
final int? episode;
/// `OP1`, `NCED2`, `OVA 2` for extras.
final String? extra;
/// `Movie 2`'s number.
final int? movieNumber;
const LocalEpisode({
required this.file,
required this.parsed,
required this.kind,
required this.showKey,
required this.title,
this.season,
this.seasonName,
this.episode,
this.extra,
this.movieNumber,
});
String get path => file.path;
String get name => file.name;
String get folder => file.folder;
/// Stable across rescans: the show, season and number (or the extra,
/// the movie, or the path for anything else). Two copies of one
/// episode share it.
String get key => switch (kind) {
LocalKind.episode => '$showKey|s${season ?? ''}|e$episode',
LocalKind.extra => '$showKey|s${season ?? ''}|x${extra!.toLowerCase()}',
LocalKind.movie => '$showKey|m${movieNumber ?? ''}',
LocalKind.other => '|f|${file.path}',
};
/// `E056`, `S03E01`, `OP1`, `Movie 1`, else ''.
String get label => switch (kind) {
LocalKind.episode => [
if (season != null) 'S${season.toString().padLeft(2, '0')}',
'E${episode.toString().padLeft(2, '0')}',
].join(),
LocalKind.extra => extra!,
LocalKind.movie => movieNumber == null ? 'Movie' : 'Movie $movieNumber',
LocalKind.other => '',
};
@override
String toString() => 'LocalEpisode($key, ${file.name})';
}
/// One season of a show as found: a numbered season, or the absolute run
/// ([number] null).
class LocalSeason {
final int? number;
final String? name;
/// In episode order; a second copy of an episode follows the first
/// (newest release name first).
final List<LocalEpisode> episodes;
/// Numbers before, between and (by a folder's range) after the
/// episodes that are not there.
final List<EpisodeGap> gaps;
const LocalSeason(this.number, this.name, this.episodes, this.gaps);
bool get absolute => number == null;
/// One file per episode number (the first copy), in order.
List<LocalEpisode> get distinct {
final seen = <int>{};
return [
for (final e in episodes)
if (seen.add(e.episode!)) e
];
}
/// "Season 3 · Alicization", "Season 1", "Episodes".
String get title {
if (number == null) return name ?? 'Episodes';
return name == null ? 'Season $number' : 'Season $number · $name';
}
}
final _episodeKey = RegExp(r'\|s(\d*)\|e(\d+)$');
/// What to play after an episode: the next one there is, and the
/// numbers skipped to reach it (not in the folders).
class LocalNext {
final LocalEpisode episode;
final EpisodeGap? skipped;
const LocalNext(this.episode, {this.skipped});
}
/// A show found in the folders.
class LocalShow {
/// [normalizeSeries] of its title: stable across rescans and folders.
final String key;
/// The most common spelling among its files.
final String title;
final List<LocalSeason> seasons;
/// OP/ED, OVA, specials: by season, then kind, then number.
final List<LocalEpisode> extras;
/// Its movies (`Title - Movie 2`).
final List<LocalEpisode> movies;
/// The folders its files are in, sorted.
final List<String> folders;
/// The most common release group.
final String? group;
const LocalShow({
required this.key,
required this.title,
required this.seasons,
this.extras = const [],
this.movies = const [],
this.folders = const [],
this.group,
});
List<LocalEpisode> get episodes => [for (final s in seasons) ...s.episodes];
/// Every file of the show: episodes, extras, movies.
Iterable<LocalEpisode> get files sync* {
yield* episodes;
yield* extras;
yield* movies;
}
int get episodeCount => seasons.fold(0, (n, s) => n + s.distinct.length);
LocalSeason? season(int? number) => seasons.where((s) => s.number == number).firstOrNull;
/// The episode after [lastKey] (a [LocalEpisode.key]; null: nothing
/// watched yet, so the first one), with the numbers skipped on the way;
/// null when there is nothing after it. The next season follows the
/// last episode of one.
LocalNext? nextAfter(String? lastKey) {
final all = [
for (final s in seasons)
for (final e in s.distinct) (s, e)
];
if (all.isEmpty) return null;
if (lastKey == null) {
final (s, e) = all.first;
final lead = s.gaps.where((g) => g.last < e.episode!).firstOrNull;
return LocalNext(e, skipped: lead);
}
final at = all.indexWhere((p) => p.$2.key == lastKey);
if (at < 0) {
// Gone from the folders: the first one after its number.
final m = _episodeKey.firstMatch(lastKey);
if (m == null) return null;
final season = m.group(1)!.isEmpty ? null : int.parse(m.group(1)!);
final ep = int.parse(m.group(2)!);
final next = all.indexWhere((p) => p.$1.number == season && p.$2.episode! > ep);
if (next < 0) return null;
final (s, e) = all[next];
return LocalNext(e, skipped: e.episode! > ep + 1 ? EpisodeGap(ep + 1, e.episode! - 1) : null);
}
if (at + 1 >= all.length) return null;
final (s0, e0) = all[at];
final (s1, e1) = all[at + 1];
if (!identical(s0, s1)) {
final lead = s1.gaps.where((g) => g.last < e1.episode!).firstOrNull;
return LocalNext(e1, skipped: lead);
}
final from = e0.episode! + 1, to = e1.episode! - 1;
return LocalNext(e1, skipped: to >= from ? EpisodeGap(from, to) : null);
}
@override
String toString() => 'LocalShow($key, ${seasons.length} seasons)';
}
/// The folders' videos, grouped.
class LocalLibrary {
final List<LocalShow> shows;
/// Movies of no show found here.
final List<LocalEpisode> movies;
/// Videos that are not episodes, extras of a show or movies.
final List<LocalEpisode> other;
/// Files left out: not videos (`.lnk`, `.nfo`, pictures).
final int ignored;
const LocalLibrary({
this.shows = const [],
this.movies = const [],
this.other = const [],
this.ignored = 0,
});
static const empty = LocalLibrary();
LocalShow? show(String key) => shows.where((s) => s.key == key).firstOrNull;
/// Every video of the library.
Iterable<LocalEpisode> get files sync* {
for (final s in shows) {
yield* s.files;
}
yield* movies;
yield* other;
}
LocalEpisode? byPath(String path) => files.where((e) => e.path == path).firstOrNull;
}
/// The show and season a file names, with its folders as hints.
LocalEpisode readLocalFile(LocalFile file, {Iterable<String> roots = const []}) {
final p = parseReleaseName(file.name);
final hints = [for (final d in _foldersBelow(file.path, roots)) parseFolderName(d)];
FolderHint? first(bool Function(FolderHint h) test) => hints.where(test).firstOrNull;
final titled = first((h) => h.title.isNotEmpty && RegExp(r'\p{L}', unicode: true).hasMatch(h.title));
final seasonHint = first((h) => h.season != null);
final nearest = hints.firstOrNull;
var kind = switch (p.kind) {
ReleaseKind.episode => LocalKind.episode,
ReleaseKind.extra => LocalKind.extra,
ReleaseKind.movie => LocalKind.movie,
ReleaseKind.other => LocalKind.other,
};
String? extra = p.extra;
var ownTitle = p.series.trim();
// A file in an `Extras` / `NCOP` folder is an extra of the show above;
// a name that says nothing else (`Creditless Opening 1`) is its label.
if (nearest != null && nearest.extras && kind != LocalKind.movie) {
if (kind == LocalKind.episode) extra = 'Extra ${p.episode}';
if (kind == LocalKind.other) {
extra = ownTitle;
ownTitle = '';
}
kind = LocalKind.extra;
}
final usesFolder = kind != LocalKind.other && ownTitle.isEmpty;
final title = usesFolder && titled != null ? titled.title : ownTitle;
final season = p.season ?? (kind == LocalKind.movie ? null : seasonHint?.season);
final seasonName = p.subtitle ?? (season != null && seasonHint?.season == season ? seasonHint?.subtitle : null);
return LocalEpisode(
file: file,
parsed: p,
kind: kind,
showKey: kind == LocalKind.other ? '' : normalizeSeries(title),
title: title.isEmpty ? file.name : title,
season: season,
seasonName: seasonName,
episode: kind == LocalKind.episode ? p.episode : null,
extra: kind == LocalKind.extra ? (extra ?? 'Extra') : null,
movieNumber: kind == LocalKind.movie ? p.movieNumber : null,
);
}
/// Groups [files] (any kind; only videos are kept) into shows. [roots]:
/// the watched folders the files were found under; folders below them
/// give hints, the root itself last.
LocalLibrary groupLocal(Iterable<LocalFile> files, {Iterable<String> roots = const []}) {
var ignored = 0;
final read = <LocalEpisode>[];
for (final f in files) {
if (kindOf(f.path) != FileKind.video) {
ignored++;
continue;
}
read.add(readLocalFile(f, roots: roots));
}
final byKey = <String, List<LocalEpisode>>{};
final extras = <String, List<LocalEpisode>>{};
final movies = <String, List<LocalEpisode>>{};
final other = <LocalEpisode>[];
for (final e in read) {
if (e.kind != LocalKind.other && e.showKey.isEmpty) {
other.add(e);
continue;
}
switch (e.kind) {
case LocalKind.episode:
byKey.putIfAbsent(e.showKey, () => []).add(e);
case LocalKind.extra:
extras.putIfAbsent(e.showKey, () => []).add(e);
case LocalKind.movie:
movies.putIfAbsent(e.showKey, () => []).add(e);
case LocalKind.other:
other.add(e);
}
}
// Ranges in folder names (`Bleach 112-150`) say where a run should end.
final ranges = <String, int>{};
for (final e in read) {
if (e.kind != LocalKind.episode) continue;
final h = parseFolderName(_baseName(e.folder));
final last = h.lastEpisode;
if (last == null || h.key != e.showKey && h.key.isNotEmpty) continue;
final k = '${e.showKey}|${e.season}';
if (last > (ranges[k] ?? 0)) ranges[k] = last;
}
final shows = <LocalShow>[];
for (final entry in byKey.entries) {
final key = entry.key;
final eps = entry.value;
final bySeason = <int?, List<LocalEpisode>>{};
for (final e in eps) {
bySeason.putIfAbsent(e.season, () => []).add(e);
}
final numbers = bySeason.keys.toList()..sort(_nullFirst);
final seasons = [
for (final n in numbers) _season(n, bySeason[n]!, ranges['$key|$n']),
];
final x = (extras.remove(key) ?? [])..sort(_byExtra);
final m = (movies.remove(key) ?? [])..sort(_byMovie);
final all = [...eps, ...x, ...m];
shows.add(LocalShow(
key: key,
title: _mostCommon([for (final e in eps) e.title])!,
seasons: seasons,
extras: x,
movies: m,
folders: ({for (final e in all) e.folder}.toList()..sort()),
group: _mostCommon([
for (final e in all)
if (e.parsed.group != null) e.parsed.group!
]),
));
}
shows.sort((a, b) {
final t = a.title.toLowerCase().compareTo(b.title.toLowerCase());
return t != 0 ? t : a.key.compareTo(b.key);
});
// Extras of no show here are just videos; movies of none stand alone.
for (final list in extras.values) {
other.addAll(list);
}
final standalone = [for (final list in movies.values) ...list]..sort((a, b) {
final t = a.title.toLowerCase().compareTo(b.title.toLowerCase());
return t != 0 ? t : _byMovie(a, b);
});
other.sort(_byName);
return LocalLibrary(shows: shows, movies: standalone, other: other, ignored: ignored);
}
LocalSeason _season(int? number, List<LocalEpisode> eps, int? rangeEnd) {
eps.sort(_byEpisode);
final present = <int>{for (final e in eps) e.episode!}.toList()..sort();
final gaps = <EpisodeGap>[];
var expect = 1;
for (final n in present) {
if (n > expect) gaps.add(EpisodeGap(expect, n - 1));
expect = n + 1;
}
if (rangeEnd != null && rangeEnd >= expect) gaps.add(EpisodeGap(expect, rangeEnd));
return LocalSeason(
number,
_mostCommon([
for (final e in eps)
if (e.seasonName != null) e.seasonName!
]),
eps,
gaps);
}
int _nullFirst(int? a, int? b) {
if (a == b) return 0;
if (a == null) return -1;
if (b == null) return 1;
return a.compareTo(b);
}
int _byName(LocalEpisode a, LocalEpisode b) {
final t = a.name.toLowerCase().compareTo(b.name.toLowerCase());
return t != 0 ? t : a.path.compareTo(b.path);
}
int _byEpisode(LocalEpisode a, LocalEpisode b) {
final e = a.episode!.compareTo(b.episode!);
if (e != 0) return e;
// Same episode twice (v2, another group): newest release name first.
final n = b.name.compareTo(a.name);
return n != 0 ? n : a.path.compareTo(b.path);
}
final _extraNumber = RegExp(r'(\d+)$');
int _byExtra(LocalEpisode a, LocalEpisode b) {
final s = _nullFirst(a.season, b.season);
if (s != 0) return s;
String word(LocalEpisode e) => e.extra!.replaceFirst(_extraNumber, '').trim().toLowerCase();
int number(LocalEpisode e) => int.tryParse(_extraNumber.firstMatch(e.extra!)?.group(1) ?? '') ?? 0;
final w = _extraOrder(word(a)).compareTo(_extraOrder(word(b)));
if (w != 0) return w;
final ww = word(a).compareTo(word(b));
if (ww != 0) return ww;
final n = number(a).compareTo(number(b));
return n != 0 ? n : _byName(a, b);
}
// OP before ED (creditless with them), then the rest by name.
int _extraOrder(String word) => switch (word) {
'op' || 'ncop' => 0,
'ed' || 'nced' => 1,
_ => 2,
};
int _byMovie(LocalEpisode a, LocalEpisode b) {
final n = (a.movieNumber ?? 0).compareTo(b.movieNumber ?? 0);
return n != 0 ? n : _byName(a, b);
}
/// The most frequent of [values]; ties go to the first in sort order.
String? _mostCommon(Iterable<String> values) {
final counts = <String, int>{};
for (final v in values) {
counts.update(v, (n) => n + 1, ifAbsent: () => 1);
}
if (counts.isEmpty) return null;
final keys = counts.keys.toList()..sort();
var best = keys.first;
for (final k in keys) {
if (counts[k]! > counts[best]!) best = k;
}
return best;
}
/// Folder names between the watched folder [roots] holding [path] and
/// the file, nearest first, then the watched folder's own name; just the
/// parent when no root holds it.
List<String> _foldersBelow(String path, Iterable<String> roots) {
final parts = _split(path);
if (parts.length < 2) return const [];
final dirs = parts.sublist(0, parts.length - 1);
List<String>? best;
for (final r in roots) {
final rp = _split(r);
if (rp.length > dirs.length) continue;
var ok = true;
for (var i = 0; i < rp.length; i++) {
if (rp[i] != dirs[i]) {
ok = false;
break;
}
}
if (ok && (best == null || rp.length > best.length)) best = rp;
}
if (best == null) return [dirs.last];
final below = dirs.sublist(best.length).reversed.toList();
return [...below, if (best.isNotEmpty) best.last];
}
List<String> _split(String path) => path.split(RegExp(r'[/\\]')).where((p) => p.isNotEmpty).toList();
String _baseName(String path) {
final parts = _split(path);
return parts.isEmpty ? path : parts.last;
}
String _parent(String path) {
final i = path.lastIndexOf(RegExp(r'[/\\]'));
return i <= 0 ? (i == 0 ? path.substring(0, 1) : '') : path.substring(0, i);
}
String _extension(String name) {
final dot = name.lastIndexOf('.');
return dot <= 0 ? '' : name.substring(dot + 1).toLowerCase();
}
@@ -0,0 +1,417 @@
// Release file and folder names -> show / season / episode / extra / movie,
// anitomy-style but small: enough to group a downloads folder into shows.
// Unknown shapes fall back to the cleaned name with no episode, never to a
// wrong number. Pure and offline (L1 of the library, see ARCHITECTURE.md
// "Library").
/// What a release name is about.
enum ReleaseKind {
/// A numbered episode.
episode,
/// `Title (2019)`, `Title - Movie 2`, `Gekijouban Title`.
movie,
/// Not an episode of the run: OP/ED (creditless or not), OVA, specials,
/// PV, menus.
extra,
/// Nothing says which: a clip, a season pack's name, a page title.
other,
}
/// What a release file name says.
class ParsedName {
/// Title without group, season marker, episode, year or release tags.
final String series;
final int? season;
final int? episode;
/// Release group: leading `[Group]` or scene-style trailing `-GRP`.
final String? group;
/// Release year, taken out of [series] (movies, `Title (2019)`).
final int? year;
/// Non-episode marker (`NCOP1`, `OP2`, `ED`, `OVA 2`, `Special`, `PV1`,
/// `Menu`): such files carry no [episode] so they never sort among the
/// real ones.
final String? extra;
/// A movie marker was found (`Movie`, `Movie 01`, `Gekijouban`).
final bool movie;
/// `Movie 2`: its number in the show's movies.
final int? movieNumber;
/// From a parenthetical next to the season (`(Season 3 - Alicization)`):
/// the season's own name.
final String? subtitle;
const ParsedName(
this.series, {
this.season,
this.episode,
this.group,
this.year,
this.extra,
this.movie = false,
this.movieNumber,
this.subtitle,
});
ReleaseKind get kind {
if (episode != null) return ReleaseKind.episode;
if (extra != null) return ReleaseKind.extra;
if (movie || year != null) return ReleaseKind.movie;
return ReleaseKind.other;
}
/// `S01E03`, `E25`, the [extra] marker, `Movie 2`, `S02` for a season
/// pack, else ''.
String get label {
final s = season == null ? '' : 'S${season.toString().padLeft(2, '0')}';
if (episode != null) return '${s}E${episode.toString().padLeft(2, '0')}';
if (extra != null) return extra!;
if (movie) return movieNumber == null ? 'Movie' : 'Movie $movieNumber';
return s;
}
/// Grouping key: case, punctuation and spacing do not split a series.
String get seriesKey => normalizeSeries(series);
@override
String toString() => 'ParsedName($series, s=$season, e=$episode, '
'group=$group, year=$year, extra=$extra, movie=$movie/$movieNumber, '
'subtitle=$subtitle)';
}
/// Lowercase letters and digits of [s], single spaces between words.
String normalizeSeries(String s) => s
.toLowerCase()
.replaceAll(RegExp(r"['’`]"), '')
.replaceAll(RegExp(r'[^\p{L}\p{N}]+', unicode: true), ' ')
.trim();
// Release tags: the title ends where the first of these starts.
final _junk = RegExp(
r'\b(?:2160p|1440p|1080[pi]|720p|576p|480p|4K|UHD|[xh]\.?26[45]|HEVC|AVC|'
r'AV1|VP9|WEB(?:-?DL|-?Rip)?|BD(?:Rip|Remux)?|Blu-?Ray|BRRip|DVD(?:Rip)?|'
r'HDTV|HDR(?:10)?|DV|REMUX|AAC(?:\d\.\d)?|FLAC|Opus|DTS(?:-HD)?|E?-?AC-?3|'
r'DDP?\d?|TrueHD|Atmos|10-?bit|8-?bit|Hi10P?|PROPER|REPACK|Dual[ -]Audio|'
r'Multi(?:ple)?[ -]Subs?(?:titles?)?|AMZN|NF|CR|DSNP|HMAX|ATVP|'
r'Batch|Complete)\b',
caseSensitive: false,
);
final _ext = RegExp(r'\.(?=[a-z0-9]*[a-z])[a-z0-9]{2,4}$', caseSensitive: false);
final _leadingGroup = RegExp(r'^\s*\[([^\]]+)\]');
final _sceneGroup = RegExp(r'-([A-Za-z0-9]+)$');
final _bracketYear = RegExp(r'[(\[]((?:19|20)\d{2})[)\]]');
final _brackets = RegExp(r'\[[^\]]*\]|\([^)]*\)|\{[^}]*\}|【[^】]*】');
final _parenthetical = RegExp(r'\(([^)]*)\)');
// Inside a parenthetical: `Season 3 - Alicization`, `S2: Subtitle`,
// `2nd Season`.
final _seasonPhrase = RegExp(
r'^\s*(?:Season\s?(\d{1,2})|S(\d{1,2})|(\d{1,2})(?:st|nd|rd|th)\s+Season)'
r'(?:\s*[-–:~]\s*(.+?))?\s*$',
caseSensitive: false,
);
// S01E02, S01.E02, S1 E2; a batch `S01E01-E02` / `S01E01E02` keeps the first.
final _sxe = RegExp(r'\bS(\d{1,2})[ .]?E(\d{1,4})(?:v\d)?(?!\d)',
caseSensitive: false);
final _nxn = RegExp(r'\b(\d{1,2})x(\d{2,3})\b');
// Anime: `Title - 25`, `- 03v2`, `- 01-02` (batch keeps the first).
final _dashNumber = RegExp(r'\s[-–]\s(\d{1,4})(?:v\d)?(?=$|[\s\-~.])');
final _episodeWord = RegExp(
r'\b(?:Episode|Ep\.?|E)\s?(\d{1,4})(?:v\d)?\b',
caseSensitive: false,
);
// A name that is only the number (`01.mkv`, `001v2`; not a year).
final _bareNumber = RegExp(r'^(?!(?:19|20)\d\d(?:v\d)?$)(\d{1,4})(?:v\d)?$');
// Extras: unambiguous words anywhere, short ones only right after the dash.
final _extraAnywhere = RegExp(
r'(?:^|\s)(NCOP|NCED|OVA|OAD|ONA)\s?(\d{1,2})?(?:v\d)?(?=$|\s)',
caseSensitive: false,
);
final _extraAfterDash = RegExp(
r'\s[-–]\s(?:Creditless\s|Clean\s|NC\s)?'
r'(OP|ED|SP|Specials?|Extras?|Recap|Preview|PV|CM|Menu|Opening|Ending)'
r'\s?(\d{1,2})?(?:v\d)?(?=$|\s)',
caseSensitive: false,
);
final _creditless = RegExp(r'\s[-–]\s(?:Creditless|Clean|NC)\s', caseSensitive: false);
// Movies: `Title - Movie 01`, `Title - The Movie`, `Title Movie 2` at the
// end, `Gekijouban Title` in front.
final _movieAfterDash = RegExp(
r'\s[-–]\s(?:The\s)?Movie(?:\s(\d{1,2}))?(?=$|\s)',
caseSensitive: false,
);
final _movieAtEnd =
RegExp(r'\s(?:The\sMovie|Movie(?=\s\d))(?:\s(\d{1,2}))?$', caseSensitive: false);
final _gekijouban = RegExp(r'^\s*Gekij(?:ou|ō|o)ban\s+', caseSensitive: false);
final _seasonInTitle = RegExp(
r'\s(?:S(\d{1,2})|Season\s?(\d{1,2})|(\d{1,2})(?:st|nd|rd|th)\s+Season)\s*$',
caseSensitive: false,
);
// A folder named only for its season: `Season 2`, `S02`, `2nd Season`.
final _seasonOnly = RegExp(
r'^(?:S(\d{1,2})|Season\s?(\d{1,2})|(\d{1,2})(?:st|nd|rd|th)\s+Season)$',
caseSensitive: false,
);
final _trailingYear = RegExp(r'\s((?:19|20)\d{2})$');
// A folder's episode range: `Bleach 001-055`, `Show 01~12`.
final _range = RegExp(r'\s(\d{1,4})\s?[-~–]\s?(\d{1,4})$');
final _extrasFolder = RegExp(
r'^(?:Extras?|Specials?|SPs?|Bonus|NC(?:OP|ED)?s?|Creditless|OP\s?&?\s?ED|'
r'Openings?|Endings?|Menus?|PVs?|CMs?|Featurettes?)$',
caseSensitive: false,
);
/// Season and season name from parentheticals of [s] (`(Season 3 -
/// Alicization)`), read before the brackets are thrown away.
({int? season, String? subtitle}) _parentheticalSeason(String s) {
for (final m in _parenthetical.allMatches(s)) {
final p = _seasonPhrase.firstMatch(m.group(1)!);
if (p == null) continue;
final n = int.parse(p.group(1) ?? p.group(2) ?? p.group(3)!);
final sub = p.group(4)?.trim();
return (season: n, subtitle: sub == null || sub.isEmpty ? null : sub);
}
return (season: null, subtitle: null);
}
/// Parses a file name (with or without the extension, no directories).
ParsedName parseReleaseName(String fileName) {
var s = fileName.replaceFirst(_ext, '').trim();
final original = s;
String? group = _leadingGroup.firstMatch(s)?.group(1)?.trim();
int? year;
final by = _bracketYear.firstMatch(s);
if (by != null) year = int.parse(by.group(1)!);
final paren = _parentheticalSeason(s);
s = s.replaceAll(_brackets, ' ');
// Dots and underscores as separators (scene style), unless the name
// already has spaces and only the odd `Dr. Stone` dot.
final dotted = !s.contains(' ') || RegExp(r'\w\.\w').allMatches(s).length > 2;
if (dotted) {
// `...H264-GRP`, but not `...WEB-DL`.
final tags = _junk.allMatches(s);
if (group == null && tags.isNotEmpty && tags.last.end < s.length) {
final g = _sceneGroup.firstMatch(s);
if (g != null) {
group = g.group(1);
s = s.substring(0, g.start);
}
}
s = s.replaceAll('.', ' ');
}
s = s.replaceAll('_', ' ').replaceAll(RegExp(r'\s+'), ' ').trim();
// Release tags end the interesting part.
final junk = _junk.firstMatch(s);
if (junk != null && junk.start > 0) s = s.substring(0, junk.start).trim();
var movie = false;
int? movieNumber;
final gk = _gekijouban.firstMatch(s);
if (gk != null) {
movie = true;
s = s.substring(gk.end);
}
int? season, episode;
String? extra;
var cut = s.length;
final extraMatch =
_extraAfterDash.firstMatch(s) ?? _extraAnywhere.firstMatch(s);
final sxe = _sxe.firstMatch(s);
final movieMatch = _movieAfterDash.firstMatch(s) ?? _movieAtEnd.firstMatch(s);
if (extraMatch != null) {
extra = _extraLabel(s, extraMatch);
cut = extraMatch.start;
if (sxe != null && sxe.start < cut) {
season = int.parse(sxe.group(1)!);
cut = sxe.start;
}
} else if (sxe != null) {
season = int.parse(sxe.group(1)!);
episode = int.parse(sxe.group(2)!);
cut = sxe.start;
} else if (movieMatch != null) {
movie = true;
final n = movieMatch.group(1);
movieNumber = n == null ? null : int.parse(n);
cut = movieMatch.start;
} else {
final nxn = _nxn.firstMatch(s);
if (nxn != null) {
season = int.parse(nxn.group(1)!);
episode = int.parse(nxn.group(2)!);
cut = nxn.start;
} else {
final m = _dashNumber.firstMatch(s) ??
_episodeWord.firstMatch(s) ??
(movie ? null : _bareNumber.firstMatch(s));
if (m != null) {
episode = int.parse(m.group(1)!);
cut = m.start;
}
}
}
var series = s.substring(0, cut);
// Season pack or season marker kept in the title: `Title S2`,
// `Title Season 2`, `Title 2nd Season`, `Title.S01.1080p`.
final sIn = _seasonInTitle.firstMatch(series);
if (sIn != null) {
season ??= int.parse(sIn.group(1) ?? sIn.group(2) ?? sIn.group(3)!);
series = series.substring(0, sIn.start);
}
season ??= paren.season;
series = _trimTail(series);
// A trailing year is the release year, unless it is the whole title or
// too far ahead to be one (`Blade Runner 2049`).
final ty = _trailingYear.firstMatch(series);
if (ty != null) {
final y = int.parse(ty.group(1)!);
if (y <= DateTime.now().year + 1) {
year ??= y;
series = series.substring(0, ty.start).trim();
}
}
series = _trimTail(series);
// A name that is only an episode or extra (`01.mkv`, `NCOP1.mkv`) has
// no title: the folder gives one.
if (series.isEmpty && episode == null && extra == null && !movie) {
series = original.replaceAll(RegExp(r'[._]+'), ' ').trim();
}
return ParsedName(
series,
season: season,
episode: episode,
group: group,
year: year,
extra: extra,
movie: movie,
movieNumber: movieNumber,
subtitle: paren.subtitle,
);
}
String _trimTail(String s) => s.replaceAll(RegExp(r'[\s\-–:,~]+$'), '').trim();
/// `NCOP1`, `OP2`, `ED`, `OVA 2`, `Special 1`, `PV1`: short words joined,
/// long ones spaced; Opening / Ending read as OP / ED, creditless ones as
/// NCOP / NCED.
String _extraLabel(String s, RegExpMatch m) {
final word = m.group(1)!;
var upper = switch (word.toLowerCase()) {
'opening' => 'OP',
'ending' => 'ED',
final w when w.length <= 3 || w.startsWith('nc') => word.toUpperCase(),
_ => _title(word),
};
final creditless = _creditless.matchAsPrefix(s, m.start) != null;
if (creditless && (upper == 'OP' || upper == 'ED')) upper = 'NC$upper';
// `OVA - 02`: the number may follow the dash instead.
final n = m.group(2) ?? _dashNumber.firstMatch(s.substring(m.end))?.group(1);
if (n == null) return upper;
return (upper.startsWith('NC') || upper.length <= 2)
? '$upper${int.parse(n)}'
: '$upper ${int.parse(n)}';
}
String _title(String w) => w[0].toUpperCase() + w.substring(1).toLowerCase();
/// What a folder's name says about the videos in it: lower priority than
/// the file's own name (see `groupLocal`).
class FolderHint {
/// Show title; '' when the folder names none (`Season 2`, `Extras`).
final String title;
final int? season;
/// The season's own name, from a parenthetical (`Alicization`).
final String? subtitle;
/// An episode range in the name (`Bleach 056-111`).
final int? firstEpisode;
final int? lastEpisode;
final String? group;
final int? year;
/// A folder of extras (`Extras`, `NCOP`, `Specials`).
final bool extras;
const FolderHint({
this.title = '',
this.season,
this.subtitle,
this.firstEpisode,
this.lastEpisode,
this.group,
this.year,
this.extras = false,
});
String get key => normalizeSeries(title);
@override
String toString() => 'FolderHint("$title", s=$season, sub=$subtitle, '
'range=$firstEpisode-$lastEpisode, extras=$extras)';
}
/// Parses one folder name (no separators): `[Judas] Bleach 056-111 [BD
/// 1080p]`, `Sword Art Online (Season 3 - Alicization)`, `Season 2`.
FolderHint parseFolderName(String name) {
var s = name.trim();
final group = _leadingGroup.firstMatch(s)?.group(1)?.trim();
final by = _bracketYear.firstMatch(s);
final paren = _parentheticalSeason(s);
s = s.replaceAll(_brackets, ' ');
final dotted = !s.contains(' ') || RegExp(r'\w\.\w').allMatches(s).length > 2;
if (dotted) s = s.replaceAll('.', ' ');
s = s.replaceAll('_', ' ').replaceAll(RegExp(r'\s+'), ' ').trim();
final junk = _junk.firstMatch(s);
if (junk != null && junk.start > 0) s = s.substring(0, junk.start).trim();
s = _trimTail(s);
if (_extrasFolder.hasMatch(s)) return FolderHint(group: group, extras: true);
final only = _seasonOnly.firstMatch(s);
if (only != null) {
return FolderHint(
season: int.parse(only.group(1) ?? only.group(2) ?? only.group(3)!),
group: group,
);
}
int? first, last;
final r = _range.firstMatch(s);
if (r != null) {
final a = int.parse(r.group(1)!), b = int.parse(r.group(2)!);
if (a <= b && !(a >= 1900 && b <= 2100)) {
first = a;
last = b;
s = _trimTail(s.substring(0, r.start));
}
}
int? season = paren.season;
final sIn = _seasonInTitle.firstMatch(s);
if (sIn != null) {
season ??= int.parse(sIn.group(1) ?? sIn.group(2) ?? sIn.group(3)!);
s = _trimTail(s.substring(0, sIn.start));
}
int? year = by == null ? null : int.parse(by.group(1)!);
final ty = _trailingYear.firstMatch(s);
if (ty != null && ty.start > 0) {
year ??= int.parse(ty.group(1)!);
s = _trimTail(s.substring(0, ty.start));
}
return FolderHint(
title: s,
season: season,
subtitle: paren.subtitle,
firstEpisode: first,
lastEpisode: last,
group: group,
year: year,
);
}
+3 -2
View File
@@ -103,8 +103,9 @@ class JellyfinRef {
/// What a media is, independent of where its bytes come from (`media.ref`): /// 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 /// 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 /// as is in `swap.media`, `state.media` and `queue[].media` (the server
/// keeps it, at most [RelayConstants.maxMediaRefBytes] of JSON); nothing /// keeps it, at most [RelayConstants.maxMediaRefBytes] of JSON). The app
/// produces it yet (step 4). /// makes it from its library (files), Jellyfin's provider ids and a
/// link's name.
class MediaRef { class MediaRef {
static const kinds = {'episode', 'movie', 'extra'}; static const kinds = {'episode', 'movie', 'extra'};
+135
View File
@@ -0,0 +1,135 @@
import 'package:sync_relay_core/core.dart';
import 'package:test/test.dart';
// L1 grouping (groupLocal) over the names of a real anime folder: three
// Bleach folders (one holding only shortcut junk) with absolute numbering
// and a hole, a Sword Art Online season folder with OP/ED extras, a loose
// Re:Zero S04E17 and a Kimetsu no Yaiba movie. Paths only: nothing here
// touches the disk (the app's scanner test walks a generated tree).
const root = '/lib/Binux';
const bleach1 = '$root/[Judas] Bleach 001-055 [BD 1080p][HEVC x265 10bit]';
const bleach2 = '$root/[Judas] Bleach 056-111 [BD 1080p][HEVC x265 10bit]';
const bleach3 = '$root/[Judas] Bleach 112-139 [BD 1080p][HEVC x265 10bit][Dual-Audio][Eng-Subs]';
const sao = '$root/[Judas] Sword Art Online (Season 3 - Alicization) [BD 1080p][HEVC x265 10bit][Eng-Subs]';
String three(int n) => n.toString().padLeft(3, '0');
String two(int n) => n.toString().padLeft(2, '0');
/// The Binux names (and order as `find` lists them: not sorted).
List<String> binux() => [
'$root/[Erai-raws] Kimetsu no Yaiba - Mugenjou Hen - Movie 01 [1080p CR WEBRip HEVC AAC][MultiSub][8E47462C].mkv',
for (final junk in ['Amine', 'App', 'Audio', 'Document', 'Download', 'Image', 'Recent', 'Video'])
'$bleach1/$junk#DfopghDGLvYAG.lnk',
for (final n in [...List.generate(10, (i) => 56 + i), 108, 109, 110, 111]) '$bleach2/[Judas] Bleach - ${three(n)}.mkv',
for (var n = 139; n >= 112; n--) '$bleach3/[Judas] Bleach - ${three(n)}.mkv',
'$root/[Judas] Re.Zero - S04E17.mkv',
for (var n = 1; n <= 24; n++) '$sao/[Judas] Sword Art Online S3 - ${two(n)}.mkv',
for (final x in ['Ending 1', 'Ending 2', 'Opening 1', 'Opening 2', 'Opening 3'])
'$sao/[Judas] Sword Art Online S3 - $x.mkv',
];
LocalLibrary group(Iterable<String> paths) =>
groupLocal([for (final p in paths) LocalFile(p, 1 << 30, DateTime(2026))], roots: [root]);
void main() {
test('the Binux folder: three shows, a movie, junk left out', () {
final lib = group(binux());
expect(lib.shows.map((s) => s.title), ['Bleach', 'Re.Zero', 'Sword Art Online']);
expect(lib.movies.map((m) => (m.title, m.label)), [('Kimetsu no Yaiba - Mugenjou Hen', 'Movie 1')]);
expect(lib.other, isEmpty);
expect(lib.ignored, 8, reason: 'the .lnk shortcuts');
});
test('Bleach: one show across folders, absolute numbering, the hole known', () {
final bleach = group(binux()).show('bleach')!;
expect(bleach.folders, [bleach2, bleach3]);
expect(bleach.group, 'Judas');
final run = bleach.seasons.single;
expect(run.absolute, isTrue);
expect(run.number, isNull);
expect(run.distinct.map((e) => e.episode), [...List.generate(10, (i) => 56 + i), ...List.generate(32, (i) => 108 + i)]);
expect(run.gaps, const [EpisodeGap(1, 55), EpisodeGap(66, 107)]);
expect(bleach.episodeCount, 42);
expect(run.episodes.first.label, 'E56');
expect(run.episodes.first.key, 'bleach|s|e56');
// Next: from nothing it starts at 56 (1–55 missing); after 65 it
// skips 66–107; after 111 it is 112, nothing skipped; 139 is the end.
final start = bleach.nextAfter(null)!;
expect([start.episode.episode, start.skipped], [56, const EpisodeGap(1, 55)]);
final at65 = run.episodes.firstWhere((e) => e.episode == 65);
final skip = bleach.nextAfter(at65.key)!;
expect([skip.episode.episode, skip.skipped?.label], [108, '66–107']);
final at111 = run.episodes.firstWhere((e) => e.episode == 111);
expect([bleach.nextAfter(at111.key)!.episode.episode, bleach.nextAfter(at111.key)!.skipped], [112, null]);
expect(bleach.nextAfter('bleach|s|e139'), isNull);
// An episode no longer in the folders: the next one after its number.
expect(bleach.nextAfter('bleach|s|e70')!.episode.episode, 108);
});
test('Sword Art Online: season and its name from the folder, OP/ED as extras', () {
final s = group(binux()).show('sword art online')!;
final season = s.seasons.single;
expect([season.number, season.name, season.title], [3, 'Alicization', 'Season 3 · Alicization']);
expect(season.distinct.map((e) => e.episode), List.generate(24, (i) => i + 1));
expect(season.gaps, isEmpty);
expect(s.extras.map((e) => e.label), ['OP1', 'OP2', 'OP3', 'ED1', 'ED2']);
expect(s.extras.every((e) => e.kind == LocalKind.extra && e.season == 3), isTrue);
expect(season.episodes.first.label, 'S03E01');
final first = s.nextAfter(null)!;
expect([first.episode.episode, first.skipped], [1, null]);
});
test('Re:Zero: a loose S04E17 is a show of its own, numbers as found', () {
final re = group(binux()).show('re zero')!;
final s4 = re.seasons.single;
expect([s4.number, s4.distinct.single.episode, s4.gaps], [4, 17, const [EpisodeGap(1, 16)]]);
expect(re.folders, [root]);
});
test('deterministic: any input order gives the same library', () {
String dump(LocalLibrary l) => [
for (final s in l.shows) '${s.key}:${[for (final e in s.files) e.path].join(',')}',
for (final m in l.movies) m.path,
].join('\n');
final a = group(binux());
final b = group(binux().reversed);
final c = group([...binux()]..sort());
expect(dump(b), dump(a));
expect(dump(c), dump(a));
});
test('folder hints: titles for bare names, season folders, extras folders, ranges', () {
final lib = group([
'$root/Frieren/Season 2/01.mkv',
'$root/Frieren/Season 2/02.mkv',
'$root/Frieren/Season 2/Extras/Creditless Opening.mkv',
'$root/Frieren/Frieren - 05.mkv',
'$root/Show 01-12/Show - 01.mkv',
'$root/Show 01-12/Show - 02.mkv',
'$root/clip.mp4',
'$root/Lonely/[A] Lonely - OVA.mkv',
]);
final f = lib.show('frieren')!;
expect(f.seasons.map((s) => s.number), [null, 2]);
expect(f.season(2)!.distinct.map((e) => e.episode), [1, 2]);
expect(f.extras.single.extra, 'Creditless Opening');
// A range in the folder name: what should follow is missing too.
expect(lib.show('show')!.seasons.single.gaps, const [EpisodeGap(3, 12)]);
// An extra of no show here, and a clip: just videos.
expect(lib.other.map((e) => e.name), ['[A] Lonely - OVA.mkv', 'clip.mp4']);
});
test('copies of one episode share its key; the newest name comes first', () {
final lib = group([
'$root/a/[A] Show - 01.mkv',
'$root/a/[A] Show - 01v2.mkv',
'$root/a/[A] Show - 02.mkv',
]);
final s = lib.shows.single.seasons.single;
expect(s.episodes.map((e) => e.name), ['[A] Show - 01v2.mkv', '[A] Show - 01.mkv', '[A] Show - 02.mkv']);
expect(s.distinct.map((e) => e.name), ['[A] Show - 01v2.mkv', '[A] Show - 02.mkv']);
expect(s.episodes[0].key, s.episodes[1].key);
});
}
@@ -1,5 +1,5 @@
import 'package:flutter_test/flutter_test.dart'; import 'package:test/test.dart';
import 'package:starsync/src/files/name_parser.dart'; import 'package:sync_relay_core/core.dart';
void _expect( void _expect(
String name, String name,
@@ -96,6 +96,16 @@ void main() {
_expect('Show - OP1 [1080p].mkv', 'Show', extra: 'OP1'); _expect('Show - OP1 [1080p].mkv', 'Show', extra: 'OP1');
_expect('Show - ED.mkv', 'Show', extra: 'ED'); _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', () { test('"Special" inside a title is a title', () {
_expect('Special Operations Unit - 04.mkv', 'Special Operations Unit', _expect('Special Operations Unit - 04.mkv', 'Special Operations Unit',
episode: 4); episode: 4);
@@ -142,6 +152,19 @@ void main() {
test('WEB-DL at the end is not a group', () { test('WEB-DL at the end is not a group', () {
_expect('Some.Movie.2021.1080p.WEB-DL.mkv', 'Some Movie', year: 2021); _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', () { test('hyphen in the title', () {
_expect('Spider-Man.2002.1080p.BluRay.mkv', 'Spider-Man', year: 2002); _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', () { test('seriesKey ignores case and punctuation', () {
expect(parseReleaseName('Re:Zero - 01.mkv').seriesKey, expect(parseReleaseName('Re:Zero - 01.mkv').seriesKey,
parseReleaseName('re zero - 02.mkv').seriesKey); parseReleaseName('re zero - 02.mkv').seriesKey);
+6 -6
View File
@@ -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')); Response _plain(int status) => Response(status, body: Body.fromString('status $status'));
/// `GET /j/<sid>/<token>/<itemId>`: relays byte ranges of the current /// `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 /// 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; /// for a valid viewer token: no open proxy. Range goes up as sent;
/// status, Content-Range, Content-Length, Content-Type and Accept-Ranges /// 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 /// 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( void mountJellyfinRelayRoute(
RelicApp app, RelicApp app,
SessionMap sessions, SessionMap sessions,
JellyfinCredential? Function() credential, JellyfinCredential? Function(String serverId) credentialFor,
HttpClient Function() client, { HttpClient Function() client, {
JellyfinRef? Function()? next, JellyfinRef? Function()? next,
String pattern = '/j/:sid/:token/:itemId', String pattern = '/j/:sid/:token/:itemId',
@@ -58,14 +59,13 @@ void mountJellyfinRelayRoute(
default: default:
return _plain(401); return _plain(401);
} }
final cred = credential(); JellyfinRef? onLogin(JellyfinRef? r) => r != null && credentialFor(r.serverId) != null ? r : null;
if (cred == null || cred.serverId.isEmpty) return _plain(404);
JellyfinRef? onLogin(JellyfinRef? r) => r != null && cred.serverId == r.serverId ? r : null;
final current = onLogin(session.media?.jellyfin); final current = onLogin(session.media?.jellyfin);
final upNext = onLogin(next?.call()); final upNext = onLogin(next?.call());
if (current == null && upNext == null) return _plain(404); if (current == null && upNext == null) return _plain(404);
final ref = [current, upNext].firstWhere((r) => r != null && _sameItemId(itemId, r.itemId), orElse: () => null); final ref = [current, upNext].firstWhere((r) => r != null && _sameItemId(itemId, r.itemId), orElse: () => null);
if (ref == null) return _plain(403); if (ref == null) return _plain(403);
final cred = credentialFor(ref.serverId)!;
final range = req.headers['range']?.join(','); final range = req.headers['range']?.join(',');
final HttpClientRequest upReq; final HttpClientRequest upReq;
+6 -1
View File
@@ -61,7 +61,12 @@ Future<void> serve(Map<String, Object?> config, {SendPort? events}) async {
final port = await server.share( final port = await server.share(
port: (msg['port'] as num?)?.toInt() ?? 0, port: (msg['port'] as num?)?.toInt() ?? 0,
secret: (msg['secret'] as String?) ?? '', 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}); reply.send({'ok': true, 'port': port});
} catch (e) { } catch (e) {
reply.send({'ok': false, 'message': '$e'}); reply.send({'ok': false, 'message': '$e'});
+24 -11
View File
@@ -23,16 +23,31 @@ class ServerHub {
/// The `/sync` socket of each member that has one, by token; a `gone` /// The `/sync` socket of each member that has one, by token; a `gone`
/// member has none. /// member has none.
final Map<String, RelicWebSocket> sockets = {}; final Map<String, RelicWebSocket> sockets = {};
/// The server's Jellyfin login for the `/j/...` relay; null = no relay. /// The server's Jellyfin logins for the `/j/...` relay, at most one per
JellyfinCredential? jellyfin; /// 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. /// Reads web pages for "the server fetches it"; null = files only.
final YtDlp? ytDlp; final YtDlp? ytDlp;
/// Random per server boot (`welcome.instance`). /// Random per server boot (`welcome.instance`).
final String instance = _randomId(); final String instance = _randomId();
ServerHub(this.sessions, this.expectedSecret, ServerHub(this.sessions, this.expectedSecret,
{SyncEngine? engine, this.hostKey = '', this.jellyfin, this.ytDlp}) {SyncEngine? engine, this.hostKey = '', JellyfinCredential? jellyfin, this.ytDlp})
: engine = engine ?? SyncEngine() { : jellyfinLogins = [if (jellyfin != null) jellyfin],
engine = engine ?? SyncEngine() {
this.engine this.engine
..onGone = _memberGone ..onGone = _memberGone
..onLeft = _memberLeft; ..onLeft = _memberLeft;
@@ -106,20 +121,18 @@ class ServerHub {
bool get _sourceGone => engine.media?.relay?.sourceGone ?? false; 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); bool get relayAvailable => _onLogin(session.media?.jellyfin);
/// Up next's first item is a Jellyfin ref on that server: `/j` serves it /// Up next's first item is a Jellyfin ref on such a server: `/j` serves
/// too, so viewers without access get it ready. /// it too, so viewers without access get it ready.
bool get relayNextAvailable => _onLogin(nextJellyfinRef); bool get relayNextAvailable => _onLogin(nextJellyfinRef);
/// Up next's first item's Jellyfin ref (null: none, or not a ref). /// Up next's first item's Jellyfin ref (null: none, or not a ref).
JellyfinRef? get nextJellyfinRef => engine.queue.items.firstOrNull?.media.jellyfin; JellyfinRef? get nextJellyfinRef => engine.queue.items.firstOrNull?.media.jellyfin;
bool _onLogin(JellyfinRef? ref) { bool _onLogin(JellyfinRef? ref) => ref != null && loginFor(ref.serverId) != null;
final cred = jellyfin;
return ref != null && cred != null && cred.serverId.isNotEmpty && cred.serverId == ref.serverId;
}
/// What the swapper reads when the server cannot fetch a link ([why] /// 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 /// from the relay). A yt-dlp failure on a server without cookies says
+21 -14
View File
@@ -130,7 +130,7 @@ class SyncServer {
mountArtRoute(_app, hub); mountArtRoute(_app, hub);
mountSessionLogRoutes(_app, hub, () => _sessionLog); mountSessionLogRoutes(_app, hub, () => _sessionLog);
mountJellyfinRelayRoute( mountJellyfinRelayRoute(
_app, sessions, () => hub.jellyfin, () => _relayClient ??= HttpClient(), _app, sessions, hub.loginFor, () => _relayClient ??= HttpClient(),
next: () => hub.nextJellyfinRef, onLog: logRequest); next: () => hub.nextJellyfinRef, onLog: logRequest);
// advertiseHost is only an explicit override; otherwise echo what the client dialled. // advertiseHost is only an explicit override; otherwise echo what the client dialled.
String authority(String dialledHost) => config.advertiseHost.isNotEmpty String authority(String dialledHost) => config.advertiseHost.isNotEmpty
@@ -193,32 +193,39 @@ class SyncServer {
} }
/// A configured credential without a ServerId gets it from /// 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 { Future<void> _resolveJellyfinServerId() async {
final cred = hub.jellyfin; final logins = <JellyfinCredential>[];
if (cred == null) return; for (final cred in hub.jellyfinLogins) {
if (cred.serverId.isEmpty) { var c = cred;
final client = JellyfinClient(baseUrl: cred.baseUrl, clientInfo: cred.clientInfo); if (c.serverId.isEmpty) {
final client = JellyfinClient(baseUrl: c.baseUrl, clientInfo: c.clientInfo);
try { try {
final info = await client.publicInfo().timeout(const Duration(seconds: 10)); final info = await client.publicInfo().timeout(const Duration(seconds: 10));
hub.jellyfin = cred.withServerId(info.id); c = c.withServerId(info.id);
} catch (e) { } catch (e) {
_log.warning('jellyfin relay off: server id lookup failed: $e'); _log.warning('jellyfin relay off for ${c.baseUrl}: server id lookup failed: $e');
return;
} finally { } finally {
client.close(); client.close();
} }
} }
_log.info('jellyfin relay on for server ${hub.jellyfin!.serverId}'); if (c.serverId.isNotEmpty && !logins.any((l) => l.serverId == c.serverId)) {
logins.add(c);
_log.info('jellyfin relay on for server ${c.serverId}');
}
}
hub.jellyfinLogins = logins;
} }
/// Opens the running server to the network: a second listener on /// Opens the running server to the network: a second listener on
/// every IPv4 interface at [port] (0: a free one), the password /// 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. /// already in (on the first listener) stay connected; nothing restarts.
/// Shared already: the listener moves to [port] when it differs. Returns /// Shared already: the listener moves to [port] when it differs. Returns
/// the bound port; throws when it cannot bind. /// 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'); if (_server == null) throw StateError('the server is not running');
final was = _shared; final was = _shared;
if (was != null && (port == 0 || port == was.port)) { if (was != null && (port == 0 || port == was.port)) {
@@ -247,9 +254,9 @@ class SyncServer {
return listener.port; return listener.port;
} }
void _applyShareSettings(String secret, JellyfinCredential? jellyfin) { void _applyShareSettings(String secret, List<JellyfinCredential>? jellyfin) {
session.secret = secret; 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 /// 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 { test('a credential without serverId looks it up at start', () async {
await startServer(cred(serverId: '')); await startServer(cred(serverId: ''));
expect(server.hub.jellyfin!.serverId, _serverId); expect(server.hub.jellyfin!.serverId, _serverId);