diff --git a/README.md b/README.md index 90cc105..abb8d8d 100644 --- a/README.md +++ b/README.md @@ -216,8 +216,21 @@ Up next, add the folder to Files, or fill Link). open here (needs cookies, 403, 404, not a video) says why and goes up only with "Put it up anyway"; in the player, whoever cannot play it is named ("Bob can't play this: Not allowed (403)") and nobody waits for them. -- **Files**: video folders you add on this computer, grouped by series - (click a series to list its episodes), or any file through Browse. The others stream it from you. +- **Files**: your own library from the video folders you add on this + computer (the **Library** shows them all together, each folder on its + own too), or any file through Browse. Files are sorted into shows and + movies by their names and folders: one show split over several folders + is one show, OP/ED and specials go under its **Extras**, a folder like + "Show (Season 3 - Alicization)" gives the season and its name. Shows + are looked up on AniList for their proper name, cover and banner. A + show's page has its seasons and episodes and a button for the next one + after what you last played here; when episodes are missing on the way + it says so ("66–107 aren't in your folders — skip to 108?"). Matched + the wrong show? **Wrong show?** searches AniList and pins the right one + for the show everywhere or just one folder; **Episode numbers…** says + which episode the first file is when the files count differently. Both + stay through rescans. The others stream what you put up from you, and + see its proper name too. Put it up and the player opens. With nobody else in the server, the buttons say Play and a link plays straight from the site. @@ -467,8 +480,9 @@ Watching: - Friends without an account watch through the server: whoever put the item up passes it on automatically, with their own sign-in, which never leaves their computer (only the current item, only to people in the - session). They show "via server" in the member list (click the avatars). When the app hosts, - or a dedicated server has its own login (`--jellyfin `, see + session). They show "via server" in the member list (click the avatars). On your + own server (with your sign-in on that item's Jellyfin, whichever of + your linked servers it is), or a dedicated server with its own login (`--jellyfin `, see [docs/ARCHITECTURE.md](docs/ARCHITECTURE.md), Jellyfin relay), the server fetches it itself instead. - If nobody can pass it on (the person who put it up left), the app says diff --git a/apps/desktop/lib/main.dart b/apps/desktop/lib/main.dart index dd45ec3..f593782 100644 --- a/apps/desktop/lib/main.dart +++ b/apps/desktop/lib/main.dart @@ -65,6 +65,8 @@ Future main(List args) async { } catch (e, st) { debugPrint('load connect form failed: $e\n$st'); } + // The library's matches, pins and offsets (device-owned). + unawaited(graph.library.ensureLoaded()); MediaKit.ensureInitialized(); // yt-dlp for web page links: downloaded on first run, kept current. ports.links.warmUp(); diff --git a/apps/desktop/lib/src/app/app_graph.dart b/apps/desktop/lib/src/app/app_graph.dart index 9664eb0..12e4ebd 100644 --- a/apps/desktop/lib/src/app/app_graph.dart +++ b/apps/desktop/lib/src/app/app_graph.dart @@ -1,9 +1,13 @@ import 'package:flutter/widgets.dart'; import '../jellyfin/accounts.dart'; +import '../library/library_store.dart'; +import '../library/show_matcher.dart'; import '../platform/app_log.dart' show appLogDir; import '../platform/desktop/desktop_ports.dart'; import '../platform/ports/ports.dart'; +import '../schedule/anilist.dart'; +import '../schedule/animeflow.dart'; import '../screens/home/health_probe.dart'; import '../session/connection.dart' show Connection; import '../session/identity.dart' show ClientIdentity, Profile; @@ -44,6 +48,9 @@ class AppGraph { /// Home (your server) or a friend's. final Navigation navigation; + /// The library's matches (L2): local shows to AniList, pins, offsets. + final ShowMatcher library; + AppGraph._({ required this.ports, required this.sfx, @@ -53,6 +60,7 @@ class AppGraph { required this.forms, required this.yourServer, required this.navigation, + required this.library, }); /// The desktop app's graph. Tests pass their own [ports], [identity] @@ -63,6 +71,7 @@ class AppGraph { HealthGetter healthGet = defaultHealthGet, ServerStarter? serverStarter, JellyfinAccounts? jellyfin, + ShowMatcher? library, }) { final p = ports ?? desktopPorts(); final sfx = SynthSfx( @@ -81,6 +90,14 @@ class AppGraph { final connection = Connection(notify: notifier.notify, identity: identity ?? ClientIdentity.stored()); final coordinator = SessionCoordinator(notifier: notifier, connection: connection, ytDlp: p.links, jellyfin: jellyfin); + // Device-owned: the library box; AnimeFlow's runs when signed in. + final matcher = library ?? + ShowMatcher( + store: HiveLibraryStore(), + aniList: AniListClient(), + flow: AnimeFlowLookup(AnimeFlowClient.fromAccounts(coordinator.jellyfin)), + ); + coordinator.fileRef = matcher.refForPath; final forms = ConnectStore(); final yourServer = YourServer( forms: forms, @@ -119,6 +136,7 @@ class AppGraph { forms: forms, yourServer: yourServer, navigation: navigation, + library: matcher, ); } diff --git a/apps/desktop/lib/src/files/name_parser.dart b/apps/desktop/lib/src/files/name_parser.dart deleted file mode 100644 index 2ca3a25..0000000 --- a/apps/desktop/lib/src/files/name_parser.dart +++ /dev/null @@ -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(); diff --git a/apps/desktop/lib/src/files/scanner.dart b/apps/desktop/lib/src/files/scanner.dart index 2e8b01e..2b8fd60 100644 --- a/apps/desktop/lib/src/files/scanner.dart +++ b/apps/desktop/lib/src/files/scanner.dart @@ -1,16 +1,13 @@ import 'dart:io'; import 'dart:isolate'; -import 'name_parser.dart'; +import 'package:sync_relay_core/core.dart'; -// Folder scan for the Files source: videos with their parsed names, -// sidecar subtitles, everything else counted and hidden. The walk is -// synchronous I/O, so it runs in its own isolate. +export 'package:sync_relay_core/core.dart' show FileKind, LocalFile, kindOf, subtitleExtensions, videoExtensions; -const videoExtensions = { - 'mkv', 'mp4', 'webm', 'avi', 'mov', 'm4v', 'ts', 'm2ts', 'wmv', 'flv', // -}; -const subtitleExtensions = {'ass', 'ssa', 'srt', 'vtt', 'sup'}; +// Folder scan for the Files source: the videos (grouped into shows by +// core's `groupLocal`, L1), sidecar subtitles, everything else counted +// and hidden. The walk is synchronous I/O, so it runs in its own isolate. /// Files looked at per scan; past this the result is [FolderScan.truncated]. const maxScannedFiles = 20000; @@ -18,27 +15,8 @@ const maxScannedFiles = 20000; /// Samples and trailers under this size are hidden next to the real thing. const _sampleMaxBytes = 300 << 20; -enum FileKind { video, subtitle, other } - -FileKind kindOf(String path) { - final ext = _extension(_baseName(path)); - if (videoExtensions.contains(ext)) return FileKind.video; - if (subtitleExtensions.contains(ext)) return FileKind.subtitle; - return FileKind.other; -} - -class VideoFile { - final String path; - final int size; - final ParsedName parsed; - final DateTime modified; - const VideoFile(this.path, this.size, this.parsed, this.modified); - - String get name => _baseName(path); -} - class FolderScan { - final List videos; + final List videos; /// Non-video files and samples (subtitles and dot-entries not counted). final int hiddenCount; /// Up to 6 of the hidden file names, for "and N other files" hints. @@ -75,7 +53,7 @@ Future scanFolder(String dir, {int maxFiles = maxScannedFiles}) => /// The walk behind [scanFolder]; blocks, so call it from an isolate. FolderScan scanFolderSync(String dir, {int maxFiles = maxScannedFiles}) { - final videos = []; + final videos = []; final subs = []; final examples = []; var hidden = 0, seen = 0; @@ -123,8 +101,7 @@ FolderScan scanFolderSync(String dir, {int maxFiles = maxScannedFiles}) { if (examples.length < 6) examples.add(name); continue; } - videos.add(VideoFile(e.path, size, parseReleaseName(name), - stat?.modified ?? DateTime.fromMillisecondsSinceEpoch(0))); + videos.add(LocalFile(e.path, size, stat?.modified ?? DateTime.fromMillisecondsSinceEpoch(0))); } } return FolderScan( @@ -138,7 +115,7 @@ FolderScan scanFolderSync(String dir, {int maxFiles = maxScannedFiles}) { /// Subtitles in the same folder whose name is the video's base name, /// optionally followed by `.lang` / `.forced` style suffixes. -Map> _sidecars(List videos, List subs) { +Map> _sidecars(List videos, List subs) { final byStem = >{}; for (final s in subs) { final stem = _stripExtension(s); @@ -163,93 +140,11 @@ Map> _sidecars(List videos, List 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 episodes; - final List extras; - const SeriesGroup(this.title, this.key, this.episodes, this.extras); - - /// Distinct seasons, ascending; files without one count as season 1. - List 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 series; - final List 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 videos) { - final episodes = >{}; - final extras = >{}; - final loose = []; - 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 files) { - final counts = {}; - for (final f in files) { - counts.update(f.parsed.series, (n) => n + 1, ifAbsent: () => 1); - } - return counts.entries - .reduce((a, b) => b.value > a.value ? b : a) - .key; -} - String _baseName(String path) { final i = path.lastIndexOf(RegExp(r'[/\\]')); return i < 0 ? path : path.substring(i + 1); } -String _extension(String name) { - final dot = name.lastIndexOf('.'); - return dot <= 0 ? '' : name.substring(dot + 1).toLowerCase(); -} - String _stripExtension(String path) { final dot = path.lastIndexOf('.'); final sep = path.lastIndexOf(RegExp(r'[/\\]')); diff --git a/apps/desktop/lib/src/files/ui/file_tiles.dart b/apps/desktop/lib/src/files/ui/file_tiles.dart index 6827b5f..52f9576 100644 --- a/apps/desktop/lib/src/files/ui/file_tiles.dart +++ b/apps/desktop/lib/src/files/ui/file_tiles.dart @@ -2,13 +2,13 @@ import 'dart:io'; import 'package:flutter/material.dart'; +import 'package:sync_relay_core/core.dart'; + import '../../shell/avatar.dart'; -import '../name_parser.dart'; -import '../scanner.dart'; import 'thumb.dart'; -// The Files page's rows and cards: a series header, an episode row (also -// used for Recent), a loose-video card, and the empty states. +// The Files page's rows and cards: an episode row (also used for Recent), +// a loose-video card, and the empty states. /// What a play control is doing right now. enum PlayState { idle, starting, playing } @@ -25,76 +25,8 @@ String parentName(String path) => baseName(File(path).parent.path); /// Cleaned title of a loose video: `Title (2019)`. String cleanTitle(ParsedName p) => p.year == null ? p.series : '${p.series} (${p.year})'; -class SeriesHeader extends StatelessWidget { - final SeriesGroup group; - final ThumbCache thumbs; - /// Its episodes are listed below; [onTap] opens or closes them. - final bool expanded; - final VoidCallback? onTap; - const SeriesHeader( - {super.key, required this.group, required this.thumbs, this.expanded = true, this.onTap}); - - @override - Widget build(BuildContext context) { - final scheme = Theme.of(context).colorScheme; - final first = group.episodes.first; - final n = group.episodes.length; - final extras = group.extras.length; - final detail = [ - '$n ${n == 1 ? 'episode' : 'episodes'}', - if (extras > 0) '$extras ${extras == 1 ? 'extra' : 'extras'}', - ?first.parsed.group, - ].join(' · '); - return Padding( - padding: const EdgeInsets.only(top: 14), - child: InkWell( - onTap: onTap, - borderRadius: BorderRadius.circular(10), - child: Padding( - padding: const EdgeInsets.symmetric(vertical: 4), - child: Row( - children: [ - ClipRRect( - borderRadius: BorderRadius.circular(8), - child: ThumbBuilder( - cache: thumbs, - path: first.path, - builder: (context, t, _) => ThumbImage(t, width: 96, height: 54), - ), - ), - const SizedBox(width: 12), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text(group.title, - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: Theme.of(context).textTheme.titleMedium), - Text(detail, - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: TextStyle(color: scheme.onSurfaceVariant, fontSize: 12)), - ], - ), - ), - if (onTap != null) - AnimatedRotation( - turns: expanded ? 0.5 : 0, - duration: const Duration(milliseconds: 200), - child: Icon(Icons.expand_more, color: scheme.onSurfaceVariant), - ), - const SizedBox(width: 8), - ], - ), - ), - ), - ); - } -} - -/// One file as a row: an episode under its series header, or a Recent -/// entry ([title] given, [indent] off). +/// One file as a row: an episode on a show's page, or a Recent entry +/// ([indent] off). class FileRow extends StatelessWidget { final String path; final String title; @@ -207,7 +139,7 @@ const videoCardTextHeight = 66.0; /// A loose video (movie, clip): 16:9 frame with its duration, then the /// cleaned title and size. class VideoCard extends StatelessWidget { - final VideoFile video; + final LocalEpisode video; final PlayState state; final ThumbCache thumbs; final VoidCallback? onTap; @@ -281,7 +213,7 @@ class VideoCard extends StatelessWidget { ), ), ), - Text(formatSize(video.size), + Text(formatSize(video.file.size), style: TextStyle(color: scheme.onSurfaceVariant, fontSize: 12)), ], ), diff --git a/apps/desktop/lib/src/files/ui/files_page.dart b/apps/desktop/lib/src/files/ui/files_page.dart index ec08b1f..91cd38c 100644 --- a/apps/desktop/lib/src/files/ui/files_page.dart +++ b/apps/desktop/lib/src/files/ui/files_page.dart @@ -3,51 +3,57 @@ import 'dart:io'; import 'package:flutter/material.dart'; import 'package:signals/signals_flutter.dart'; +import 'package:sync_relay_core/core.dart'; +import '../../library/show_matcher.dart'; import '../../shell/motion.dart'; import '../../platform/ports/folder_access.dart'; import '../../app/app_graph.dart'; import '../../widgets/app_menu.dart'; import '../folder_store.dart'; -import '../name_parser.dart'; import '../scanner.dart'; import 'file_tiles.dart'; +import 'library_view.dart'; import 'thumb.dart'; export 'thumb.dart' show ThumbnailLoader; -// The Files source: watched folders on the left (chips on top when -// narrow), the open one on the right grouped into series and loose -// videos, or Recent. A folder is scanned when first opened and on Rescan; -// scans are kept for the app session. Series start collapsed; the ones -// opened stay open for the app session. Playing goes through [onPlay]. +// The Files source as a library: the watched folders on the left (chips +// on top when narrow): the Library (every folder together), Recent, then +// each folder. A library is shows and movies as poster cards, grouped by +// the library's L1 and named by its L2 match, plus the videos that are +// neither; a show opens its own page. Folders are scanned when first +// needed and on Rescan; scans, the open place and the open show are kept +// for the app session. Playing goes through [onPlay]. /// Below this width the sidebar turns into chips above the list. const filesNarrowWidth = 700.0; +/// The place that shows every watched folder together. +const libraryPlace = '\u0000library'; + /// Scans kept for the app session, by folder. final _scans = {}; final _running = >{}; -String? _lastOpen; -/// Series opened this session, by `folder\nseries key`. -final _expanded = {}; +String? _lastOpen = libraryPlace; +/// The show open in each place (by show key) for the app session. +final _openShows = {}; final _showRequests = ValueNotifier(0); - -/// Opens [dir] (null: Recent) in the Files page: now if one is showing, -/// else when it next opens. +/// Opens [dir] (null: Recent; [libraryPlace]: the library) in the Files +/// page: now if one is showing, else when it next opens. void showFilesFolder(String? dir) { _lastOpen = dir; _showRequests.value++; } -/// Forgets the session's scans, open series and the open folder. +/// Forgets the session's scans, open shows and the open place. @visibleForTesting void resetFilesPageSession() { _scans.clear(); _running.clear(); - _expanded.clear(); - _lastOpen = null; + _openShows.clear(); + _lastOpen = libraryPlace; } class FilesPage extends StatefulWidget { @@ -66,6 +72,8 @@ class FilesPage extends StatefulWidget { final Future Function(String dir)? scan; /// Defaults to [FolderStore.instance]. final FolderStore? store; + /// Defaults to the app's (`AppGraph.library`). + final ShowMatcher? matcher; /// Default: the system folder / file dialogs. final Future Function()? pickFolder; final Future Function()? pickVideo; @@ -80,6 +88,7 @@ class FilesPage extends StatefulWidget { this.thumbnails, this.scan, this.store, + this.matcher, this.pickFolder, this.pickVideo, }); @@ -88,52 +97,56 @@ class FilesPage extends StatefulWidget { State createState() => _FilesPageState(); } -/// A scan grouped for display, made once per scan. +/// A place's scans grouped (L1), made once per set of scans. class _Library { - final GroupedVideos grouped; - final int subtitleFiles; + final List scans; + final LocalLibrary lib; + final Map byPath; - _Library(FolderScan scan) - : grouped = groupBySeries(scan.videos), - subtitleFiles = {for (final l in scan.subtitles.values) ...l}.length; + _Library(this.scans, List roots) + : lib = groupLocal([for (final s in scans) ...s.videos], roots: roots), + byPath = {} { + for (final e in lib.files) { + byPath[e.path] = e; + } + } - /// Series rows in order: a header (video null), then, for the series - /// [open] says, its episodes and extras. - List<(SeriesGroup, VideoFile?)> rows(bool Function(SeriesGroup g) open) => [ - for (final g in grouped.series) ...[ - (g, null), - if (open(g)) - for (final v in [...g.episodes, ...g.extras]) (g, v), - ], - ]; + int get videos => scans.fold(0, (n, s) => n + s.videos.length); + int get hidden => scans.fold(0, (n, s) => n + s.hiddenCount); + int get subtitleFiles => { + for (final s in scans) + for (final l in s.subtitles.values) ...l + }.length; + bool get truncated => scans.any((s) => s.truncated); } -final _libraries = Expando<_Library>(); - class _FilesPageState extends State { FolderAccess get _folders => AppScope.of(context).ports.folders; + ShowMatcher get _matcher => widget.matcher ?? AppScope.of(context).library; late final ThumbCache _thumbs = widget.thumbnails == null ? ThumbCache.shared : ThumbCache(widget.thumbnails!); FolderStore get _store => widget.store ?? FolderStore.instance; - /// Null: Recent. + /// Null: Recent; [libraryPlace]: every folder. String? _open = _lastOpen; bool _showHidden = false; String? _starting; String? _error; + final _libs = {}; @override void initState() { super.initState(); _showRequests.addListener(_shown); + unawaited(_matcher.ensureLoaded()); _store.ensureLoaded().then((_) { if (!mounted) return; final open = _open; - if (open != null && !_store.folders.value.contains(open)) { - _select(null); - } else if (open != null) { - _scan(open); + if (open != null && open != libraryPlace && !_store.folders.value.contains(open)) { + _select(libraryPlace); + } else { + _scanFor(open); } }); } @@ -148,19 +161,32 @@ class _FilesPageState extends State { Future _runScan(String dir) => (widget.scan ?? _folders.scan)(dir); - void _select(String? dir) { + /// The folders a place needs scanned. + List _foldersOf(String? place) => place == null + ? const [] + : place == libraryPlace + ? _store.folders.peek() + : [place]; + + void _scanFor(String? place) { + for (final f in _foldersOf(place)) { + if (!_scans.containsKey(f) && !_running.containsKey(f)) _scan(f); + } + } + + void _select(String? place) { setState(() { - _open = _lastOpen = dir; + _open = _lastOpen = place; _showHidden = false; }); - if (dir != null && !_scans.containsKey(dir)) _scan(dir); + _scanFor(place); } Future _scan(String dir, {bool rescan = false}) async { var f = _running[dir]; if (f == null || rescan) { f = _running[dir] = _runScan(dir); - if (rescan) _thumbs.forget([for (final v in _scans[dir]?.videos ?? const []) v.path]); + if (rescan) _thumbs.forget([for (final v in _scans[dir]?.videos ?? const []) v.path]); } if (mounted) setState(() {}); FolderScan result; @@ -176,6 +202,26 @@ class _FilesPageState extends State { 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 a, List b) { + for (var i = 0; i < a.length; i++) { + if (!identical(a[i], b[i])) return false; + } + return true; + } + Future _addFolder() async { String? dir; try { @@ -207,7 +253,8 @@ class _FilesPageState extends State { void _removeFolder(String dir) { _store.removeFolder(dir); _scans.remove(dir); - if (_open == dir) _select(null); + if (_open == dir) _select(libraryPlace); + setState(() {}); } Future _play(String path) async { @@ -271,6 +318,22 @@ class _FilesPageState extends State { child: Builder( builder: (context) => GestureDetector(onSecondaryTap: () => _menu(context, path), child: child))); + late final _actions = FilesActions( + play: (p) => unawaited(_play(p)), + stateOf: _stateOf, + menuable: _menuable, + thumbs: _thumbs, + ); + + /// The last episode of [show] played here (Recent), by key. + String? _lastPlayed(_Library lib, LocalShow show) { + for (final p in _store.recent.value) { + final e = lib.byPath[p]; + if (e != null && e.showKey == show.key && e.kind == LocalKind.episode) return e.key; + } + return null; + } + @override Widget build(BuildContext context) => LayoutBuilder( builder: (context, box) { @@ -278,11 +341,13 @@ class _FilesPageState extends State { return SignalBuilder(builder: (context) { final folders = _store.folders.value; final recent = _store.recent.value; - final open = _open != null && folders.contains(_open) ? _open : null; + _matcher.revision.value; + final open = _open == null || _open == libraryPlace || folders.contains(_open) ? _open : libraryPlace; + final showKey = open == null ? null : _openShows[open]; final main = fadeThrough( KeyedSubtree( - key: ValueKey(open ?? ''), - child: _main(context, narrow, folders, recent, open), + key: ValueKey('${open ?? ''}\n${showKey ?? ''}'), + child: _main(context, narrow, folders, recent, open, showKey), ), ); if (narrow) return main; @@ -302,12 +367,23 @@ class _FilesPageState extends State { Widget _sidebar(BuildContext context, List folders, String? open) { final scheme = Theme.of(context).colorScheme; final shape = RoundedRectangleBorder(borderRadius: BorderRadius.circular(10)); + Widget? status(String? s) => + s == null ? null : Text(s, style: TextStyle(color: scheme.onSurfaceVariant, fontSize: 11)); return ListView( padding: const EdgeInsets.fromLTRB(20, 20, 8, 20), children: [ - const Text('Folders', style: TextStyle(fontWeight: FontWeight.w600)), - const SizedBox(height: 8), ListTile( + key: const ValueKey('place:library'), + dense: true, + selected: open == libraryPlace, + shape: shape, + leading: const Icon(Icons.video_library_outlined, size: 18), + title: const Text('Library'), + subtitle: status(_libraryStatus(folders)), + onTap: () => _select(libraryPlace), + ), + ListTile( + key: const ValueKey('place:recent'), dense: true, selected: open == null, shape: shape, @@ -315,6 +391,9 @@ class _FilesPageState extends State { title: const Text('Recent'), onTap: () => _select(null), ), + const SizedBox(height: 12), + const Text('Folders', style: TextStyle(fontWeight: FontWeight.w600)), + const SizedBox(height: 8), for (final f in folders) ListTile( key: ValueKey('folder:$f'), @@ -323,10 +402,7 @@ class _FilesPageState extends State { shape: shape, leading: const Icon(Icons.folder_outlined, size: 18), title: Text(baseName(f), maxLines: 1, overflow: TextOverflow.ellipsis), - subtitle: switch (_folderStatus(f)) { - final s? => Text(s, style: TextStyle(color: scheme.onSurfaceVariant, fontSize: 11)), - null => null, - }, + subtitle: status(_folderStatus(f)), trailing: IconButton( tooltip: 'Remove folder', icon: const Icon(Icons.close, size: 16), @@ -364,33 +440,77 @@ class _FilesPageState extends State { return n == null ? null : '$n ${n == 1 ? 'video' : 'videos'}'; } + String? _libraryStatus(List folders) { + if (folders.any(_running.containsKey)) return 'scanning…'; + if (folders.isEmpty || folders.any((f) => !_scans.containsKey(f))) return null; + final n = _libraryOf(libraryPlace)?.lib.shows.length ?? 0; + return '$n ${n == 1 ? 'show' : 'shows'}'; + } + + List _notices() => [ + if (!widget.alone && widget.relayed) const _RelayNotice(), + AnimatedSize( + duration: easeTime, + curve: ease, + alignment: Alignment.topLeft, + child: _error == null + ? const SizedBox(width: double.infinity) + : _ErrorBanner(_error!, onClose: () => setState(() => _error = null)), + ), + ]; + Widget _main( BuildContext context, bool narrow, List folders, List recent, String? open, + String? showKey, ) { - final scan = open == null ? null : _scans[open]; - final scanning = open != null && _running.containsKey(open); + final lib = open == null ? null : _libraryOf(open); + final show = showKey == null ? null : lib?.lib.show(showKey); + if (open != null && lib != null && show != null) { + return ShowPage( + key: ValueKey('show:$open:${show.key}'), + show: show, + matcher: _matcher, + actions: _actions, + lastPlayed: _lastPlayed(lib, show), + backLabel: open == libraryPlace ? 'Library' : baseName(open), + onBack: () => setState(() => _openShows.remove(open)), + top: [if (narrow) _chips(folders, open), ..._notices()], + ); + } + final scanning = _foldersOf(open).any(_running.containsKey); + // A folder added elsewhere (a drop, the handoff) joins the library. + if (open == libraryPlace && folders.any((f) => !_scans.containsKey(f) && !_running.containsKey(f))) { + scheduleMicrotask(() { + if (mounted) _scanFor(libraryPlace); + }); + } final top = [ _header(context, open), if (narrow) _chips(folders, open), - if (!widget.alone && widget.relayed) const _RelayNotice(), - AnimatedSize( - duration: easeTime, - curve: ease, - alignment: Alignment.topLeft, - child: _error == null - ? const SizedBox(width: double.infinity) - : _ErrorBanner(_error!, onClose: () => setState(() => _error = null)), - ), + ..._notices(), ]; final slivers = [ SliverList.list(children: top), if (open == null) ..._recent(context, recent) - else if (scan == null) + else if (open == libraryPlace && folders.isEmpty) + SliverToBoxAdapter( + child: FilesEmpty( + Icons.video_library_outlined, + 'Your library is empty', + 'Add a folder with your videos: shows, seasons and movies are found by their names.', + action: FilledButton.icon( + onPressed: _addFolder, + icon: const Icon(Icons.create_new_folder_outlined), + label: const Text('Add folder'), + ), + ), + ) + else if (lib == null) const SliverToBoxAdapter( child: Padding( padding: EdgeInsets.only(top: 60), @@ -398,7 +518,7 @@ class _FilesPageState extends State { ), ) else - ..._folder(context, open, scan, scanning), + ..._library(context, open, lib, scanning), ]; return CustomScrollView( slivers: [ @@ -412,25 +532,30 @@ class _FilesPageState extends State { Widget _header(BuildContext context, String? open) { final scheme = Theme.of(context).colorScheme; + final (icon, title, sub) = switch (open) { + null => (Icons.history, 'Recent', null), + libraryPlace => (Icons.video_library, 'Library', null), + final dir => (Icons.folder, baseName(dir), dir), + }; return Padding( padding: const EdgeInsets.only(bottom: 14), child: Row( children: [ - Icon(open == null ? Icons.history : Icons.folder, color: scheme.primary), + Icon(icon, color: scheme.primary), const SizedBox(width: 10), Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( - open == null ? 'Recent' : baseName(open), + title, maxLines: 1, overflow: TextOverflow.ellipsis, style: Theme.of(context).textTheme.headlineSmall, ), - if (open != null) + if (sub != null) Text( - open, + sub, maxLines: 1, overflow: TextOverflow.ellipsis, style: TextStyle(color: scheme.onSurfaceVariant, fontSize: 12), @@ -449,6 +574,11 @@ class _FilesPageState extends State { spacing: 8, runSpacing: 8, children: [ + ChoiceChip( + label: const Text('Library'), + selected: open == libraryPlace, + onSelected: (_) => _select(libraryPlace), + ), ChoiceChip( label: const Text('Recent'), selected: open == null, @@ -506,6 +636,7 @@ class _FilesPageState extends State { _RecentRow( key: ValueKey(path), path: path, + matcher: _matcher, state: _stateOf(path), thumbs: _thumbs, onTap: _tapOf(path), @@ -516,21 +647,32 @@ class _FilesPageState extends State { ]; } - List _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 _library(BuildContext context, String place, _Library lib, bool scanning) { final scheme = Theme.of(context).colorScheme; - final lib = _libraries[scan] ??= _Library(scan); - final series = lib.grouped.series.length; + final l = lib.lib; + final folderScan = place == libraryPlace ? null : _scans[place]; String count(int n, String one, String many) => '$n ${n == 1 ? one : many}'; final summary = [ - count(scan.videos.length, 'video', 'videos') + (series > 0 ? ' in $series series' : ''), - if (scan.hiddenCount > 0) - '${count(scan.hiddenCount, 'other file', 'other files')} hidden', + count(lib.videos, 'video', 'videos') + + (l.shows.isEmpty ? '' : ' in ${count(l.shows.length, 'show', 'shows')}'), + if (lib.hidden > 0) '${count(lib.hidden, 'other file', 'other files')} hidden', if (lib.subtitleFiles > 0) count(lib.subtitleFiles, 'subtitle file', 'subtitle files'), - if (scan.truncated) 'stopped after $maxScannedFiles files', + if (lib.truncated) 'stopped after $maxScannedFiles files', ].join(' · '); - final loose = lib.grouped.loose; - String seriesId(SeriesGroup g) => '$dir\n${g.key}'; - final rows = lib.rows((g) => _expanded.contains(seriesId(g))); + final folders = _foldersOf(place); + final movies = l.movies; + final other = l.other; return [ SliverToBoxAdapter( child: Wrap( @@ -539,7 +681,7 @@ class _FilesPageState extends State { Icon(Icons.filter_alt_outlined, size: 16, color: scheme.onSurfaceVariant), const SizedBox(width: 6), Text(summary, style: TextStyle(color: scheme.onSurfaceVariant, fontSize: 12)), - if (scan.hiddenCount > 0) ...[ + if (folderScan != null && folderScan.hiddenCount > 0) ...[ const SizedBox(width: 8), TextButton( onPressed: () => setState(() => _showHidden = !_showHidden), @@ -547,49 +689,55 @@ class _FilesPageState extends State { ), ], TextButton.icon( - onPressed: scanning ? null : () => _scan(dir, rescan: true), + onPressed: scanning + ? null + : () { + for (final f in folders) { + _scan(f, rescan: true); + } + }, icon: scanning - ? const SizedBox.square( - dimension: 14, child: CircularProgressIndicator(strokeWidth: 2)) + ? const SizedBox.square(dimension: 14, child: CircularProgressIndicator(strokeWidth: 2)) : const Icon(Icons.refresh, size: 16), label: Text(scanning ? 'Scanning…' : 'Rescan'), ), ], ), ), - SliverToBoxAdapter( - child: AnimatedSize( - duration: easeTime, - curve: ease, - alignment: Alignment.topLeft, - child: !_showHidden - ? const SizedBox(width: double.infinity) - : Column( - children: [ - for (final f in scan.hiddenExamples) - ListTile( - dense: true, - enabled: false, - leading: const Icon(Icons.insert_drive_file_outlined, size: 18), - title: Text(f, maxLines: 1, overflow: TextOverflow.ellipsis), - trailing: Text( - kindOf(f) == FileKind.video ? 'sample' : 'not a video', - style: const TextStyle(fontSize: 12), - ), - ), - if (scan.hiddenCount > scan.hiddenExamples.length) - ListTile( - dense: true, - enabled: false, - title: Text('and ${scan.hiddenCount - scan.hiddenExamples.length} more'), - ), - ], - ), - ), - ), - if (scan.videos.isEmpty) + if (folderScan != null) SliverToBoxAdapter( - child: _folders.folderExists(dir) + child: AnimatedSize( + duration: easeTime, + curve: ease, + alignment: Alignment.topLeft, + child: !_showHidden + ? const SizedBox(width: double.infinity) + : Column( + children: [ + for (final f in folderScan.hiddenExamples) + ListTile( + dense: true, + enabled: false, + leading: const Icon(Icons.insert_drive_file_outlined, size: 18), + title: Text(f, maxLines: 1, overflow: TextOverflow.ellipsis), + trailing: Text( + kindOf(f) == FileKind.video ? 'sample' : 'not a video', + style: const TextStyle(fontSize: 12), + ), + ), + if (folderScan.hiddenCount > folderScan.hiddenExamples.length) + ListTile( + dense: true, + enabled: false, + title: Text('and ${folderScan.hiddenCount - folderScan.hiddenExamples.length} more'), + ), + ], + ), + ), + ), + if (lib.videos == 0) + SliverToBoxAdapter( + child: place == libraryPlace || _folders.folderExists(place) ? const FilesEmpty( Icons.search_off, 'No videos here', @@ -601,46 +749,25 @@ class _FilesPageState extends State { 'It may have been moved, renamed or be on a drive that is not connected.', ), ), - // Series: files whose names carry an episode number; a header - // click opens or closes its episodes. - SliverList.builder( - itemCount: rows.length, - itemBuilder: (context, i) { - final (group, v) = rows[i]; - if (v == null) { - final id = seriesId(group); - return SeriesHeader( - key: ValueKey('series:${group.key}'), - group: group, - thumbs: _thumbs, - expanded: _expanded.contains(id), - onTap: () => setState(() => _expanded.contains(id) ? _expanded.remove(id) : _expanded.add(id)), - ); - } - return _menuable( - v.path, - FileRow( - key: ValueKey(v.path), - path: v.path, - title: v.parsed.label.isEmpty ? v.parsed.series : v.parsed.label, - subtitle: v.name, - size: formatSize(v.size), - state: _stateOf(v.path), - thumbs: _thumbs, - onTap: _tapOf(v.path), - )); - }, - ), - // Everything else (movies, clips) as a thumbnail grid. - if (loose.isNotEmpty) ...[ + if (l.shows.isNotEmpty) ...[ + SliverToBoxAdapter(child: _section(context, 'Shows', count: '${l.shows.length}')), + SliverGrid.builder( + gridDelegate: posterGrid(), + itemCount: l.shows.length, + itemBuilder: (context, i) => _showCard(place, lib, l.shows[i]), + ), + ], + if (movies.isNotEmpty) ...[ + SliverToBoxAdapter(child: _section(context, 'Movies', count: '${movies.length}')), + SliverGrid.builder( + gridDelegate: posterGrid(), + itemCount: movies.length, + itemBuilder: (context, i) => _movieCard(movies[i]), + ), + ], + if (other.isNotEmpty) ...[ SliverToBoxAdapter( - child: Padding( - padding: const EdgeInsets.only(top: 22, bottom: 10), - child: Text( - series == 0 ? 'Videos' : 'Other videos', - style: Theme.of(context).textTheme.titleMedium, - ), - ), + child: _section(context, l.shows.isEmpty && movies.isEmpty ? 'Videos' : 'Other videos'), ), SliverLayoutBuilder(builder: (context, c) { const gap = 12.0; @@ -654,9 +781,9 @@ class _FilesPageState extends State { crossAxisSpacing: gap, mainAxisExtent: cardW * 9 / 16 + videoCardTextHeight, ), - itemCount: loose.length, + itemCount: other.length, itemBuilder: (context, i) { - final v = loose[i]; + final v = other[i]; return _menuable( v.path, VideoCard( @@ -672,12 +799,60 @@ class _FilesPageState extends State { ], ]; } + + Widget _showCard(String place, _Library lib, LocalShow show) { + final m = _matcher.showMatch(show); + final last = _lastPlayed(lib, show); + final String subtitle; + if (last == null) { + final n = show.episodeCount; + subtitle = '$n ${n == 1 ? 'episode' : 'episodes'}'; + } else { + final next = show.nextAfter(last); + subtitle = next == null ? 'Watched to the end' : 'Next: ${episodeNumber(next.episode)}'; + } + final playing = widget.nowPlayingPath != null && show.files.any((e) => e.path == widget.nowPlayingPath); + return PosterCard( + key: ValueKey('showcard:${show.key}'), + title: m?.entry.title ?? show.title, + subtitle: subtitle, + coverUrl: m?.entry.cover, + framePath: show.episodes.firstOrNull?.path, + thumbs: _thumbs, + playing: playing, + onShown: () => _matcher.want(show), + onTap: () => setState(() => _openShows[place] = show.key), + ); + } + + Widget _movieCard(LocalEpisode movie) { + final m = _matcher.seasonMatch(movie); + return _menuable( + movie.path, + PosterCard( + key: ValueKey('moviecard:${movie.path}'), + title: m?.entry.title ?? (movie.parsed.year == null ? movie.title : '${movie.title} (${movie.parsed.year})'), + subtitle: [ + if (movie.movieNumber != null) movie.label, + if (m?.entry.year != null) '${m!.entry.year}' else if (movie.parsed.year != null) '${movie.parsed.year}', + formatSize(movie.file.size), + ].join(' · '), + coverUrl: m?.entry.cover, + framePath: movie.path, + thumbs: _thumbs, + playing: _stateOf(movie.path) == PlayState.playing, + onShown: () => _matcher.wantMovie(movie), + onTap: _tapOf(movie.path), + )); + } } -/// A Recent entry: `Series · S01E03` over the file name, or the cleaned -/// title over its folder; greyed when the file is gone. +/// A Recent entry: `Show · E02` over the file name (the library's names +/// when it knows the show), or the cleaned title over its folder; greyed +/// when the file is gone. class _RecentRow extends StatefulWidget { final String path; + final ShowMatcher matcher; final PlayState state; final ThumbCache thumbs; final VoidCallback? onTap; @@ -685,6 +860,7 @@ class _RecentRow extends StatefulWidget { const _RecentRow({ super.key, required this.path, + required this.matcher, required this.state, required this.thumbs, required this.onTap, @@ -711,12 +887,14 @@ class _RecentRowState extends State<_RecentRow> { @override Widget build(BuildContext context) { final path = widget.path; - final p = parseReleaseName(baseName(path)); + final e = widget.matcher.episodeAt(path) ?? + readLocalFile(LocalFile(path, 0, DateTime.fromMillisecondsSinceEpoch(0))); final missing = _stat?.type == FileSystemEntityType.notFound; - final episode = p.episode != null || p.extra != null; + final episode = e.kind == LocalKind.episode || e.kind == LocalKind.extra; + final name = widget.matcher.seasonMatch(e)?.entry.title ?? e.title; return FileRow( path: path, - title: episode ? '${p.series} · ${p.label}' : cleanTitle(p), + title: episode ? '$name · ${e.label}' : (e.kind == LocalKind.other ? cleanTitle(e.parsed) : name), subtitle: missing ? 'File not found' : (episode ? baseName(path) : parentName(path)), size: _stat == null || missing ? null : formatSize(_stat!.size), state: widget.state, diff --git a/apps/desktop/lib/src/files/ui/library_view.dart b/apps/desktop/lib/src/files/ui/library_view.dart new file mode 100644 index 0000000..a2e202b --- /dev/null +++ b/apps/desktop/lib/src/files/ui/library_view.dart @@ -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 createState() => _PosterCardState(); +} + +class _PosterCardState extends State { + 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 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 createState() => _ShowPageState(); +} + +class _ShowPageState extends State { + 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 _episodes(BuildContext context, LocalSeason season) { + final rows = []; + 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 showWrongShowDialog(BuildContext context, LocalShow show, ShowMatcher matcher) => + showDialog(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 createState() => _WrongShowDialogState(); +} + +class _WrongShowDialogState extends State { + late final _query = TextEditingController(text: widget.show.title.replaceAll(RegExp(r'[._]+'), ' ')); + List? _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 _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( + 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 showEpisodeNumbersDialog(BuildContext context, LocalShow show, ShowMatcher matcher) => + showDialog(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')), + ], + ); +} diff --git a/apps/desktop/lib/src/jellyfin/format.dart b/apps/desktop/lib/src/jellyfin/format.dart index 147aac0..5973267 100644 --- a/apps/desktop/lib/src/jellyfin/format.dart +++ b/apps/desktop/lib/src/jellyfin/format.dart @@ -1,5 +1,6 @@ import 'package:sync_relay_core/core.dart'; +import '../library/media_refs.dart'; import '../screens/home/picked_media.dart'; import 'accounts.dart'; @@ -89,5 +90,6 @@ JellyfinMedia pickFor(JellyfinAccount account, JellyfinItem item) { title: item.name, subtitle: subtitle, runtime: item.runTime, + ref: jellyfinMediaRef(item), ); } diff --git a/apps/desktop/lib/src/jellyfin/session_media.dart b/apps/desktop/lib/src/jellyfin/session_media.dart index c6d1c12..39680d6 100644 --- a/apps/desktop/lib/src/jellyfin/session_media.dart +++ b/apps/desktop/lib/src/jellyfin/session_media.dart @@ -7,6 +7,7 @@ import '../screens/home/picked_media.dart'; import '../session/party.dart' show isPresent; import 'accounts.dart'; import 'format.dart'; +import '../library/media_refs.dart'; // Jellyfin media in a session: turning picks and pasted links into refs // (the wire carries only `{serverId, itemId, ...}`), the hosted server's @@ -28,7 +29,8 @@ HelloMedia mediaForPick(JellyfinMedia m) => HelloMedia( name: m.title.isEmpty ? 'Jellyfin item' : m.title, size: 0, duration: 0, - jellyfin: refForPick(m)); + jellyfin: refForPick(m), + ref: m.ref ?? jellyfinPickRef(m.title, m.subtitle)); /// The pick a ref stands for on a linked server (for highlighting it in /// the browser); null when this install has no account there. @@ -78,7 +80,8 @@ final _sourceId = RegExp(r'^[A-Za-z0-9-]{1,64}$'); /// is one. Future mediaForLink(JellyfinAccounts accounts, String url, {Duration timeout = const Duration(seconds: 6)}) async { - final plain = HelloMedia(name: linkName(url), size: 0, duration: 0, url: url); + final name = linkName(url); + final plain = HelloMedia(name: name, size: 0, duration: 0, url: url, ref: linkMediaRef(name)); final link = parseJellyfinLink(url); if (link == null) return plain; await accounts.ensureLoaded(); @@ -103,9 +106,11 @@ Future mediaForLink(JellyfinAccounts accounts, String url, ); final account = accounts.account(serverId); final client = accounts.clientFor(serverId); + MediaRef? what; if (account != null && client != null) { try { final pick = pickFor(account, await client.item(link.itemId).timeout(timeout)); + what = pick.ref; ref = refForPick(JellyfinMedia( serverId: serverId, baseUrl: account.baseUrl, @@ -120,7 +125,11 @@ Future mediaForLink(JellyfinAccounts accounts, String url, } } return HelloMedia( - name: ref.title.isEmpty ? 'Jellyfin item' : ref.title, size: 0, duration: 0, jellyfin: ref); + name: ref.title.isEmpty ? 'Jellyfin item' : ref.title, + size: 0, + duration: 0, + jellyfin: ref, + ref: what ?? jellyfinPickRef(ref.title, ref.subtitle ?? '')); } /// Display name for a link: last path segment, decoded, never the query @@ -139,27 +148,28 @@ String linkName(String url) { return uri.host.isNotEmpty ? uri.host : 'link'; } -/// The hosted server's relay login: the account for [serverId] (the -/// hosted item's server), else the default account, when its token is not -/// known to be expired. The device fields are exactly the ones this app -/// sends with that token ([JellyfinAccounts.clientInfo]): Jellyfin rewrites -/// a token's device from the request header, so the relay must not differ. -JellyfinCredential? relayCredentialFor(JellyfinAccounts accounts, {String? serverId}) { - bool usable(JellyfinAccount? a) => - a != null && accounts.statusOf(a.serverId) != JellyfinAccountStatus.expired; - var a = serverId == null ? null : accounts.account(serverId); - if (!usable(a)) a = accounts.defaultAccount; - if (!usable(a)) return null; +/// The hosted server's relay logins: one per linked Jellyfin account +/// whose token is not known to be expired (the default account first), +/// so `/j` relays an item with its own server's login. The device fields +/// are exactly the ones this app sends with those tokens +/// ([JellyfinAccounts.clientInfo]): Jellyfin rewrites a token's device +/// from the request header, so the relay must not differ. +List relayCredentialsFor(JellyfinAccounts accounts) { final info = accounts.clientInfo; - return JellyfinCredential( - baseUrl: a!.baseUrl, - serverId: a.serverId, - token: a.token, - client: info.client, - device: info.device, - deviceId: info.deviceId, - version: info.version, - ); + final first = accounts.defaultAccount; + return [ + for (final a in [?first, ...accounts.accounts.where((a) => a.serverId != first?.serverId)]) + if (accounts.statusOf(a.serverId) != JellyfinAccountStatus.expired) + JellyfinCredential( + baseUrl: a.baseUrl, + serverId: a.serverId, + token: a.token, + client: info.client, + device: info.device, + deviceId: info.deviceId, + version: info.version, + ), + ]; } /// `/j/...` URL of the current Jellyfin media on the host this client diff --git a/apps/desktop/lib/src/library/library_store.dart b/apps/desktop/lib/src/library/library_store.dart new file mode 100644 index 0000000..736c05e --- /dev/null +++ b/apps/desktop/lib/src/library/library_store.dart @@ -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 ensureLoaded(); + Object? get(String key); + void put(String key, Object value); + void delete(String key); + + /// Every key starting with [prefix]. + Iterable keys(String prefix); +} + +/// For tests and as the fallback when Hive is not available. +class MemoryLibraryStore implements LibraryStore { + final Map values; + MemoryLibraryStore([Map? values]) : values = values ?? {}; + + @override + Future 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 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? _load; + + @override + Future 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 keys(String prefix) => [ + ...?_box?.keys.whereType().where((k) => k.startsWith(prefix)), + ..._memory.keys(prefix), + ]; +} diff --git a/apps/desktop/lib/src/library/media_refs.dart b/apps/desktop/lib/src/library/media_refs.dart new file mode 100644 index 0000000..8a8dca9 --- /dev/null +++ b/apps/desktop/lib/src/library/media_refs.dart @@ -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'; +} diff --git a/apps/desktop/lib/src/library/show_matcher.dart b/apps/desktop/lib/src/library/show_matcher.dart new file mode 100644 index 0000000..6f8a040 --- /dev/null +++ b/apps/desktop/lib/src/library/show_matcher.dart @@ -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 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 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, List)>? _load; + + Future<(List, List)> _fetch() async => + (await client.library(), await client.series()); + + @override + Future forAniList(int anilistId) async { + final load = _load ??= _fetch(); + final (List, List) 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 Function(Duration)? delay, + }) : _now = now ?? DateTime.now, + _delay = delay ?? Future.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 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>(const {}); + + // Files seen in scans this run, for refs of files put up. + final _byPath = {}; + final _entries = {}; + final _flows = {}; + final _jobs = Function()>{}; + final _failed = {}; + bool _pumping = false; + DateTime? _lastRequest; + DateTime? _pausedUntil; + + /// Loads the cache (once); reads before it finishes see nothing. + Future 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 = {}; + 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 = []; + 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 _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> 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 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 _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 _ask(Future 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 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 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; +} diff --git a/apps/desktop/lib/src/queue/queue_controller.dart b/apps/desktop/lib/src/queue/queue_controller.dart index 3b9cde7..f3cdb0b 100644 --- a/apps/desktop/lib/src/queue/queue_controller.dart +++ b/apps/desktop/lib/src/queue/queue_controller.dart @@ -7,11 +7,11 @@ import 'package:flutter/foundation.dart'; import 'package:signals/signals_flutter.dart'; import 'package:sync_relay_core/core.dart'; -import '../files/name_parser.dart'; import '../files/thumbnails.dart'; import '../jellyfin/accounts.dart'; import '../jellyfin/format.dart' show pickFor; import '../jellyfin/session_media.dart'; +import '../library/media_refs.dart'; import '../screens/home/picked_media.dart'; import '../session/app_events.dart' show AddedToUpNext; import '../session/media_art.dart'; @@ -164,15 +164,22 @@ class QueueController { if (bytes.length <= RelayConstants.maxPosterBytes) thumb = bytes; } catch (_) {} } - final ep = [ - if (parsed.season != null) 'S${parsed.season}', - if (parsed.episode != null) 'E${parsed.episode}', - ].join(); + // The library's name for it when it knows the show (what friends + // see in Up next), else the release name's. + final what = coord.fileRef?.call(path); + final ep = what != null && what.kind == 'episode' + ? (refEpisodeLine(what) ?? '').replaceAll(' ', '') + : [ + if (parsed.season != null) 'S${parsed.season}', + if (parsed.episode != null) 'E${parsed.episode}', + if (parsed.episode == null) ?parsed.extra, + ].join(); + final title = what != null && what.title.isNotEmpty ? what.title : parsed.series.trim(); return ( item: QueueItem( - media: HelloMedia(name: name, size: await file.length(), duration: 0), + media: HelloMedia(name: name, size: await file.length(), duration: 0, ref: what), how: QueueHow.file, - title: parsed.series.trim().isEmpty ? name : parsed.series.trim(), + title: title.isEmpty ? name : title, sub: ep, duration: (shot?.duration.inMilliseconds ?? 0) / 1000, key: _newKey(), @@ -187,7 +194,7 @@ class QueueController { if (ref != null) { return ( item: QueueItem( - media: HelloMedia(name: linked.name, size: 0, duration: 0, jellyfin: ref), + media: linked, how: QueueHow.jellyfin, title: ref.title.isEmpty ? linked.name : ref.title, sub: ref.subtitle ?? '', @@ -234,7 +241,8 @@ class QueueController { size: 0, duration: 0, url: how == QueueHow.through ? null : url, - relay: how == QueueHow.server ? const MediaRelay(fetch: true) : null), + relay: how == QueueHow.server ? const MediaRelay(fetch: true) : null, + ref: linkMediaRef(title)), how: how, title: title, sub: site, diff --git a/apps/desktop/lib/src/queue/queue_ui.dart b/apps/desktop/lib/src/queue/queue_ui.dart index 71b77a8..c12261b 100644 --- a/apps/desktop/lib/src/queue/queue_ui.dart +++ b/apps/desktop/lib/src/queue/queue_ui.dart @@ -9,7 +9,6 @@ import 'package:sync_relay_core/core.dart'; import '../controls/controls_probe.dart' show ControlsHold; import '../controls/minimal/style.dart'; -import '../files/scanner.dart' show FileKind, kindOf; import '../jellyfin/images.dart'; import '../jellyfin/session_media.dart' show EpisodeLink; import '../screens/home/picked_media.dart'; diff --git a/apps/desktop/lib/src/schedule/anilist.dart b/apps/desktop/lib/src/schedule/anilist.dart index e6fd6ac..bdad4f3 100644 --- a/apps/desktop/lib/src/schedule/anilist.dart +++ b/apps/desktop/lib/src/schedule/anilist.dart @@ -8,8 +8,10 @@ import 'package:sync_relay_core/core.dart'; // AniList airing schedule (public GraphQL, no login): every episode airing -// in a time range, with covers, banners and popularity; and a title search -// (cover + banner of a show, for the media art of files and links). +// in a time range, with covers, banners and popularity; a title search +// (cover + banner of a show, for the media art of files and links); and +// the library's lookups (search with synonyms and relations, one entry by +// id; see ARCHITECTURE.md "Media identity"). // // AniList allows ~90 requests/min (sometimes degraded to 30), so the raw // pages are cached: in memory for 10 min (tab switches) and on disk for an @@ -105,6 +107,177 @@ class AniListMedia { } } +/// An anime as the library matches it ([AniListClient.searchAnime], +/// [AniListClient.anime]): titles and synonyms to match on, its format +/// and length, art, and the next TV entry of its run ([sequel]) to number +/// across split seasons and cours. JSON round-trips for the library's +/// cache ([toJson]). +class AniListEntry { + final int id; + final String romaji; + final String english; + final String native; + final List 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 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 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 = {}; + 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 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()], + 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> searchAnime(String query); + + /// One anime by id; null when AniList has none. + Future anime(int id); +} + /// "#e4a15d" -> opaque [Color]; anything else -> null. Color? parseHexColor(Object? s) { if (s is! String || s.length != 7 || !s.startsWith('#')) return null; @@ -142,7 +315,7 @@ class _Cached { const _Cached(this.fetched, this.from, this.to, this.raw); } -class AniListClient { +class AniListClient implements AniListLookup { AniListClient({ String url = aniListDefaultUrl, HttpClient? http, @@ -306,6 +479,49 @@ coverImage{extraLarge large} bannerImage}}'''; } } + static final _listQuery = 'query(\$search:String){Page(perPage:8){media(search:\$search,type:ANIME,' + 'isAdult:false,sort:SEARCH_MATCH){${AniListEntry.graphQLFields}}}}'; + static final _byIdQuery = 'query(\$id:Int){Media(id:\$id,type:ANIME){${AniListEntry.graphQLFields}}}'; + + /// Up to 8 anime matching [query], AniList's best first (the library's + /// matching and its "Wrong show?" search). Not cached here: the library + /// keeps what it needs. + @override + Future> 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 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 mediaById(int id) async { + try { + final (data, _, _) = await _graphql(byIdArtQuery, {'id': id}); + return AniListMedia.fromJson(data?['Media']); + } on AniListException catch (e) { + if (e.statusCode == 404) return null; + rethrow; + } + } + static String _errorText(Object? j) { final errs = j is Map ? j['errors'] : null; if (errs is! List || errs.isEmpty) return ''; diff --git a/apps/desktop/lib/src/screens/home/picked_media.dart b/apps/desktop/lib/src/screens/home/picked_media.dart index d85160e..5f31a90 100644 --- a/apps/desktop/lib/src/screens/home/picked_media.dart +++ b/apps/desktop/lib/src/screens/home/picked_media.dart @@ -1,3 +1,5 @@ +import 'package:sync_relay_core/core.dart' show MediaRef; + /// What the media picker selected. Null selection = nothing (hosting /// without media is allowed). sealed class PickedMedia { @@ -72,6 +74,9 @@ final class JellyfinMedia extends PickedMedia { /// "Series · S1E6" for episodes, the year for movies; may be empty. final String subtitle; final Duration? runtime; + /// What it is (`media.ref`), when it was picked from the item itself + /// (with its provider ids); not stored, not part of equality. + final MediaRef? ref; const JellyfinMedia({ required this.serverId, required this.baseUrl, @@ -80,6 +85,7 @@ final class JellyfinMedia extends PickedMedia { required this.title, this.subtitle = '', this.runtime, + this.ref, }); @override diff --git a/apps/desktop/lib/src/session/media_art.dart b/apps/desktop/lib/src/session/media_art.dart index 27d70da..4dc92bf 100644 --- a/apps/desktop/lib/src/session/media_art.dart +++ b/apps/desktop/lib/src/session/media_art.dart @@ -7,7 +7,6 @@ import 'package:flutter/foundation.dart'; import 'package:image/image.dart' as img; import 'package:sync_relay_core/core.dart'; -import '../files/name_parser.dart'; import '../schedule/anilist.dart'; // Art for media this client swapped in (`art`, see PROTOCOL.md "Media @@ -58,6 +57,11 @@ Future aniListArt(AniListClient aniList, String name, if (!RegExp(r'\p{L}', unicode: true).hasMatch(series)) return null; final match = await aniList.searchMedia(series); if (match == null) return null; + return _artOf(match, fetch); +} + +Future _artOf(AniListMedia match, + Future Function(Uri url, Map headers, int maxBytes) fetch) async { Future get(String? url, int maxBytes) async { final uri = url == null ? null : Uri.tryParse(url); return uri == null ? null : fetch(uri, const {}, maxBytes); @@ -71,6 +75,14 @@ Future aniListArt(AniListClient aniList, String name, return (poster: poster, banner: banner); } +/// Cover and banner of AniList entry [id] (a file the library matched); +/// null when AniList has none. +Future aniListArtById(AniListClient aniList, int id, + {Future Function(Uri url, Map headers, int maxBytes) fetch = fetchImage}) async { + final match = await aniList.mediaById(id); + return match == null ? null : _artOf(match, fetch); +} + /// A web video's thumbnail at [url] as both cover and banner (the same /// bytes, so the same hash: what tells clients it is a thumbnail, see /// `NowPlaying.thumbnail`); shrunk to a JPEG under the cover cap when diff --git a/apps/desktop/lib/src/session/now_playing_info.dart b/apps/desktop/lib/src/session/now_playing_info.dart index acad9e3..5fd9ebc 100644 --- a/apps/desktop/lib/src/session/now_playing_info.dart +++ b/apps/desktop/lib/src/session/now_playing_info.dart @@ -1,6 +1,6 @@ -import 'package:sync_relay_core/core.dart' show hasMediaExtension; +import 'package:sync_relay_core/core.dart' show MediaRef, hasMediaExtension, parseReleaseName; -import '../files/name_parser.dart'; +import '../library/media_refs.dart' show refEpisodeLine; import '../jellyfin/session_media.dart'; import 'now_playing.dart'; @@ -26,12 +26,29 @@ NowPlaying? nowPlayingOf(Map? media) { if (web) { return NowPlaying(title: name, posterHash: poster, bannerHash: banner, thumbnail: true); } + final what = _mediaRef(media['ref']); final (title, episode) = ref != null ? jellyfinTitle(ref.title.isNotEmpty ? ref.title : name, ref.subtitle) - : fileTitle(name); + : what != null && what.title.isNotEmpty + ? refTitle(what, name) + : fileTitle(name); return NowPlaying(title: title, episode: episode, posterHash: poster, bannerHash: banner); } +MediaRef? _mediaRef(Object? j) { + try { + return MediaRef.fromJson(j); + } catch (_) { + return null; + } +} + +/// Files and links that say what they are (`media.ref`, from the +/// sender's library): its proper title and `S3 E13` / `E108`; an extra +/// keeps the name's own marker (OP1, NCED2). +(String, String?) refTitle(MediaRef ref, String name) => + (ref.title, refEpisodeLine(ref) ?? (ref.kind == 'extra' ? fileTitle(name).$2 : null)); + /// [media] (`state.media`, not Jellyfin) is a web page's video, whose art /// will be its thumbnail: a shared http(s) link that is no media file (the /// everyone-opens-it mode), or a relayed one (passed on by a member or diff --git a/apps/desktop/lib/src/session/session_coordinator.dart b/apps/desktop/lib/src/session/session_coordinator.dart index 111b2bd..854244e 100644 --- a/apps/desktop/lib/src/session/session_coordinator.dart +++ b/apps/desktop/lib/src/session/session_coordinator.dart @@ -12,6 +12,7 @@ import '../jellyfin/accounts.dart'; import '../jellyfin/progress.dart'; import '../jellyfin/session_media.dart'; import '../jellyfin/track_prefs.dart'; +import '../library/media_refs.dart'; import '../playback/media_marks.dart'; import '../playback/playback_controller.dart'; import '../platform/ports/link_resolver.dart'; @@ -118,6 +119,9 @@ class SessionCoordinator { /// Set by the hosting app: makes a local file the hosted one (File swap); /// null when this app is not the host. Future<({String name, int size})?> Function(String path)? hostFile; + /// What a local file is (`media.ref`, from the library: `ShowMatcher`), + /// set by the composition root; null: files go up without one. + MediaRef? Function(String path)? fileRef; /// The app's yt-dlp and link probes (tests pass their own). final LinkResolver ytDlp; /// The link to the server (socket, reconnects, clock offset, its @@ -1449,8 +1453,11 @@ class SessionCoordinator { final uri = thumb == null ? null : Uri.tryParse(thumb); bytes = uri == null ? null : await thumbnailArt(uri); } else { + // The library's match names the AniList entry; else a title search. + final known = ref == null ? media.ref?.anilistId : null; final name = ref == null ? media.name : jellyfinTitle(ref.title, ref.subtitle).$1; - bytes = await aniListArt(_aniList ??= AniListClient(), name); + final aniList = _aniList ??= AniListClient(); + bytes = await (known != null ? aniListArtById(aniList, known) : null) ?? await aniListArt(aniList, name); } final poster = bytes?.poster, banner = bytes?.banner; if (poster == null && banner == null) return; @@ -1491,8 +1498,9 @@ class SessionCoordinator { // A page is named by its title, never its URL tail ("1", "watch"). final uri = Uri.tryParse(url) ?? Uri(); final name = probe?.title ?? (probe != null || hasMediaExtension(uri) || uri.host.isEmpty ? media.name : uri.host); + final what = linkMediaRef(name); if (relay == LinkRelay.direct) { - _swap(HelloMedia(name: name, size: 0, duration: 0, url: url), queueId: queueId); + _swap(HelloMedia(name: name, size: 0, duration: 0, url: url, ref: what), queueId: queueId); return null; } if (relay == LinkRelay.me) { @@ -1526,8 +1534,8 @@ class SessionCoordinator { await check?.close(); } _pendingOrigin = _LinkOrigin(url, probe, y); - _swap(HelloMedia( - name: name, size: size, duration: 0, relay: MediaRelay(tracks: specs)), link: url, queueId: queueId); + _swap(HelloMedia(name: name, size: size, duration: 0, relay: MediaRelay(tracks: specs), ref: what), + link: url, queueId: queueId); return null; } // The server reads it with its own yt-dlp; only a page this app knows @@ -1537,7 +1545,12 @@ class SessionCoordinator { return probe.relayProblem; } _swap(HelloMedia( - name: name, size: max(0, probe?.size ?? 0), duration: 0, url: url, relay: const MediaRelay(fetch: true)), + name: name, + size: max(0, probe?.size ?? 0), + duration: 0, + url: url, + relay: const MediaRelay(fetch: true), + ref: what), queueId: queueId); return null; } @@ -1563,18 +1576,23 @@ class SessionCoordinator { /// when sent. Future swapFile(String path, {String? queueId}) async { markInput(); + final what = fileRef?.call(path); final host = hostFile; if (host != null) { final file = await host(path); if (file == null) return 'file not found — check the path'; - _swap(HelloMedia(name: file.name, size: file.size, duration: 0), queueId: queueId); + _swap(HelloMedia(name: file.name, size: file.size, duration: 0, ref: what), queueId: queueId); return null; } final file = File(path); if (!await file.exists()) return 'file not found — check the path'; _pendingOrigin = _FileOrigin(path); _swap(HelloMedia( - name: file.uri.pathSegments.last, size: await file.length(), duration: 0, relay: const MediaRelay()), + name: file.uri.pathSegments.last, + size: await file.length(), + duration: 0, + relay: const MediaRelay(), + ref: what), queueId: queueId); return null; } diff --git a/apps/desktop/lib/src/session/your_server.dart b/apps/desktop/lib/src/session/your_server.dart index 0d75bbc..fd2eaaa 100644 --- a/apps/desktop/lib/src/session/your_server.dart +++ b/apps/desktop/lib/src/session/your_server.dart @@ -8,7 +8,7 @@ import 'package:signals/signals.dart'; import 'package:sync_relay_core/core.dart'; import '../jellyfin/accounts.dart'; -import '../jellyfin/session_media.dart' show relayCredentialFor; +import '../jellyfin/session_media.dart' show relayCredentialsFor; import '../state/connect_prefs.dart'; import '../state/server_isolate.dart'; import 'invite.dart'; @@ -60,7 +60,7 @@ class YourServer { _interfaces = interfaces ?? machineInterfaces; final ConnectStore forms; - /// Linked Jellyfin servers (default: the app's): the relay login. + /// Linked Jellyfin servers (default: the app's): the relay logins. final JellyfinAccounts? jellyfin; final Future Function() _ytDlpDir; final String? Function() _logDir; @@ -156,8 +156,8 @@ class YourServer { port: want, secret: form.hostSecret, // Guests without an account watch Jellyfin items through the - // server, with this app's own sign-in. - jellyfin: relayCredentialFor(_accounts), + // server, with this app's own sign-in on the item's server. + jellyfin: relayCredentialsFor(_accounts), ); final invite = await inviteFor(bound, password: form.hostSecret.isNotEmpty); shared.value = invite; diff --git a/apps/desktop/lib/src/state/server_isolate.dart b/apps/desktop/lib/src/state/server_isolate.dart index e18a03e..d8269dd 100644 --- a/apps/desktop/lib/src/state/server_isolate.dart +++ b/apps/desktop/lib/src/state/server_isolate.dart @@ -159,9 +159,10 @@ class ServerIsolate { /// Opens the server to the network ([SyncServer.share]): a listener on /// every interface at [port], the password [secret], the Jellyfin relay - /// login [jellyfin]. Returns the bound port; throws a [StateError] with + /// logins [jellyfin] (one per Jellyfin server). Returns the bound port; + /// throws a [StateError] with /// the server's reason (a port in use) when it cannot. - Future share({required int port, required String secret, JellyfinCredential? jellyfin, + Future share({required int port, required String secret, List? jellyfin, Duration timeout = const Duration(seconds: 10)}) async { if (_exited.isCompleted) throw StateError('the server is not running'); final reply = ReceivePort('server share'); @@ -170,7 +171,7 @@ class ServerIsolate { 't': 'share', 'port': port, 'secret': secret, - 'jellyfin': ?jellyfin?.toMap(), + if (jellyfin != null) 'jellyfin': [for (final c in jellyfin) c.toMap()], 'reply': reply.sendPort, }); final msg = await reply.first.timeout(timeout); diff --git a/apps/desktop/test/files_page_test.dart b/apps/desktop/test/files_page_test.dart index 65770c5..14672e3 100644 --- a/apps/desktop/test/files_page_test.dart +++ b/apps/desktop/test/files_page_test.dart @@ -1,5 +1,3 @@ -// restructure: rewrite in step 4 (Library + Media identity): series -// grouping on the page follows L1/L2. import 'dart:async'; import 'dart:io'; import 'dart:typed_data'; @@ -12,21 +10,54 @@ import 'package:starsync/src/files/scanner.dart'; import 'package:starsync/src/files/thumbnails.dart'; import 'package:starsync/src/files/ui/file_tiles.dart'; import 'package:starsync/src/files/ui/files_page.dart'; +import 'package:starsync/src/files/ui/library_view.dart'; +import 'package:starsync/src/library/library_store.dart'; +import 'package:starsync/src/library/show_matcher.dart'; +import 'package:starsync/src/schedule/anilist.dart'; import 'test_graph.dart'; /// This file's composition root (see test_graph.dart). final _graph = testGraph(); -// The Files page over a real (tiny) folder tree: the scan runs in-line -// instead of in an isolate and thumbnails come from a fake loader, so -// nothing here decodes video. +// The Files tab as a library over a real (tiny) generated tree with the +// names of a real anime folder: the scan runs in-line instead of in an +// isolate, thumbnails come from a fake loader and AniList is a fake, so +// nothing here decodes video or goes online. + +final _tmp = Platform.environment['WT_TEST_TMP'] ?? Directory.systemTemp.path; late Directory tmp; late String anime; late FolderStore store; late List thumbRequests; late int scans; +late ShowMatcher matcher; +late FakeAniList aniList; + +const b1 = '[Judas] Bleach 001-055 [BD 1080p][HEVC x265 10bit]'; +const b2 = '[Judas] Bleach 056-111 [BD 1080p][HEVC x265 10bit]'; +const b3 = '[Judas] Bleach 112-139 [BD 1080p][HEVC x265 10bit][Dual-Audio][Eng-Subs]'; +const sao = '[Judas] Sword Art Online (Season 3 - Alicization) [BD 1080p][HEVC x265 10bit][Eng-Subs]'; +const kimetsu = + '[Erai-raws] Kimetsu no Yaiba - Mugenjou Hen - Movie 01 [1080p CR WEBRip HEVC AAC][MultiSub][8E47462C].mkv'; + +String bleach(int n) => '$anime/${n < 112 ? b2 : b3}/[Judas] Bleach - ${n.toString().padLeft(3, '0')}.mkv'; + +const _tybw = AniListEntry( + id: 41467, romaji: 'BLEACH: Sennen Kessen-hen', english: 'Bleach: Thousand-Year Blood War', format: 'TV', episodes: 13); + +class FakeAniList implements AniListLookup { + final searches = []; + @override + Future> searchAnime(String query) async { + searches.add(query); + return query.toLowerCase().contains('thousand') ? const [_tybw] : const []; + } + + @override + Future anime(int id) async => null; +} void touch(String path) => File(path) ..createSync(recursive: true) @@ -56,6 +87,7 @@ Future pumpPage( Future Function(String path)? onPlay, ThumbnailLoader? thumbnails, Future Function()? pickFolder, + String? nowPlayingPath, }) async { tester.view.physicalSize = size; tester.view.devicePixelRatio = 1; @@ -69,45 +101,62 @@ Future pumpPage( thumbnails: thumbnails ?? fakeThumb, scan: inlineScan, store: store, + matcher: matcher, pickFolder: pickFolder, + nowPlayingPath: nowPlayingPath, ), ), ))); await frames(tester); } -Future openFolder(WidgetTester tester, String name) async { +Future openPlace(WidgetTester tester, String name) async { await tester.tap(find.text(name).first); await frames(tester); } -/// Series start collapsed: a click on the header lists the episodes. -Future openSeries(WidgetTester tester, [String title = 'Show']) async { - await tester.tap(find.descendant(of: find.byType(SeriesHeader), matching: find.text(title))); +/// Scrolls the main list until [f] is on screen. +Future reveal(WidgetTester tester, Finder f) async { + await tester.scrollUntilVisible(f, 150, + scrollable: find.descendant(of: find.byType(CustomScrollView), matching: find.byType(Scrollable)).first); + await frames(tester, 3); +} + +/// A show's card in the library opens its page. +Future openShow(WidgetTester tester, String title) async { + final card = find.descendant(of: find.byType(PosterCard), matching: find.text(title)); + await reveal(tester, card); + await tester.tap(card); await frames(tester); } void main() { setUp(() async { - tmp = Directory.systemTemp.createTempSync('files_page_'); - anime = '${tmp.path}/anime'; - for (final f in [ - '[Grp] Show - 01.mkv', - '[Grp] Show - 02.mkv', - '[Grp] Show - 03.mkv', - '[Grp] Show - NCOP1.mkv', - '[Grp] Show - 01.srt', - 'Some Movie (2019).mkv', - 'notes.txt', - 'extras/cover.jpg', - ]) { - touch('$anime/$f'); + tmp = Directory(_tmp).createTempSync('files_page_'); + anime = '${tmp.path}/Binux'; + for (final j in ['App', 'Audio', 'Video']) { + touch('$anime/$b1/$j#D4aIyEEtZEWR9gBorzBWiMh_.lnk'); } + for (final n in [56, 57, 58, 59, 60, 61, 62, 63, 64, 65, 108, 109, 110, 111, 112, 113]) { + touch(bleach(n)); + } + for (var n = 1; n <= 3; n++) { + touch('$anime/$sao/[Judas] Sword Art Online S3 - 0$n.mkv'); + } + for (final x in ['Opening 1', 'Ending 1']) { + touch('$anime/$sao/[Judas] Sword Art Online S3 - $x.mkv'); + } + touch('$anime/$sao/[Judas] Sword Art Online S3 - 01.srt'); + touch('$anime/[Judas] Re.Zero - S04E17.mkv'); + touch('$anime/$kimetsu'); + touch('$anime/notes.txt'); // An in-memory box: the store loads and writes through it, with no // file I/O left pending in the widget tests' fake-async zone. await Hive.openBox(filesBoxName, bytes: Uint8List(0)); store = FolderStore(); await store.ensureLoaded(); + aniList = FakeAniList(); + matcher = ShowMatcher(store: MemoryLibraryStore(), aniList: aniList, delay: (_) async {}); thumbRequests = []; scans = 0; resetFilesPageSession(); @@ -118,102 +167,145 @@ void main() { tmp.deleteSync(recursive: true); }); - testWidgets('sidebar names folders by their last segment, header follows', (tester) async { - store.addFolder('$anime/'); - await pumpPage(tester, pickFolder: () async => '${tmp.path}/movies'); - expect(find.text('Folders'), findsOneWidget); - expect(find.text('anime'), findsOneWidget); - expect(find.text('Recent'), findsNWidgets(2)); // sidebar + header + testWidgets('the Library first: empty until a folder is added, then its shows', (tester) async { + await pumpPage(tester, pickFolder: () async => anime); + expect(find.text('Library'), findsNWidgets(2)); // sidebar + header + expect(find.text('Your library is empty'), findsOneWidget); + await tester.tap(find.text('Add folder').first); + await frames(tester); + expect(store.folders.value, [anime]); + expect(find.text('Binux'), findsNWidgets(2)); // sidebar + header + expect(scans, 1); + // Back to the Library: the same scan, now grouped into shows. + await openPlace(tester, 'Library'); + expect(scans, 1); + expect(find.text('3 shows'), findsOneWidget); // sidebar + expect(find.byType(PosterCard), findsNWidgets(4)); + expect(find.text('Shows'), findsOneWidget); + expect(find.text('Movies'), findsOneWidget); + + await openPlace(tester, 'Recent'); expect(find.text('Nothing played yet'), findsOneWidget); - expect(scans, 0, reason: 'folders are scanned when opened'); - - await openFolder(tester, 'anime'); - expect(find.text('anime'), findsNWidgets(2)); // sidebar + header - expect(find.text('$anime/'), findsOneWidget); // full path under it - expect(find.text('Recent'), findsOneWidget); - expect(find.text('5 videos'), findsOneWidget); // sidebar subtitle - expect(scans, 1); - - // Back and forth: the scan is kept. - await openFolder(tester, 'Recent'); - await openFolder(tester, 'anime'); - expect(scans, 1); - - // Add folder: picked, stored, opened. - Directory('${tmp.path}/movies').createSync(); - await tester.tap(find.text('Add folder')); + await tester.tap(find.byTooltip('Remove folder')); await frames(tester); - expect(store.folders.value, ['$anime/', '${tmp.path}/movies']); - expect(find.text('movies'), findsNWidgets(2)); - expect(find.text('No videos here'), findsOneWidget); - - await tester.tap(find.byTooltip('Remove folder').last); - await frames(tester); - expect(store.folders.value, ['$anime/']); - expect(find.text('Recent'), findsNWidgets(2)); + expect(store.folders.value, isEmpty); }); - testWidgets('episodes group into series, the rest is a card grid', (tester) async { + testWidgets('shows, a movie, counts and what is hidden', (tester) async { store.addFolder(anime); await pumpPage(tester); - await openFolder(tester, 'anime'); - expect(find.byType(SeriesHeader), findsOneWidget); - expect(find.text('Show'), findsOneWidget); - expect(find.text('3 episodes · 1 extra · Grp'), findsOneWidget); - // Collapsed until clicked; open for the rest of the session. - expect(find.text('E01'), findsNothing); - await openSeries(tester); - for (final l in ['E01', 'E02', 'E03', 'NCOP1']) { - expect(find.text(l), findsOneWidget); + for (final t in ['Bleach', 'Re.Zero', 'Sword Art Online']) { + expect(find.descendant(of: find.byType(PosterCard), matching: find.text(t)), findsOneWidget); } - expect(find.text('[Grp] Show - 02.mkv'), findsOneWidget); - expect(find.byType(VideoCard), findsOneWidget); - expect(find.text('Some Movie (2019)'), findsOneWidget); - expect(find.text('Other videos'), findsOneWidget); - // Durations come from the thumbnail. - expect(find.text('24:00 · 1 KB'), findsNWidgets(4)); - expect(find.text('24:00'), findsOneWidget); // card badge + expect(find.text('16 episodes'), findsOneWidget); // Bleach + expect(find.text('Kimetsu no Yaiba - Mugenjou Hen'), findsOneWidget); + expect(find.text('Movie 1 · 1 KB'), findsOneWidget); + expect(find.text('23 videos in 3 shows · 4 other files hidden · 1 subtitle file'), findsOneWidget); + expect(find.byType(VideoCard), findsNothing, reason: 'nothing is left over'); - await openFolder(tester, 'Recent'); - await openFolder(tester, 'anime'); - expect(find.text('E01'), findsOneWidget); - await openSeries(tester); - expect(find.text('E01'), findsNothing); - expect(find.byType(VideoCard), findsOneWidget); - }); - - testWidgets('hidden files are counted and some can be shown', (tester) async { - store.addFolder(anime); - await pumpPage(tester); - await openFolder(tester, 'anime'); - expect(find.text('5 videos in 1 series · 2 other files hidden · 1 subtitle file'), - findsOneWidget); - expect(find.text('notes.txt'), findsNothing); + // The folder view says which files were left out. + await openPlace(tester, 'Binux'); await tester.tap(find.text('Show some')); await frames(tester); expect(find.text('notes.txt'), findsOneWidget); - expect(find.text('cover.jpg'), findsOneWidget); - expect(find.text('not a video'), findsNWidgets(2)); - await tester.tap(find.text('Hide them')); - await frames(tester); - expect(find.text('notes.txt'), findsNothing); + expect(find.text('App#D4aIyEEtZEWR9gBorzBWiMh_.lnk'), findsOneWidget); }); - testWidgets('rescan picks up new files', (tester) async { + testWidgets('a show page: season with its name, episodes, extras', (tester) async { store.addFolder(anime); await pumpPage(tester); - await openFolder(tester, 'anime'); - await openSeries(tester); - touch('$anime/[Grp] Show - 04.mkv'); - expect(find.text('E04'), findsNothing); - await tester.tap(find.text('Rescan')); + await openShow(tester, 'Sword Art Online'); + expect(find.text('Season 3 · Alicization'), findsOneWidget); + for (final e in ['Episode 1', 'Episode 2', 'Episode 3']) { + expect(find.text(e), findsOneWidget); + } + expect(find.text('Extras'), findsOneWidget); + expect(find.text('OP1'), findsOneWidget); + expect(find.text('ED1'), findsOneWidget); + expect(find.text('Play: S3 E1'), findsOneWidget); + expect(find.text('Not matched on AniList'), findsOneWidget); + // The open show stays open for the session; Back returns. + await openPlace(tester, 'Recent'); + await openPlace(tester, 'Library'); + expect(find.text('Season 3 · Alicization'), findsOneWidget); + await tester.tap(find.text('Library').last); await frames(tester); - expect(scans, 2); - expect(find.text('E04'), findsOneWidget); - expect(find.text('4 episodes · 1 extra · Grp'), findsOneWidget); + expect(find.byType(PosterCard), findsNWidgets(4)); }); - testWidgets('a play goes to onPlay and into Recent', (tester) async { + testWidgets('next episode knows what is missing: start at 56, then skip to 108', (tester) async { + store.addFolder(anime); + final played = []; + await pumpPage(tester, onPlay: (p) async { + played.add(p); + return null; + }); + await openShow(tester, 'Bleach'); + expect(find.text('1–55 aren\'t in your folders — start at 56?'), findsOneWidget); + await tester.tap(find.text('Start at 56')); + await frames(tester); + expect(played, [bleach(56)]); + await reveal(tester, find.text('66–107 aren\'t in your folders')); // in the list + + // Watched up to 65: the next one is 108, and the page says why. + await reveal(tester, find.text('Episode 65')); + await tester.tap(find.text('Episode 65')); + await frames(tester); + expect(store.recent.value.first, bleach(65)); + await tester.drag(find.byType(CustomScrollView), const Offset(0, 3000)); + await frames(tester, 3); + expect(find.text('66–107 aren\'t in your folders — skip to 108?'), findsOneWidget); + await tester.tap(find.text('Skip to 108')); + await frames(tester); + expect(played.last, bleach(108)); + expect(find.text('Next: 109'), findsOneWidget); + + // The card says it too. + await tester.tap(find.text('Library').last); + await frames(tester); + expect(find.text('Next: 109'), findsOneWidget); + + // Recent names the show and episode. + await openPlace(tester, 'Recent'); + expect(find.text('Bleach · E108'), findsOneWidget); + }); + + testWidgets('Wrong show? pins the right AniList entry and the names follow', (tester) async { + store.addFolder(anime); + await pumpPage(tester); + await openShow(tester, 'Bleach'); + await tester.tap(find.text('Wrong show?')); + await frames(tester); + await tester.enterText(find.byType(TextField), 'Bleach Thousand-Year Blood War'); + await tester.testTextInput.receiveAction(TextInputAction.done); + await frames(tester); + expect(aniList.searches.last, 'Bleach Thousand-Year Blood War'); + await tester.tap(find.text('Bleach: Thousand-Year Blood War')); + await frames(tester); + expect(matcher.showPin('bleach'), 41467); + expect(find.text('Bleach: Thousand-Year Blood War'), findsOneWidget); // the page's title + expect(find.text('pinned'), findsOneWidget); + expect(find.textContaining('files say "Bleach"'), findsOneWidget); + await tester.tap(find.text('Library').last); + await frames(tester); + expect(find.descendant(of: find.byType(PosterCard), matching: find.text('Bleach: Thousand-Year Blood War')), + findsOneWidget); + }); + + testWidgets('episode numbers: the first file is episode N', (tester) async { + store.addFolder(anime); + await pumpPage(tester); + await openShow(tester, 'Sword Art Online'); + await tester.tap(find.text('Episode numbers…')); + await frames(tester); + await tester.enterText(find.byKey(const ValueKey('offset:3')), '25'); + await tester.tap(find.text('Save')); + await frames(tester); + expect(matcher.offset('sword art online', 3), 24); + expect(find.text('Episode 25'), findsOneWidget); + }); + + testWidgets('a play goes to onPlay and into Recent; failures are shown, not kept', (tester) async { store.addFolder(anime); final played = []; String? reply; @@ -222,66 +314,39 @@ void main() { return reply; }); expect(find.textContaining('Keep the app open while it plays'), findsOneWidget); - await openFolder(tester, 'anime'); - await openSeries(tester); - - await tester.tap(find.text('E02')); - await frames(tester); - expect(played, ['$anime/[Grp] Show - 02.mkv']); - expect(store.recent.value, ['$anime/[Grp] Show - 02.mkv']); - reply = 'Nobody can play mkv here'; - await tester.tap(find.text('Some Movie (2019)')); + await reveal(tester, find.text('Kimetsu no Yaiba - Mugenjou Hen')); + await tester.tap(find.text('Kimetsu no Yaiba - Mugenjou Hen')); await frames(tester); - expect(played, hasLength(2)); + await tester.drag(find.byType(CustomScrollView), const Offset(0, 3000)); + await frames(tester); + expect(played, ['$anime/$kimetsu']); expect(find.text('Nobody can play mkv here'), findsOneWidget); - expect(store.recent.value, hasLength(1), reason: 'failed plays are not recent'); - - await openFolder(tester, 'Recent'); - expect(find.text('Show · E02'), findsOneWidget); - expect(find.text('[Grp] Show - 02.mkv'), findsOneWidget); + expect(store.recent.value, isEmpty); }); - testWidgets('now playing is marked', (tester) async { + testWidgets('now playing is marked on its show and row', (tester) async { store.addFolder(anime); - tester.view.physicalSize = const Size(1280, 800); - tester.view.devicePixelRatio = 1; - addTearDown(tester.view.reset); - await tester.pumpWidget(scoped(_graph, MaterialApp( - home: Scaffold( - body: FilesPage( - relayed: false, - onPlay: (_) async => null, - nowPlayingPath: '$anime/[Grp] Show - 03.mkv', - thumbnails: fakeThumb, - scan: inlineScan, - store: store, - ), - ), - ))); - await frames(tester); - await openFolder(tester, 'anime'); - await openSeries(tester); + await pumpPage(tester, nowPlayingPath: '$anime/$sao/[Judas] Sword Art Online S3 - 02.mkv'); + await openShow(tester, 'Sword Art Online'); expect(find.text('now playing'), findsOneWidget); expect(find.byIcon(Icons.graphic_eq), findsOneWidget); }); - testWidgets('narrow: folders become chips above the list', (tester) async { + testWidgets('narrow: places become chips above the list', (tester) async { store.addFolder('$anime/'); await pumpPage(tester, size: const Size(600, 800)); expect(find.text('Folders'), findsNothing); - expect(find.widgetWithText(InputChip, 'anime'), findsOneWidget); + expect(find.widgetWithText(ChoiceChip, 'Library'), findsOneWidget); expect(find.widgetWithText(ChoiceChip, 'Recent'), findsOneWidget); - await tester.tap(find.widgetWithText(InputChip, 'anime')); - await frames(tester); - expect(find.text('$anime/'), findsOneWidget); - await openSeries(tester); - expect(find.text('E01'), findsOneWidget); - // The chip's x removes the folder, like the sidebar's. + expect(find.widgetWithText(InputChip, 'Binux'), findsOneWidget); + await openShow(tester, 'Re.Zero'); + expect(find.text('Season 4'), findsOneWidget); + expect(find.text('16 isn\'t in your folders'), findsNothing); + expect(find.text('1–16 aren\'t in your folders — start at 17?'), findsOneWidget); await tester.tap(find.byTooltip('Remove folder')); await frames(tester); expect(store.folders.value, isEmpty); - expect(find.widgetWithText(InputChip, 'anime'), findsNothing); expect(tester.takeException(), isNull); }); @@ -289,53 +354,52 @@ void main() { testWidgets('no overflow at ${size.width.toInt()}x${size.height.toInt()}', (tester) async { store.addFolder(anime); for (var i = 0; i < 7; i++) { - touch('$anime/A Movie With A Rather Long Title Indeed Part $i (2020).mkv'); + touch('$anime/clips/A Clip With A Rather Long Title Indeed Part $i.mkv'); } - store.addRecent('$anime/[Grp] Show - 01.mkv'); + store.addRecent(bleach(56)); await pumpPage(tester, size: size, relayed: true); expect(tester.takeException(), isNull); - await tester.tap(find.text('anime').first); + await tester.drag(find.byType(CustomScrollView), const Offset(0, -2000)); await frames(tester); - await openSeries(tester); - expect(find.text('E01'), findsOneWidget); expect(tester.takeException(), isNull); - await tester.tap(find.text('Show some')); + await tester.drag(find.byType(CustomScrollView), const Offset(0, 4000)); await frames(tester); + await openShow(tester, 'Bleach'); + await reveal(tester, find.text('Episode 56')); + expect(tester.takeException(), isNull); await tester.drag(find.byType(CustomScrollView), const Offset(0, -2000)); await frames(tester); expect(tester.takeException(), isNull); }); } - testWidgets('thumbnails load for visible tiles only, and cancel when scrolled away', - (tester) async { + testWidgets('thumbnails load for visible rows only, and cancel when scrolled away', (tester) async { final big = '${tmp.path}/big'; for (var i = 1; i <= 120; i++) { touch('$big/Long Show - ${i.toString().padLeft(3, '0')}.mkv'); } store.addFolder(big); final gates = {}; - await pumpPage(tester, size: const Size(1100, 700), - thumbnails: (path, {cancelled}) { + await pumpPage(tester, size: const Size(1100, 700), thumbnails: (path, {cancelled}) { thumbRequests.add(path); gates[path] = cancelled!; return Completer().future; // never finishes }); - await openFolder(tester, 'big'); - await openSeries(tester, 'Long Show'); - expect(find.text('E01'), findsOneWidget); + await openShow(tester, 'Long Show'); + expect(find.text('Episode 1'), findsOneWidget); final first = thumbRequests.toSet(); expect(first, isNotEmpty); expect(first.length, lessThan(30)); - expect(first, contains('$big/Long Show - 001.mkv')); - expect(gates['$big/Long Show - 001.mkv']!(), isFalse); + // (Episode 1's frame is also the header's banner, which stays.) + expect(first, contains('$big/Long Show - 002.mkv')); + expect(gates['$big/Long Show - 002.mkv']!(), isFalse); - // Fly past the middle: tiles that only flash by never ask. + // Fly past the middle: rows that only flash by never ask. await tester.drag(find.byType(CustomScrollView), const Offset(0, -6000)); await tester.pump(); await tester.drag(find.byType(CustomScrollView), const Offset(0, -6000)); await frames(tester); - expect(gates['$big/Long Show - 001.mkv']!(), isTrue); + expect(gates['$big/Long Show - 002.mkv']!(), isTrue); expect(thumbRequests, contains('$big/Long Show - 120.mkv')); expect(thumbRequests.toSet().length, lessThan(60)); }); diff --git a/apps/desktop/test/files_scanner_test.dart b/apps/desktop/test/files_scanner_test.dart index 7cb52cc..c3c02e0 100644 --- a/apps/desktop/test/files_scanner_test.dart +++ b/apps/desktop/test/files_scanner_test.dart @@ -1,10 +1,14 @@ -// restructure: rewrite in step 4 (Library + Media identity): groupBySeries -// becomes L1 local grouping; the scan and classify tests stay. import 'dart:io'; import 'package:flutter_test/flutter_test.dart'; -import 'package:starsync/src/files/name_parser.dart'; import 'package:starsync/src/files/scanner.dart'; +import 'package:sync_relay_core/core.dart' show groupLocal, readLocalFile, EpisodeGap, LocalKind; + +// The Files scan on a real (generated) tree, and the library's L1 +// grouping over what it found (core's own tests cover the grouping +// rules on paths alone). + +final _tmp = Platform.environment['WT_TEST_TMP'] ?? Directory.systemTemp.path; late Directory _root; @@ -19,11 +23,8 @@ void _file(String rel, {int size = 10}) { } } -VideoFile _v(String name) => - VideoFile('/x/$name', 1, parseReleaseName(name), DateTime(2020)); - void main() { - setUp(() => _root = Directory.systemTemp.createTempSync('files_scan_')); + setUp(() => _root = Directory(_tmp).createTempSync('files_scan_')); tearDown(() => _root.deleteSync(recursive: true)); test('classifies, skips hidden and samples, pairs sidecar subtitles', @@ -69,7 +70,7 @@ void main() { isFalse); final ep = scan.videos.firstWhere((v) => v.name.contains('- 02')); - expect(ep.parsed.episode, 2); + expect(readLocalFile(ep).episode, 2); expect(ep.size, 10); expect(ep.modified.year, DateTime.now().year); }); @@ -115,38 +116,45 @@ void main() { expect(kindOf('mkv'), FileKind.other); }); - group('groupBySeries', () { - test('groups across spellings, sorts by season then episode', () { - final g = groupBySeries([ - _v('[A] Re:Zero S2 - 02 [1080p].mkv'), - _v('[A] Re:Zero - 10 [1080p].mkv'), - _v('[B] Re Zero - 02 [720p].mkv'), - _v('[A] Re:Zero S2 - 01 [1080p].mkv'), - _v('[A] Re:Zero - NCOP1 [1080p].mkv'), - _v('Other Show - 03.mkv'), - _v('Other Show - 01.mkv'), - _v('Movie.2020.1080p.mkv'), - _v('vid_2024_final.mp4'), - _v('[A] Lonely - OVA.mkv'), - ]); - expect(g.series.map((s) => s.title), ['Other Show', 'Re:Zero']); - final re = g.series[1]; - expect(re.episodes.map((v) => v.parsed.label), - ['E02', 'E10', 'S02E01', 'S02E02']); - expect(re.seasons, [1, 2]); - expect(re.extras.single.parsed.extra, 'NCOP1'); - expect(g.series[0].episodes.map((v) => v.parsed.episode), [1, 3]); - expect(g.loose.map((v) => v.name), [ - '[A] Lonely - OVA.mkv', - 'Movie.2020.1080p.mkv', - 'vid_2024_final.mp4', - ]); - }); + test('a generated tree of the Binux names, scanned and grouped (L1)', () async { + const b1 = '[Judas] Bleach 001-055 [BD 1080p][HEVC x265 10bit]'; + const b2 = '[Judas] Bleach 056-111 [BD 1080p][HEVC x265 10bit]'; + const b3 = '[Judas] Bleach 112-139 [BD 1080p][HEVC x265 10bit][Dual-Audio][Eng-Subs]'; + const sao = '[Judas] Sword Art Online (Season 3 - Alicization) [BD 1080p][HEVC x265 10bit][Eng-Subs]'; + String n3(int n) => n.toString().padLeft(3, '0'); + // The first Bleach folder holds 1-byte shortcuts only. + for (final j in ['Amine', 'App', 'Audio', 'Data', 'Document', 'Download', 'Image', 'Internal storage', 'Recent', 'Video']) { + _file('$b1/$j#D4aIyEEtZEWR9gBorzBWiMh_.lnk', size: 1); + } + for (final n in [56, 57, 58, 59, 60, 61, 62, 63, 64, 65, 108, 109, 110, 111]) { + _file('$b2/[Judas] Bleach - ${n3(n)}.mkv'); + } + for (var n = 112; n <= 139; n++) { + _file('$b3/[Judas] Bleach - ${n3(n)}.mkv'); + } + for (var n = 1; n <= 24; n++) { + _file('$sao/[Judas] Sword Art Online S3 - ${n.toString().padLeft(2, '0')}.mkv'); + } + for (final x in ['Opening 1', 'Opening 2', 'Opening 3', 'Ending 1', 'Ending 2']) { + _file('$sao/[Judas] Sword Art Online S3 - $x.mkv'); + } + _file('[Judas] Re.Zero - S04E17.mkv'); + _file('[Erai-raws] Kimetsu no Yaiba - Mugenjou Hen - Movie 01 [1080p CR WEBRip HEVC AAC][MultiSub][8E47462C].mkv'); - test('empty input', () { - final g = groupBySeries(const []); - expect(g.series, isEmpty); - expect(g.loose, isEmpty); - }); + final scan = await scanFolder(_root.path); + expect(scan.videos, hasLength(14 + 28 + 24 + 5 + 2)); + expect(scan.hiddenCount, 10, reason: 'the shortcuts are not videos'); + final lib = groupLocal(scan.videos, roots: [_root.path]); + expect(lib.shows.map((s) => s.title), ['Bleach', 'Re.Zero', 'Sword Art Online']); + final bleach = lib.show('bleach')!; + expect(bleach.folders, [_p(b2), _p(b3)]); + expect(bleach.seasons.single.gaps, const [EpisodeGap(1, 55), EpisodeGap(66, 107)]); + final s = lib.show('sword art online')!; + expect([s.seasons.single.number, s.seasons.single.name], [3, 'Alicization']); + expect(s.extras.map((e) => e.label), ['OP1', 'OP2', 'OP3', 'ED1', 'ED2']); + expect(lib.show('re zero')!.seasons.single.number, 4); + expect(lib.movies.single.kind, LocalKind.movie); + expect(lib.movies.single.title, 'Kimetsu no Yaiba - Mugenjou Hen'); + expect(lib.other, isEmpty); }); } diff --git a/apps/desktop/test/jellyfin_session_test.dart b/apps/desktop/test/jellyfin_session_test.dart index 50af421..ab727c8 100644 --- a/apps/desktop/test/jellyfin_session_test.dart +++ b/apps/desktop/test/jellyfin_session_test.dart @@ -193,9 +193,10 @@ void main() { expect(plain.name, 'movie.mkv'); }); - test('relay credential: the account token with exactly its own device fields', () async { + test('relay credentials: each account token with exactly its own device fields', () async { final (accounts, _) = await linkedAccounts(); - final cred = relayCredentialFor(accounts, serverId: fixtureServerId)!; + final creds = relayCredentialsFor(accounts); + final cred = creds.single; final info = accounts.clientInfo; expect(cred.token, 'TEST_ACCESS_TOKEN'); expect(cred.serverId, fixtureServerId); @@ -203,13 +204,11 @@ void main() { expect([cred.client, cred.device, cred.deviceId, cred.version], [info.client, info.device, info.deviceId, info.version]); expect(cred.device, 'Test PC'); - // Another server's item: the default account still relays its own. - expect(relayCredentialFor(accounts, serverId: 'other')!.serverId, fixtureServerId); accounts.markExpired(fixtureServerId); - expect(relayCredentialFor(accounts, serverId: fixtureServerId), isNull); + expect(relayCredentialsFor(accounts), isEmpty); final none = JellyfinAccounts(store: MemoryJellyfinStore()); await none.ensureLoaded(validate: false); - expect(relayCredentialFor(none), isNull); + expect(relayCredentialsFor(none), isEmpty); }); }); diff --git a/apps/desktop/test/media_refs_test.dart b/apps/desktop/test/media_refs_test.dart new file mode 100644 index 0000000..b95eea9 --- /dev/null +++ b/apps/desktop/test/media_refs_test.dart @@ -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']); + }); + }); +} diff --git a/apps/desktop/test/show_matcher_test.dart b/apps/desktop/test/show_matcher_test.dart new file mode 100644 index 0000000..5324e4e --- /dev/null +++ b/apps/desktop/test/show_matcher_test.dart @@ -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 = [ + 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 = []; + final lookups = []; + bool offline = false; + + static Set _words(String s) => + normalizeSeries(s).split(' ').where((w) => w.length > 1 && int.tryParse(w) == null && w != 'season').toSet(); + + @override + Future> 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 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 shows; + FakeFlow(this.shows); + @override + Future forAniList(int anilistId) async => shows[anilistId]; +} + +String three(int n) => n.toString().padLeft(3, '0'); + +LocalLibrary binux({List 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 waits; + + ShowMatcher matcher({FlowLookup? flow, AniListLookup? api}) => ShowMatcher( + store: store, + aniList: api ?? aniList, + flow: flow, + delay: (d) async => waits.add(d), + ); + + Future settle(ShowMatcher m) async { + for (var i = 0; i < 200 && !m.idle; i++) { + await Future.delayed(Duration.zero); + } + await Future.delayed(Duration.zero); + expect(m.idle, isTrue); + } + + Future 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); + 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)); + }); +} diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index ba6c2b0..eb33f5e 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -7,7 +7,7 @@ Plain overview first: [HOW-IT-WORKS.md](HOW-IT-WORKS.md). Wire format: | Path | What | Rule | |---|---|---| -| `packages/core` | protocol messages, sync engine (`SyncEngine`), constants, config, Range parsing, logging, Jellyfin client, updater, watched rule (`WatchSession`); `testing.dart` (test support for all three packages, not in `core.dart`: `FlakyProxy`) | pure Dart, no Flutter | +| `packages/core` | protocol messages, sync engine (`SyncEngine`), constants, config, Range parsing, logging, Jellyfin client, updater, watched rule (`WatchSession`), the library's L1 (`library/`: release and folder names, `groupLocal`); `testing.dart` (test support for all three packages, not in `core.dart`: `FlakyProxy`) | pure Dart, no Flutter | | `packages/server` | Relic HTTP + WebSocket server (`SyncServer`, `ServerHub`, `Session`, media endpoint, relay buffer, Jellyfin relay), the `serve()` entrypoint, `bin/server.dart` (dedicated server) | pure Dart, no Flutter | | `apps/desktop` | Flutter app: UI, `AppGraph` (composition root), `SyncClient`, `Connection`, `SessionCoordinator`, `Navigation`, `YourServer` (`ServerIsolate`), `PlaybackController` (media_kit/mpv), update UI (`UpdateController`) | ships with `starsync-server` | | `tool` | harnesses: `range_logger`, `shaper`, `fake_viewer`, `e2e_check` | dev only | @@ -48,9 +48,10 @@ attach and detach the network listener (below). - **Listeners**: `SyncServer.share(port, secret, jellyfin)` attaches a second listener on every IPv4 interface at the port from Settings → Party & network (default 8080) to the running server (Relic serves the - same routes on both), sets the password and the Jellyfin relay login - (the default account's, so guests without an account watch Jellyfin - items through the server); `unshare` closes it. Nothing restarts for + same routes on both), sets the password and the Jellyfin relay logins + (one per linked account, `relayCredentialsFor`, so guests without an + account watch Jellyfin items through the server with your sign-in on + the item's own Jellyfin); `unshare` closes it. Nothing restarts for either: members on the loopback listener keep their sockets, the media and Up next stay. Unsharing sends everyone but the host out: each leaves at once (feed `leave`, reason `closed`) and its socket closes @@ -133,7 +134,9 @@ only stopping sharing sends guests out. (`AppGraph.build`) and constructs and disposes the app's systems: the `Ports`, the sounds (`SynthSfx`), the `Notifier`, the `Connection`, the `SessionCoordinator`, the connect form and recents (`ConnectStore`, -`state/connect_prefs.dart`), `YourServer` and `Navigation`; it wires them +`state/connect_prefs.dart`), `YourServer`, `Navigation` and the library's +matches (`ShowMatcher`, "Library", whose `refForPath` it hands the +coordinator as `fileRef`); it wires them through their constructors (and gives `Profile` the form and the push) and has no logic or signals of its own. Widgets reach it through one `AppScope` InheritedWidget (`AppScope.of(context)`, readable in @@ -653,20 +656,22 @@ Gotchas (Jellyfin 10.11): ## Jellyfin relay For viewers without an account on the item's Jellyfin -(`packages/server/lib/src/jellyfin_relay.dart`). The server may hold one -`JellyfinCredential` (config key `jellyfin`: `baseUrl`, `serverId`, `token`, -`client`, `device`, `deviceId`, `version`; primitives, so it crosses the -isolate boundary in the `serve()` map). The app sends it when your server -is shared (`share`): its own app-obtained token and that token's device -fields; the dedicated server -reads it from `--jellyfin ` (YAML/JSON with those keys, keeps the +(`packages/server/lib/src/jellyfin_relay.dart`). The server holds its +logins as `JellyfinCredential`s (`ServerHub.jellyfinLogins`, at most one +per Jellyfin `serverId`; each: `baseUrl`, `serverId`, `token`, `client`, +`device`, `deviceId`, `version`; primitives, so they cross the isolate +boundary). The app sends one per linked account (not known expired, the +default first) when your server is shared (`share`): its own +app-obtained tokens and their device fields; the dedicated server has one, +read from `--jellyfin ` (YAML/JSON with those keys, keeps the token off the command line) or a `jellyfin:` map in `--config`. An empty -`serverId` is looked up at start (`/System/Info/Public`); if that fails the -relay stays off. The token is never logged (`toString` and config parse -errors leave it out). +`serverId` is looked up at start (`/System/Info/Public`); if that fails +that login stays unused. Tokens are never logged (`toString` and config +parse errors leave them out). -- Available (`state.relay`) only while the current media is a ref whose - `serverId` equals the credential's; `state.relayNext` likewise for Up +- Available (`state.relay`) only while the current media is a ref on a + server one of the logins is for (`loginFor(serverId)`, which `/j` then + uses); `state.relayNext` likewise for Up next's first item, which `/j` also serves so guests can preload it (see "Getting ready" under "Up next"). Only those two items are relayed, only to a valid viewer token: no open proxy. @@ -1282,6 +1287,73 @@ every `state` and feed), the pure rules in `queue_logic.dart`, the UI in in order, a link in the Link page's mode). All go through `QueueController.add`. None of it offline. +## Library + +Local videos as shows, device-owned (the three-way truth: library, match +corrections and watch history live on the device). Two layers: + +**L1, local identity** (`packages/core/lib/src/library/`, pure and +offline): `parseReleaseName` reads a file name (title, group, season, +episode, year, extras: OP/ED, Opening/Ending, NCOP/NCED, OVA, specials, +PV, Menu, numbered; movies: `Movie N`, `The Movie`, `Gekijouban`, a +title with a year), `parseFolderName` a folder name (title, season and +its name from a parenthetical read before brackets go, `(Season 3 - +Alicization)`, an episode range `001-055`, a season-only or extras +folder). `groupLocal(files, roots:)` keeps the videos (anything else, +`.lnk` shortcuts included, is counted as ignored) and reads each +(`readLocalFile`): the file's own name wins, the folders between it and +its watched folder are hints, nearest first (a bare `01.mkv` takes the +folder's title, a file under `Season 2` its season). Files group into a +`LocalShow` by `normalizeSeries` of their title, across folders (Bleach +in three folders is one show), with `LocalSeason`s as numbered (a null +number is an absolute run, kept as found), extras and the show's movies; +a movie of no show here stands alone, anything else is "other". Each +season knows its holes (`EpisodeGap`: before the first episode, between +episodes, and up to a folder range's end), so `LocalShow.nextAfter(key)` +gives the next episode and the numbers skipped on the way. Keys are +stable across rescans (`show|s|e`, 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:`) or of a folder and what is below +it (`pin:folder:`, more specific), and an episode offset per show +and season ("these files are episodes N.."). Requests are lazy (cards +and pages on screen ask, `want`), one at a time, 800 ms apart, paused a +minute after a 429; failures are retried after 5 minutes; everything +answers from the cache offline. Episode and show names, cover and banner +come from the matched entry. + +**Media identity** (`media.ref`, [PROTOCOL.md](PROTOCOL.md) "Media +identity"): what a media is, independent of where its bytes come from. +Producers: a local file, `ShowMatcher.refFor` (L1 kind and numbers, the +match's title, AniList entry and episode, the TVDB id from AnimeFlow; +L1 only before a match); a Jellyfin item, `jellyfinMediaRef` (kind, +series title, season and episode, the AniList / Tvdb provider ids of the +show or movie, `ProviderIds` in the item fields; from a pick's lines, +`jellyfinPickRef`, when the item was not at hand); a link, +`linkMediaRef` (its name read as a release name, else a movie of that +title). Swaps (`swapFile`, `swapJellyfin`, `swapLink`) and Up next adds +carry it; Up next names a file item by it. Consumers: receivers' now +playing title and episode line (`refTitle`), the swapper's art (by the +AniList id); later the watch tracking keys on it. + ## App events and platform ports **Status.** What the session is like right now is one computed value, @@ -1558,10 +1630,24 @@ panel) and chat messages (Copy message, and a link in it). that fails although the kept preview looked fine, the preview is probed again and shows why, with "Put it up anyway" when it is a problem. Files - is `FilesPage` (`files/`): watched folders (`FolderStore`) scanned in - an isolate, videos grouped by series; series start collapsed, a header - click opens one and open ones stay open for the app session - (`showFilesFolder` opens a folder from outside). With nobody else + is `FilesPage` (`files/`), a library over the watched folders + (`FolderStore`, each scanned in an isolate when first needed): places + on the left (chips when narrow): Library (every folder together, the + default), Recent, then each folder (`showFilesFolder` opens one from + outside). A place is poster cards (2:3, like the Jellyfin browser's; + the AniList cover, else a frame of the first episode) for its Shows and + Movies, then the Other videos as 16:9 cards; a show card says how many + episodes, or the next one after the last played here. A click on a show + opens its page (`files/ui/library_view.dart`, kept open per place for + the app session, Back returns): a banner header (AniList banner, else a + frame) with the proper name, what the files are (seasons, episodes, + extras, group, folders), the match ("AniList: …", "files say …", + pinned), the gap prompt ("66–107 aren't in your folders — skip to + 108?") above the next-episode button, Wrong show? and Episode + numbers…; then season chips (more than one season), the season's + episodes as rows ("12. Name" with AniList's episode title, else + "Episode 12"; one click plays, right-click queues) with the holes as + muted rows, Extras and the show's Movies. With nobody else in the server (`alone` on the Library, Files and Schedule, D9) the buttons say Play, Link has no relay modes and a link goes up direct, Files has no relay notice; a put-up before your server is up waits for @@ -2220,10 +2306,13 @@ testers land back on stable. `0.0.0-dev` (a dev run) checks nothing. shows its swap landed (`media_art.dart`: Jellyfin series/item images with its own account; a web link's yt-dlp thumbnail, the one the Library's Recent grid shows, as poster and banner both, shrunk to a JPEG under the - poster cap when larger; else an AniList title search on the parsed + poster cap when larger; else the AniList entry `media.ref` names (a + matched local file), else an AniList title search on the parsed release name) and sends `art`, so guests without Jellyfin get covers too. `coordinator.nowPlaying` derives title, episode line and art hashes from - `state.media` (`now_playing_info.dart`). A web video + `state.media` (`now_playing_info.dart`); a non-Jellyfin media with a + `media.ref` is named by it (its title, `S3 E13` / `E108`, an extra's + own marker), see "Library". A web video (`NowPlaying.thumbnail`) shows the media name as is, no episode line, a 16:9 cover in the controls (a neutral placeholder until the art is up) and none on the start card. Art up: poster and banner of one hash mark diff --git a/docs/DEVELOPMENT.md b/docs/DEVELOPMENT.md index d72d242..af8b890 100644 --- a/docs/DEVELOPMENT.md +++ b/docs/DEVELOPMENT.md @@ -79,7 +79,9 @@ Three suites, run from each package's folder: - `cd packages/core && dart test` (a few seconds): protocol round-trips, the sync engine (clock, gate, presence and grace, correction, end of - file), the `PresencePolicy` table, queue model, + file), the `PresencePolicy` table, queue model, the library's L1 + (release and folder names, `groupLocal` over a real anime folder's + names: shows across folders, holes, extras, a movie), relay pieces, updater, logging redaction, watch rule, yt-dlp and Jellyfin client parsing against captured fixtures. - `cd packages/server && dart test` (well under a minute): live servers @@ -90,7 +92,8 @@ Three suites, run from each package's folder: under the same members and Up next, the host key standing in for the password). - `cd apps/desktop && flutter test test/`: widgets (shell, library, - controls, settings, schedule), pure app logic (`StateDiff`, name parser, + controls, settings, schedule), pure app logic (`StateDiff`, the library's L2 matching against a + fake AniList (`show_matcher_test`), `media.ref` producers, shader list, sound engine with a fake backend, invite addresses, the version gate), `singletons_test` (the `.instance` count, see ARCHITECTURE "Composition root"; lower its baseline when a use goes) and the real-mpv `*_e2e` diff --git a/docs/PROTOCOL.md b/docs/PROTOCOL.md index 1e2e224..5743ed7 100644 --- a/docs/PROTOCOL.md +++ b/docs/PROTOCOL.md @@ -354,8 +354,10 @@ media.ref = {"kind":"episode","title":"Show","anilistId":1,"anilistEp":6, is (the server drops non-integer ids and cuts `title` to 200 characters). `kind` `episode`, `movie` or `extra` (else no ref); every other field optional; `season` absent for absolute numbering. At most - 2 KiB of JSON, else `err` `bad`. **Reserved**: nothing produces it yet - (step 4). + 2 KiB of JSON, else `err` `bad`. Apps send it with swaps and Up next + adds of files (the sender's library match), Jellyfin items (their + provider ids) and links (kind and title only); receivers name the + media by it (ARCHITECTURE.md "Library"). ## Avatars diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index 7538592..bd1e782 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -61,9 +61,6 @@ Problems are numbered; delete a line when its fix ships. the `close:` log lines. - P33 No volume readout on wheel: likely fixed by the controls rework (1.2.1-dev.1; a test covers both skins); confirm in real use. -- P34 Local anime folders are opened as plain files (no banner, names, - next episode, tracking); test library: the user's - `/mnt/torrents/Torrents/Binux/` (several shows and a movie). **Restructure** (in order; reviewed against the code). Truth lives in three places: the server (members, presence, gate, transport, queue incl. @@ -71,9 +68,6 @@ the automatic continuation and countdown, relay state), the device (client id, library and folders, match corrections, watch history, settings, notification prefs, what this app is sourcing) and the integrations (Jellyfin played/resume, AniList list). -4. Library + Media identity: L1 local grouping (kinds, folder hints, - gaps, merge across folders) and L2 match (AniList/TVDB, art, names, - manual pin, cache); `MediaRef` in swap/queue media. Fixes P34. 5. Media pipeline + player host port: plan → resolve → open → ready, commands queued during a switch, viewer-side recovery when a source drops (no mpv seek loops, play what's buffered), resolver and source @@ -127,12 +121,26 @@ that the invite skips the proxy's 198.18.x here) and of the update band from 1.2.1-dev.3 in real use. No public address lookup or UPnP: the members card only says friends elsewhere need the port forwarded. Your server's `/health` name is the profile name at start (a rename shows -after a restart). Guests' Jellyfin relay uses the default account (no -longer the put-up item's server). Joining a friend while others are on +after a restart). Joining a friend while others are on your server leaves them there without you, unwarned; a join refused after dialling (wrong password) costs your own member there (`bye`, back as a new member). Your server writes its session log even when you are alone. +Left of step 4 (library): a human check of the Files library on the +real folder (`/mnt/torrents/Torrents/Binux/`: Bleach over three folders +with 66–107 missing, SAO S3 Alicization with OP/ED, Re.Zero S04E17, the +Kimetsu movie) and of the AniList matches it gets. A Jellyfin swap +carries AniList/TVDB ids only when the picked item has them (movies); an +episode's show ids need its series item, which picks do not fetch. +Receivers name the media by `media.ref` but still get art only as the +swapper's bytes (now chosen by the ref's AniList id). Relay logins are +sent at Invite: an account linked or signed in again later relays only +after Stop sharing and Invite. "Season N" without a name walks AniList's +sequels, which can land on the wrong cour where AniList splits seasons; +Wrong show? fixes it. The next episode of a local show lives on its page +(from Recent); no countdown or "Continues with" for files yet (backlog +2). The `library` box keeps every AniList entry seen (no pruning); +AniList's episode titles exist only where it lists streaming episodes. Left of step 2: system notifications need a human check (Linux: the notification shows and a click brings the window up, also on Wayland, where focus may be refused without an activation token; Windows: toasts @@ -164,7 +172,6 @@ core and app. Still open on the wire, frozen at the 1.3.0 release: holds it while the item's performer or source is `gone`. Today it is parsed and broadcast but never started; `auto` items and `cancel` are live. -- `media.ref` producers (step 4); the server carries it already. - `hello.client` optionally derived per server (HMAC of an install secret and the dialled host:port). - TLS: `/health.tls` is always false; `wss://` only with a CA-valid @@ -178,9 +185,10 @@ core and app. Still open on the wire, frozen at the 1.3.0 release: seeks are shared, and only real ranges (segments, OP/ED chapters) should ever be skipped. -2. **Files**: next episode from Files (by parsed episode, with the - countdown, and under Up next's "Continues with"). Later: tracking for - file series. +2. **Files**: the next episode of a local show (the library's + `nextAfter`, gaps included) at the end of one, with the countdown, and + under Up next's "Continues with". Tracking for file series comes with + step 6. 3. **yt-dlp follow-ups** (see ARCHITECTURE "yt-dlp"): check that the bundled Windows libmpv has the ytdl hook (Lua) and plays `edl://` URLs diff --git a/packages/core/lib/core.dart b/packages/core/lib/core.dart index a4f43eb..8d5cc43 100644 --- a/packages/core/lib/core.dart +++ b/packages/core/lib/core.dart @@ -22,3 +22,5 @@ export 'src/jellyfin/http.dart'; export 'src/jellyfin/links.dart'; export 'src/jellyfin/models.dart'; export 'src/jellyfin/track_match.dart'; +export 'src/library/local_library.dart'; +export 'src/library/release_name.dart'; diff --git a/packages/core/lib/src/jellyfin/client.dart b/packages/core/lib/src/jellyfin/client.dart index 78c25a9..3951787 100644 --- a/packages/core/lib/src/jellyfin/client.dart +++ b/packages/core/lib/src/jellyfin/client.dart @@ -183,7 +183,7 @@ class JellyfinClient { /// Fields requested for list items: enough for cards and detail headers. static const listFields = - 'PrimaryImageAspectRatio,Overview,ChildCount,RecursiveItemCount,DateCreated'; + 'PrimaryImageAspectRatio,Overview,ChildCount,RecursiveItemCount,DateCreated,ProviderIds'; static const _imageParams = { 'enableImageTypes': 'Primary,Backdrop,Thumb,Logo', 'imageTypeLimit': '1', diff --git a/packages/core/lib/src/jellyfin/models.dart b/packages/core/lib/src/jellyfin/models.dart index ce1c58f..c2f3fc5 100644 --- a/packages/core/lib/src/jellyfin/models.dart +++ b/packages/core/lib/src/jellyfin/models.dart @@ -232,6 +232,10 @@ class JellyfinItem { final String? parentLogoItemId; final String? parentLogoImageTag; + /// Ids on other databases (`AniList`, `Tvdb`, `Imdb`...), as Jellyfin's + /// metadata plugins set them; empty when none or not requested. + final Map providerIds; + /// Present on full item responses (`getItem`) for playable items. final List mediaSources; /// Streams of the default source; present on `getItem` and when @@ -273,6 +277,7 @@ class JellyfinItem { this.parentThumbImageTag, this.parentLogoItemId, this.parentLogoImageTag, + this.providerIds = const {}, this.mediaSources = const [], this.mediaStreams = const [], }); @@ -314,12 +319,26 @@ class JellyfinItem { parentThumbImageTag: _str(j['ParentThumbImageTag']), parentLogoItemId: _str(j['ParentLogoItemId']), parentLogoImageTag: _str(j['ParentLogoImageTag']), + providerIds: { + for (final e in (j['ProviderIds'] as Map? ?? const {}).entries) + if (e.value is String && (e.value as String).isNotEmpty) e.key.toString(): e.value as String, + }, mediaSources: _list(j['MediaSources']).map(JellyfinMediaSource.fromJson).toList(), mediaStreams: _list(j['MediaStreams']).map(JellyfinMediaStream.fromJson).toList(), ); JellyfinItemKind get kind => JellyfinItemKind.parse(type); + /// A provider id as a number (`providerId('AniList')`), case-insensitive + /// on the provider's name; null when absent or not a number. + int? providerId(String provider) { + final p = provider.toLowerCase(); + for (final e in providerIds.entries) { + if (e.key.toLowerCase() == p) return int.tryParse(e.value.trim()); + } + return null; + } + Duration? get runTime => runTimeTicks == null ? null : ticksToDuration(runTimeTicks!); /// "S2E12" for episodes with both numbers, else null. diff --git a/packages/core/lib/src/library/local_library.dart b/packages/core/lib/src/library/local_library.dart new file mode 100644 index 0000000..f62e007 --- /dev/null +++ b/packages/core/lib/src/library/local_library.dart @@ -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 episodes; + + /// Numbers before, between and (by a folder's range) after the + /// episodes that are not there. + final List 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 get distinct { + final seen = {}; + 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 seasons; + + /// OP/ED, OVA, specials: by season, then kind, then number. + final List extras; + + /// Its movies (`Title - Movie 2`). + final List movies; + + /// The folders its files are in, sorted. + final List 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 get episodes => [for (final s in seasons) ...s.episodes]; + + /// Every file of the show: episodes, extras, movies. + Iterable 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 shows; + + /// Movies of no show found here. + final List movies; + + /// Videos that are not episodes, extras of a show or movies. + final List 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 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 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 files, {Iterable roots = const []}) { + var ignored = 0; + final read = []; + for (final f in files) { + if (kindOf(f.path) != FileKind.video) { + ignored++; + continue; + } + read.add(readLocalFile(f, roots: roots)); + } + final byKey = >{}; + final extras = >{}; + final movies = >{}; + final other = []; + 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 = {}; + 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 = []; + for (final entry in byKey.entries) { + final key = entry.key; + final eps = entry.value; + final bySeason = >{}; + 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 eps, int? rangeEnd) { + eps.sort(_byEpisode); + final present = {for (final e in eps) e.episode!}.toList()..sort(); + final gaps = []; + 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 values) { + final counts = {}; + 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 _foldersBelow(String path, Iterable roots) { + final parts = _split(path); + if (parts.length < 2) return const []; + final dirs = parts.sublist(0, parts.length - 1); + List? 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 _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(); +} diff --git a/packages/core/lib/src/library/release_name.dart b/packages/core/lib/src/library/release_name.dart new file mode 100644 index 0000000..2b3352b --- /dev/null +++ b/packages/core/lib/src/library/release_name.dart @@ -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, + ); +} diff --git a/packages/core/lib/src/protocol.dart b/packages/core/lib/src/protocol.dart index bcc356e..f850283 100644 --- a/packages/core/lib/src/protocol.dart +++ b/packages/core/lib/src/protocol.dart @@ -103,8 +103,9 @@ class JellyfinRef { /// What a media is, independent of where its bytes come from (`media.ref`): /// an episode, a movie or an extra, by AniList and/or TVDB ids. Carried /// as is in `swap.media`, `state.media` and `queue[].media` (the server -/// keeps it, at most [RelayConstants.maxMediaRefBytes] of JSON); nothing -/// produces it yet (step 4). +/// keeps it, at most [RelayConstants.maxMediaRefBytes] of JSON). The app +/// makes it from its library (files), Jellyfin's provider ids and a +/// link's name. class MediaRef { static const kinds = {'episode', 'movie', 'extra'}; diff --git a/packages/core/test/local_library_test.dart b/packages/core/test/local_library_test.dart new file mode 100644 index 0000000..1e13933 --- /dev/null +++ b/packages/core/test/local_library_test.dart @@ -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 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 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); + }); +} diff --git a/apps/desktop/test/release_name_test.dart b/packages/core/test/release_name_test.dart similarity index 64% rename from apps/desktop/test/release_name_test.dart rename to packages/core/test/release_name_test.dart index 95b732f..f2b31ae 100644 --- a/apps/desktop/test/release_name_test.dart +++ b/packages/core/test/release_name_test.dart @@ -1,5 +1,5 @@ -import 'package:flutter_test/flutter_test.dart'; -import 'package:starsync/src/files/name_parser.dart'; +import 'package:test/test.dart'; +import 'package:sync_relay_core/core.dart'; void _expect( String name, @@ -96,6 +96,16 @@ void main() { _expect('Show - OP1 [1080p].mkv', 'Show', extra: 'OP1'); _expect('Show - ED.mkv', 'Show', extra: 'ED'); }); + test('Opening / Ending, creditless, PV, Menu, numbered', () { + _expect('[Judas] Sword Art Online S3 - Opening 1.mkv', 'Sword Art Online', + season: 3, group: 'Judas', extra: 'OP1', label: 'OP1'); + _expect('[Judas] Sword Art Online S3 - Ending 2.mkv', 'Sword Art Online', + season: 3, group: 'Judas', extra: 'ED2'); + _expect('Show - Creditless Opening 2.mkv', 'Show', extra: 'NCOP2'); + _expect('Show - PV 3.mkv', 'Show', extra: 'PV3'); + _expect('Show - Menu 1.mkv', 'Show', extra: 'Menu 1'); + expect(parseReleaseName('Show - Opening 1.mkv').kind, ReleaseKind.extra); + }); test('"Special" inside a title is a title', () { _expect('Special Operations Unit - 04.mkv', 'Special Operations Unit', episode: 4); @@ -142,6 +152,19 @@ void main() { test('WEB-DL at the end is not a group', () { _expect('Some.Movie.2021.1080p.WEB-DL.mkv', 'Some Movie', year: 2021); }); + test('Movie N, The Movie, Gekijouban', () { + final k = parseReleaseName( + '[Erai-raws] Kimetsu no Yaiba - Mugenjou Hen - Movie 01 [1080p CR WEBRip HEVC AAC][MultiSub][8E47462C].mkv'); + expect([k.series, k.movie, k.movieNumber, k.episode, k.group, k.kind, k.label], + ['Kimetsu no Yaiba - Mugenjou Hen', true, 1, null, 'Erai-raws', ReleaseKind.movie, 'Movie 1']); + final t = parseReleaseName('Show - The Movie [BD].mkv'); + expect([t.series, t.movie, t.movieNumber], ['Show', true, null]); + final g = parseReleaseName('Gekijouban Bleach Fade to Black.mkv'); + expect([g.series, g.movie, g.kind], ['Bleach Fade to Black', true, ReleaseKind.movie]); + // A title with the word in it is still a title. + final s = parseReleaseName('Some Movie (2019).mkv'); + expect([s.series, s.movie, s.year, s.kind], ['Some Movie', false, 2019, ReleaseKind.movie]); + }); test('hyphen in the title', () { _expect('Spider-Man.2002.1080p.BluRay.mkv', 'Spider-Man', year: 2002); }); @@ -160,6 +183,43 @@ void main() { }); }); + test('a name that is only the number has no title (the folder gives one)', () { + final p = parseReleaseName('01.mkv'); + expect([p.series, p.episode, p.kind], ['', 1, ReleaseKind.episode]); + expect(parseReleaseName('Episode 07.mkv').series, ''); + expect(parseReleaseName('2019.mkv').episode, isNull, reason: 'a year, not an episode'); + expect(parseReleaseName('vid.mp4').kind, ReleaseKind.other); + }); + + test('parenthetical season and its name', () { + final p = parseReleaseName('Sword Art Online (Season 3 - Alicization) - 05.mkv'); + expect([p.series, p.season, p.subtitle, p.episode], ['Sword Art Online', 3, 'Alicization', 5]); + }); + + group('folder names', () { + test('ranges, groups and tags', () { + final b = parseFolderName('[Judas] Bleach 056-111 [BD 1080p][HEVC x265 10bit]'); + expect([b.title, b.firstEpisode, b.lastEpisode, b.group, b.season], ['Bleach', 56, 111, 'Judas', null]); + final c = parseFolderName('[Judas] Bleach 112-139 [BD 1080p][HEVC x265 10bit][Dual-Audio][Eng-Subs]'); + expect([c.title, c.firstEpisode, c.lastEpisode], ['Bleach', 112, 139]); + }); + test('a parenthetical season with its name, read before brackets go', () { + final s = parseFolderName('[Judas] Sword Art Online (Season 3 - Alicization) [BD 1080p][HEVC x265 10bit][Eng-Subs]'); + expect([s.title, s.season, s.subtitle, s.group], ['Sword Art Online', 3, 'Alicization', 'Judas']); + }); + test('season-only and extras folders, years', () { + expect(parseFolderName('Season 2').season, 2); + expect(parseFolderName('Season 2').title, ''); + expect(parseFolderName('S03').season, 3); + expect(parseFolderName('Extras').extras, isTrue); + expect(parseFolderName('NCOP').extras, isTrue); + final y = parseFolderName('Show (2019)'); + expect([y.title, y.year], ['Show', 2019]); + expect(parseFolderName('Show 2nd Season').season, 2); + expect(parseFolderName('Show 2nd Season').title, 'Show'); + }); + }); + test('seriesKey ignores case and punctuation', () { expect(parseReleaseName('Re:Zero - 01.mkv').seriesKey, parseReleaseName('re zero - 02.mkv').seriesKey); diff --git a/packages/server/lib/src/jellyfin_relay.dart b/packages/server/lib/src/jellyfin_relay.dart index bac01c6..3dc7a1e 100644 --- a/packages/server/lib/src/jellyfin_relay.dart +++ b/packages/server/lib/src/jellyfin_relay.dart @@ -24,9 +24,10 @@ bool _sameItemId(String a, String b) => (normalizeItemId(a) ?? a) == (normalizeI Response _plain(int status) => Response(status, body: Body.fromString('status $status')); /// `GET /j///`: relays byte ranges of the current -/// Jellyfin media from [credential]'s server to a viewer, for guests without +/// Jellyfin media from its Jellyfin server to a viewer, with the server's +/// login there ([credentialFor] the ref's `serverId`), for guests without /// their own access. Only the active ref's item, or Up next's first one -/// ([next], so viewers can get it ready), on the credential's server, only +/// ([next], so viewers can get it ready), on a server with a login, only /// for a valid viewer token: no open proxy. Range goes up as sent; /// status, Content-Range, Content-Length, Content-Type and Accept-Ranges /// come back as Jellyfin sent them (a 401/403 from Jellyfin is the server's @@ -38,7 +39,7 @@ Response _plain(int status) => Response(status, body: Body.fromString('status $s void mountJellyfinRelayRoute( RelicApp app, SessionMap sessions, - JellyfinCredential? Function() credential, + JellyfinCredential? Function(String serverId) credentialFor, HttpClient Function() client, { JellyfinRef? Function()? next, String pattern = '/j/:sid/:token/:itemId', @@ -58,14 +59,13 @@ void mountJellyfinRelayRoute( default: return _plain(401); } - final cred = credential(); - if (cred == null || cred.serverId.isEmpty) return _plain(404); - JellyfinRef? onLogin(JellyfinRef? r) => r != null && cred.serverId == r.serverId ? r : null; + JellyfinRef? onLogin(JellyfinRef? r) => r != null && credentialFor(r.serverId) != null ? r : null; final current = onLogin(session.media?.jellyfin); final upNext = onLogin(next?.call()); if (current == null && upNext == null) return _plain(404); final ref = [current, upNext].firstWhere((r) => r != null && _sameItemId(itemId, r.itemId), orElse: () => null); if (ref == null) return _plain(403); + final cred = credentialFor(ref.serverId)!; final range = req.headers['range']?.join(','); final HttpClientRequest upReq; diff --git a/packages/server/lib/src/serve.dart b/packages/server/lib/src/serve.dart index 24beabd..1e2eb59 100644 --- a/packages/server/lib/src/serve.dart +++ b/packages/server/lib/src/serve.dart @@ -61,7 +61,12 @@ Future serve(Map config, {SendPort? events}) async { final port = await server.share( port: (msg['port'] as num?)?.toInt() ?? 0, secret: (msg['secret'] as String?) ?? '', - jellyfin: JellyfinCredential.fromMap(msg['jellyfin'])); + jellyfin: msg['jellyfin'] is List + ? [ + for (final c in msg['jellyfin'] as List) + if (JellyfinCredential.fromMap(c) case final cred?) cred, + ] + : null); reply.send({'ok': true, 'port': port}); } catch (e) { reply.send({'ok': false, 'message': '$e'}); diff --git a/packages/server/lib/src/server_hub.dart b/packages/server/lib/src/server_hub.dart index 6bcf956..6b07e80 100644 --- a/packages/server/lib/src/server_hub.dart +++ b/packages/server/lib/src/server_hub.dart @@ -23,16 +23,31 @@ class ServerHub { /// The `/sync` socket of each member that has one, by token; a `gone` /// member has none. final Map sockets = {}; - /// The server's Jellyfin login for the `/j/...` relay; null = no relay. - JellyfinCredential? jellyfin; + /// The server's Jellyfin logins for the `/j/...` relay, at most one per + /// Jellyfin server (`serverId`); empty = no relay. The dedicated + /// server has its configured one; the app's own server one for each + /// linked account, so an item is relayed with its own server's login. + List jellyfinLogins; + + /// The first login (the dedicated server's only one); null = none. + JellyfinCredential? get jellyfin => jellyfinLogins.isEmpty ? null : jellyfinLogins.first; + + /// The login on Jellyfin server [serverId]; null when there is none. + JellyfinCredential? loginFor(String serverId) { + for (final c in jellyfinLogins) { + if (c.serverId.isNotEmpty && c.serverId == serverId) return c; + } + return null; + } /// Reads web pages for "the server fetches it"; null = files only. final YtDlp? ytDlp; /// Random per server boot (`welcome.instance`). final String instance = _randomId(); ServerHub(this.sessions, this.expectedSecret, - {SyncEngine? engine, this.hostKey = '', this.jellyfin, this.ytDlp}) - : engine = engine ?? SyncEngine() { + {SyncEngine? engine, this.hostKey = '', JellyfinCredential? jellyfin, this.ytDlp}) + : jellyfinLogins = [if (jellyfin != null) jellyfin], + engine = engine ?? SyncEngine() { this.engine ..onGone = _memberGone ..onLeft = _memberLeft; @@ -106,20 +121,18 @@ class ServerHub { bool get _sourceGone => engine.media?.relay?.sourceGone ?? false; - /// The current media is a Jellyfin ref on the server [jellyfin] logs in to. + /// The current media is a Jellyfin ref on a server one of + /// [jellyfinLogins] logs in to. bool get relayAvailable => _onLogin(session.media?.jellyfin); - /// Up next's first item is a Jellyfin ref on that server: `/j` serves it - /// too, so viewers without access get it ready. + /// Up next's first item is a Jellyfin ref on such a server: `/j` serves + /// it too, so viewers without access get it ready. bool get relayNextAvailable => _onLogin(nextJellyfinRef); /// Up next's first item's Jellyfin ref (null: none, or not a ref). JellyfinRef? get nextJellyfinRef => engine.queue.items.firstOrNull?.media.jellyfin; - bool _onLogin(JellyfinRef? ref) { - final cred = jellyfin; - return ref != null && cred != null && cred.serverId.isNotEmpty && cred.serverId == ref.serverId; - } + bool _onLogin(JellyfinRef? ref) => ref != null && loginFor(ref.serverId) != null; /// What the swapper reads when the server cannot fetch a link ([why] /// from the relay). A yt-dlp failure on a server without cookies says diff --git a/packages/server/lib/src/sync_server.dart b/packages/server/lib/src/sync_server.dart index 44e6fb0..9a3bcb5 100644 --- a/packages/server/lib/src/sync_server.dart +++ b/packages/server/lib/src/sync_server.dart @@ -130,7 +130,7 @@ class SyncServer { mountArtRoute(_app, hub); mountSessionLogRoutes(_app, hub, () => _sessionLog); mountJellyfinRelayRoute( - _app, sessions, () => hub.jellyfin, () => _relayClient ??= HttpClient(), + _app, sessions, hub.loginFor, () => _relayClient ??= HttpClient(), next: () => hub.nextJellyfinRef, onLog: logRequest); // advertiseHost is only an explicit override; otherwise echo what the client dialled. String authority(String dialledHost) => config.advertiseHost.isNotEmpty @@ -193,32 +193,39 @@ class SyncServer { } /// A configured credential without a ServerId gets it from - /// `/System/Info/Public` (unauthenticated). On failure the relay stays off. + /// `/System/Info/Public` (unauthenticated). On failure that login stays + /// unused. Future _resolveJellyfinServerId() async { - final cred = hub.jellyfin; - if (cred == null) return; - if (cred.serverId.isEmpty) { - final client = JellyfinClient(baseUrl: cred.baseUrl, clientInfo: cred.clientInfo); - try { - final info = await client.publicInfo().timeout(const Duration(seconds: 10)); - hub.jellyfin = cred.withServerId(info.id); - } catch (e) { - _log.warning('jellyfin relay off: server id lookup failed: $e'); - return; - } finally { - client.close(); + final logins = []; + for (final cred in hub.jellyfinLogins) { + var c = cred; + if (c.serverId.isEmpty) { + final client = JellyfinClient(baseUrl: c.baseUrl, clientInfo: c.clientInfo); + try { + final info = await client.publicInfo().timeout(const Duration(seconds: 10)); + c = c.withServerId(info.id); + } catch (e) { + _log.warning('jellyfin relay off for ${c.baseUrl}: server id lookup failed: $e'); + } finally { + client.close(); + } + } + if (c.serverId.isNotEmpty && !logins.any((l) => l.serverId == c.serverId)) { + logins.add(c); + _log.info('jellyfin relay on for server ${c.serverId}'); } } - _log.info('jellyfin relay on for server ${hub.jellyfin!.serverId}'); + hub.jellyfinLogins = logins; } /// Opens the running server to the network: a second listener on /// every IPv4 interface at [port] (0: a free one), the password - /// [secret] ('' : open) and the Jellyfin relay login [jellyfin]. Members + /// [secret] ('' : open) and the Jellyfin relay logins [jellyfin] (one + /// per Jellyfin server; null keeps the ones it has). Members /// already in (on the first listener) stay connected; nothing restarts. /// Shared already: the listener moves to [port] when it differs. Returns /// the bound port; throws when it cannot bind. - Future share({required int port, required String secret, JellyfinCredential? jellyfin}) async { + Future share({required int port, required String secret, List? jellyfin}) async { if (_server == null) throw StateError('the server is not running'); final was = _shared; if (was != null && (port == 0 || port == was.port)) { @@ -247,9 +254,9 @@ class SyncServer { return listener.port; } - void _applyShareSettings(String secret, JellyfinCredential? jellyfin) { + void _applyShareSettings(String secret, List? jellyfin) { session.secret = secret; - if (jellyfin != null) hub.jellyfin = jellyfin; + if (jellyfin != null) hub.jellyfinLogins = [...jellyfin]; } /// Closes the network listener [share] opened: everyone but the host diff --git a/packages/server/test/jellyfin_relay_test.dart b/packages/server/test/jellyfin_relay_test.dart index 3e061b9..7b8392b 100644 --- a/packages/server/test/jellyfin_relay_test.dart +++ b/packages/server/test/jellyfin_relay_test.dart @@ -422,6 +422,31 @@ void main() { }); }); + test('several logins (your server shared): an item goes through its own server\'s login', () async { + await startServer(null); + // Two linked accounts; the default one is on another Jellyfin. + const other = JellyfinCredential( + baseUrl: 'http://127.0.0.1:1', + serverId: 'otherserver', + token: 'OTHER-TOKEN', + client: 'Watch Together', + device: 'host pc', + deviceId: 'dev-1', + version: '0.9.0', + ); + await server.share(port: 0, secret: '', jellyfin: [other, cred()]); + expect(server.hub.jellyfinLogins.map((c) => c.serverId), ['otherserver', _serverId]); + final (a, _) = await join('alice'); + final state = jsonDecode(await swapToRef(a, _item)) as Map; + expect(state['relay'], true); + final (_, w) = await join('bob'); + final r = await relayGet(w.token, _item, range: 'bytes=0-9'); + expect(r.statusCode, 206); + expect(await readAll(r), bytes.sublist(0, 10)); + expect(jf.auth.single, contains('Token="$_token"')); + expect(jf.auth.single, isNot(contains('OTHER-TOKEN'))); + }); + test('a credential without serverId looks it up at start', () async { await startServer(cred(serverId: '')); expect(server.hub.jellyfin!.serverId, _serverId);