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

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

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

No files matched your search

+18
View File
@@ -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,
);
}
-196
View File
@@ -1,196 +0,0 @@
// Release file names -> series / season / episode, anitomy-style but small:
// enough to group a downloads folder into shows. Unknown shapes fall back to
// the cleaned name with no episode, never to a wrong number.
/// What a release file name says.
class ParsedName {
/// Title without group, season marker, episode, year or release tags.
final String series;
final int? season;
final int? episode;
/// Release group: leading `[Group]` or scene-style trailing `-GRP`.
final String? group;
/// Release year, taken out of [series] (movies, `Title (2019)`).
final int? year;
/// Non-episode marker (`NCOP1`, `NCED`, `OVA 2`, `Special`): such files
/// carry no [episode] so they never sort among the real ones.
final String? extra;
const ParsedName(
this.series, {
this.season,
this.episode,
this.group,
this.year,
this.extra,
});
/// `S01E03`, `E25`, the [extra] marker, `S02` for a season pack, else ''.
String get label {
final s = season == null ? '' : 'S${season.toString().padLeft(2, '0')}';
if (episode != null) return '${s}E${episode.toString().padLeft(2, '0')}';
return extra ?? s;
}
/// Grouping key: case, punctuation and spacing do not split a series.
String get seriesKey => normalizeSeries(series);
@override
String toString() => 'ParsedName($series, s=$season, e=$episode, '
'group=$group, year=$year, extra=$extra)';
}
/// Lowercase letters and digits of [s], single spaces between words.
String normalizeSeries(String s) => s
.toLowerCase()
.replaceAll(RegExp(r"['’`]"), '')
.replaceAll(RegExp(r'[^\p{L}\p{N}]+', unicode: true), ' ')
.trim();
// Release tags: the title ends where the first of these starts.
final _junk = RegExp(
r'\b(?:2160p|1440p|1080[pi]|720p|576p|480p|4K|UHD|[xh]\.?26[45]|HEVC|AVC|'
r'AV1|VP9|WEB(?:-?DL|-?Rip)?|BD(?:Rip|Remux)?|Blu-?Ray|BRRip|DVD(?:Rip)?|'
r'HDTV|HDR(?:10)?|DV|REMUX|AAC(?:\d\.\d)?|FLAC|Opus|DTS(?:-HD)?|E?-?AC-?3|'
r'DDP?\d?|TrueHD|Atmos|10-?bit|8-?bit|Hi10P?|PROPER|REPACK|Dual[ -]Audio|'
r'Multi(?:ple)?[ -]Subs?(?:titles?)?|AMZN|NF|CR|DSNP|HMAX|ATVP|'
r'Batch|Complete)\b',
caseSensitive: false,
);
final _ext = RegExp(r'\.(?=[a-z0-9]*[a-z])[a-z0-9]{2,4}$', caseSensitive: false);
final _leadingGroup = RegExp(r'^\s*\[([^\]]+)\]');
final _sceneGroup = RegExp(r'-([A-Za-z0-9]+)$');
final _bracketYear = RegExp(r'[(\[]((?:19|20)\d{2})[)\]]');
final _brackets = RegExp(r'\[[^\]]*\]|\([^)]*\)|\{[^}]*\}|【[^】]*】');
// S01E02, S01.E02, S1 E2; a batch `S01E01-E02` / `S01E01E02` keeps the first.
final _sxe = RegExp(r'\bS(\d{1,2})[ .]?E(\d{1,4})(?:v\d)?(?!\d)',
caseSensitive: false);
final _nxn = RegExp(r'\b(\d{1,2})x(\d{2,3})\b');
// Anime: `Title - 25`, `- 03v2`, `- 01-02` (batch keeps the first).
final _dashNumber = RegExp(r'\s[-–]\s(\d{1,4})(?:v\d)?(?=$|[\s\-~.])');
final _episodeWord = RegExp(
r'\b(?:Episode|Ep\.?|E)\s?(\d{1,4})(?:v\d)?\b',
caseSensitive: false,
);
// Extras: unambiguous words anywhere, short ones only right after the dash.
final _extraAnywhere = RegExp(
r'(?:^|\s)(NCOP|NCED|OVA|OAD|ONA)\s?(\d{1,2})?(?:v\d)?(?=$|\s)',
caseSensitive: false,
);
final _extraAfterDash = RegExp(
r'\s[-–]\s(OP|ED|SP|Specials?|Extras?|Recap|Preview|PV|Menu)\s?(\d{1,2})?'
r'(?:v\d)?(?=$|\s)',
caseSensitive: false,
);
final _seasonInTitle = RegExp(
r'\s(?:S(\d{1,2})|Season\s?(\d{1,2})|(\d{1,2})(?:st|nd|rd|th)\s+Season)\s*$',
caseSensitive: false,
);
final _trailingYear = RegExp(r'\s((?:19|20)\d{2})$');
/// Parses a file name (with or without the extension, no directories).
ParsedName parseReleaseName(String fileName) {
var s = fileName.replaceFirst(_ext, '').trim();
final original = s;
String? group = _leadingGroup.firstMatch(s)?.group(1)?.trim();
int? year;
final by = _bracketYear.firstMatch(s);
if (by != null) year = int.parse(by.group(1)!);
s = s.replaceAll(_brackets, ' ');
// Dots and underscores as separators (scene style), unless the name
// already has spaces and only the odd `Dr. Stone` dot.
final dotted = !s.contains(' ') || RegExp(r'\w\.\w').allMatches(s).length > 2;
if (dotted) {
// `...H264-GRP`, but not `...WEB-DL`.
final tags = _junk.allMatches(s);
if (group == null && tags.isNotEmpty && tags.last.end < s.length) {
final g = _sceneGroup.firstMatch(s);
if (g != null) {
group = g.group(1);
s = s.substring(0, g.start);
}
}
s = s.replaceAll('.', ' ');
}
s = s.replaceAll('_', ' ').replaceAll(RegExp(r'\s+'), ' ').trim();
// Release tags end the interesting part.
final junk = _junk.firstMatch(s);
if (junk != null && junk.start > 0) s = s.substring(0, junk.start).trim();
int? season, episode;
String? extra;
var cut = s.length;
final extraMatch =
_extraAfterDash.firstMatch(s) ?? _extraAnywhere.firstMatch(s);
final sxe = _sxe.firstMatch(s);
if (extraMatch != null) {
final word = extraMatch.group(1)!;
final upper = word.length <= 4 ? word.toUpperCase() : _title(word);
// `OVA - 02`: the number may follow the dash instead.
final n = extraMatch.group(2) ??
_dashNumber.firstMatch(s.substring(extraMatch.end))?.group(1);
extra = n == null
? upper
: (upper.startsWith('NC') || upper.length <= 2)
? '$upper${int.parse(n)}'
: '$upper ${int.parse(n)}';
cut = extraMatch.start;
if (sxe != null && sxe.start < cut) {
season = int.parse(sxe.group(1)!);
cut = sxe.start;
}
} else if (sxe != null) {
season = int.parse(sxe.group(1)!);
episode = int.parse(sxe.group(2)!);
cut = sxe.start;
} else {
final nxn = _nxn.firstMatch(s);
if (nxn != null) {
season = int.parse(nxn.group(1)!);
episode = int.parse(nxn.group(2)!);
cut = nxn.start;
} else {
final m = _dashNumber.firstMatch(s) ?? _episodeWord.firstMatch(s);
if (m != null) {
episode = int.parse(m.group(1)!);
cut = m.start;
}
}
}
var series = s.substring(0, cut);
// Season pack or season marker kept in the title: `Title S2`,
// `Title Season 2`, `Title 2nd Season`, `Title.S01.1080p`.
final sIn = _seasonInTitle.firstMatch(series);
if (sIn != null) {
season ??= int.parse(sIn.group(1) ?? sIn.group(2) ?? sIn.group(3)!);
series = series.substring(0, sIn.start);
}
series = series.replaceAll(RegExp(r'[\s\-–:,~]+$'), '').trim();
// A trailing year is the release year, unless it is the whole title or
// too far ahead to be one (`Blade Runner 2049`).
final ty = _trailingYear.firstMatch(series);
if (ty != null) {
final y = int.parse(ty.group(1)!);
if (y <= DateTime.now().year + 1) {
year ??= y;
series = series.substring(0, ty.start).trim();
}
}
series = series.replaceAll(RegExp(r'[\s\-–:,~]+$'), '').trim();
if (series.isEmpty) {
series = original.replaceAll(RegExp(r'[._]+'), ' ').trim();
}
return ParsedName(
series,
season: season,
episode: episode,
group: group,
year: year,
extra: extra,
);
}
String _title(String w) => w[0].toUpperCase() + w.substring(1).toLowerCase();
+9 -114
View File
@@ -1,16 +1,13 @@
import 'dart:io';
import 'dart:isolate';
import 'name_parser.dart';
import 'package:sync_relay_core/core.dart';
// Folder scan for the Files source: videos with their parsed names,
// sidecar subtitles, everything else counted and hidden. The walk is
// synchronous I/O, so it runs in its own isolate.
export 'package:sync_relay_core/core.dart' show FileKind, LocalFile, kindOf, subtitleExtensions, videoExtensions;
const videoExtensions = {
'mkv', 'mp4', 'webm', 'avi', 'mov', 'm4v', 'ts', 'm2ts', 'wmv', 'flv', //
};
const subtitleExtensions = {'ass', 'ssa', 'srt', 'vtt', 'sup'};
// Folder scan for the Files source: the videos (grouped into shows by
// core's `groupLocal`, L1), sidecar subtitles, everything else counted
// and hidden. The walk is synchronous I/O, so it runs in its own isolate.
/// Files looked at per scan; past this the result is [FolderScan.truncated].
const maxScannedFiles = 20000;
@@ -18,27 +15,8 @@ const maxScannedFiles = 20000;
/// Samples and trailers under this size are hidden next to the real thing.
const _sampleMaxBytes = 300 << 20;
enum FileKind { video, subtitle, other }
FileKind kindOf(String path) {
final ext = _extension(_baseName(path));
if (videoExtensions.contains(ext)) return FileKind.video;
if (subtitleExtensions.contains(ext)) return FileKind.subtitle;
return FileKind.other;
}
class VideoFile {
final String path;
final int size;
final ParsedName parsed;
final DateTime modified;
const VideoFile(this.path, this.size, this.parsed, this.modified);
String get name => _baseName(path);
}
class FolderScan {
final List<VideoFile> videos;
final List<LocalFile> videos;
/// Non-video files and samples (subtitles and dot-entries not counted).
final int hiddenCount;
/// Up to 6 of the hidden file names, for "and N other files" hints.
@@ -75,7 +53,7 @@ Future<FolderScan> scanFolder(String dir, {int maxFiles = maxScannedFiles}) =>
/// The walk behind [scanFolder]; blocks, so call it from an isolate.
FolderScan scanFolderSync(String dir, {int maxFiles = maxScannedFiles}) {
final videos = <VideoFile>[];
final videos = <LocalFile>[];
final subs = <String>[];
final examples = <String>[];
var hidden = 0, seen = 0;
@@ -123,8 +101,7 @@ FolderScan scanFolderSync(String dir, {int maxFiles = maxScannedFiles}) {
if (examples.length < 6) examples.add(name);
continue;
}
videos.add(VideoFile(e.path, size, parseReleaseName(name),
stat?.modified ?? DateTime.fromMillisecondsSinceEpoch(0)));
videos.add(LocalFile(e.path, size, stat?.modified ?? DateTime.fromMillisecondsSinceEpoch(0)));
}
}
return FolderScan(
@@ -138,7 +115,7 @@ FolderScan scanFolderSync(String dir, {int maxFiles = maxScannedFiles}) {
/// Subtitles in the same folder whose name is the video's base name,
/// optionally followed by `.lang` / `.forced` style suffixes.
Map<String, List<String>> _sidecars(List<VideoFile> videos, List<String> subs) {
Map<String, List<String>> _sidecars(List<LocalFile> videos, List<String> subs) {
final byStem = <String, List<String>>{};
for (final s in subs) {
final stem = _stripExtension(s);
@@ -163,93 +140,11 @@ Map<String, List<String>> _sidecars(List<VideoFile> videos, List<String> subs) {
};
}
/// One show: its episodes in season/episode order plus its extras
/// (NCOP, OVA...) in name order.
class SeriesGroup {
/// Display title: the most common spelling among the files.
final String title;
final String key;
final List<VideoFile> episodes;
final List<VideoFile> extras;
const SeriesGroup(this.title, this.key, this.episodes, this.extras);
/// Distinct seasons, ascending; files without one count as season 1.
List<int> get seasons =>
({for (final e in episodes) e.parsed.season ?? 1}.toList()..sort());
}
/// [groupBySeries] result: shows, and videos that are not episodes.
class GroupedVideos {
final List<SeriesGroup> series;
final List<VideoFile> loose;
const GroupedVideos(this.series, this.loose);
}
/// Groups videos with an episode number (or an extra marker) by
/// [ParsedName.seriesKey]; the rest (movies, clips) stay loose, by name.
/// An extra whose series has no episodes in the list is loose too.
GroupedVideos groupBySeries(Iterable<VideoFile> videos) {
final episodes = <String, List<VideoFile>>{};
final extras = <String, List<VideoFile>>{};
final loose = <VideoFile>[];
for (final v in videos) {
final p = v.parsed;
final key = p.seriesKey;
if (p.episode != null && key.isNotEmpty) {
episodes.putIfAbsent(key, () => []).add(v);
} else if (p.extra != null && key.isNotEmpty) {
extras.putIfAbsent(key, () => []).add(v);
} else {
loose.add(v);
}
}
for (final e in extras.entries) {
if (!episodes.containsKey(e.key)) loose.addAll(e.value);
}
int byName(VideoFile a, VideoFile b) =>
a.name.toLowerCase().compareTo(b.name.toLowerCase());
final groups = [
for (final e in episodes.entries)
SeriesGroup(
_commonTitle(e.value),
e.key,
e.value..sort(_byEpisode),
(extras[e.key] ?? [])..sort(byName),
),
]..sort((a, b) => a.title.toLowerCase().compareTo(b.title.toLowerCase()));
return GroupedVideos(groups, loose..sort(byName));
}
int _byEpisode(VideoFile a, VideoFile b) {
final pa = a.parsed, pb = b.parsed;
final s = (pa.season ?? 1).compareTo(pb.season ?? 1);
if (s != 0) return s;
final e = pa.episode!.compareTo(pb.episode!);
if (e != 0) return e;
// Same episode twice (v2, another group): newest release first.
return b.name.compareTo(a.name);
}
String _commonTitle(List<VideoFile> files) {
final counts = <String, int>{};
for (final f in files) {
counts.update(f.parsed.series, (n) => n + 1, ifAbsent: () => 1);
}
return counts.entries
.reduce((a, b) => b.value > a.value ? b : a)
.key;
}
String _baseName(String path) {
final i = path.lastIndexOf(RegExp(r'[/\\]'));
return i < 0 ? path : path.substring(i + 1);
}
String _extension(String name) {
final dot = name.lastIndexOf('.');
return dot <= 0 ? '' : name.substring(dot + 1).toLowerCase();
}
String _stripExtension(String path) {
final dot = path.lastIndexOf('.');
final sep = path.lastIndexOf(RegExp(r'[/\\]'));
+8 -76
View File
@@ -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)),
],
),
+338 -160
View File
@@ -3,51 +3,57 @@ import 'dart:io';
import 'package:flutter/material.dart';
import 'package:signals/signals_flutter.dart';
import 'package:sync_relay_core/core.dart';
import '../../library/show_matcher.dart';
import '../../shell/motion.dart';
import '../../platform/ports/folder_access.dart';
import '../../app/app_graph.dart';
import '../../widgets/app_menu.dart';
import '../folder_store.dart';
import '../name_parser.dart';
import '../scanner.dart';
import 'file_tiles.dart';
import 'library_view.dart';
import 'thumb.dart';
export 'thumb.dart' show ThumbnailLoader;
// The Files source: watched folders on the left (chips on top when
// narrow), the open one on the right grouped into series and loose
// videos, or Recent. A folder is scanned when first opened and on Rescan;
// scans are kept for the app session. Series start collapsed; the ones
// opened stay open for the app session. Playing goes through [onPlay].
// The Files source as a library: the watched folders on the left (chips
// on top when narrow): the Library (every folder together), Recent, then
// each folder. A library is shows and movies as poster cards, grouped by
// the library's L1 and named by its L2 match, plus the videos that are
// neither; a show opens its own page. Folders are scanned when first
// needed and on Rescan; scans, the open place and the open show are kept
// for the app session. Playing goes through [onPlay].
/// Below this width the sidebar turns into chips above the list.
const filesNarrowWidth = 700.0;
/// The place that shows every watched folder together.
const libraryPlace = '\u0000library';
/// Scans kept for the app session, by folder.
final _scans = <String, FolderScan>{};
final _running = <String, Future<FolderScan>>{};
String? _lastOpen;
/// Series opened this session, by `folder\nseries key`.
final _expanded = <String>{};
String? _lastOpen = libraryPlace;
/// The show open in each place (by show key) for the app session.
final _openShows = <String, String>{};
final _showRequests = ValueNotifier(0);
/// Opens [dir] (null: Recent) in the Files page: now if one is showing,
/// else when it next opens.
/// Opens [dir] (null: Recent; [libraryPlace]: the library) in the Files
/// page: now if one is showing, else when it next opens.
void showFilesFolder(String? dir) {
_lastOpen = dir;
_showRequests.value++;
}
/// Forgets the session's scans, open series and the open folder.
/// Forgets the session's scans, open shows and the open place.
@visibleForTesting
void resetFilesPageSession() {
_scans.clear();
_running.clear();
_expanded.clear();
_lastOpen = null;
_openShows.clear();
_lastOpen = libraryPlace;
}
class FilesPage extends StatefulWidget {
@@ -66,6 +72,8 @@ class FilesPage extends StatefulWidget {
final Future<FolderScan> Function(String dir)? scan;
/// Defaults to [FolderStore.instance].
final FolderStore? store;
/// Defaults to the app's (`AppGraph.library`).
final ShowMatcher? matcher;
/// Default: the system folder / file dialogs.
final Future<String?> Function()? pickFolder;
final Future<String?> Function()? pickVideo;
@@ -80,6 +88,7 @@ class FilesPage extends StatefulWidget {
this.thumbnails,
this.scan,
this.store,
this.matcher,
this.pickFolder,
this.pickVideo,
});
@@ -88,52 +97,56 @@ class FilesPage extends StatefulWidget {
State<FilesPage> createState() => _FilesPageState();
}
/// A scan grouped for display, made once per scan.
/// A place's scans grouped (L1), made once per set of scans.
class _Library {
final GroupedVideos grouped;
final int subtitleFiles;
final List<FolderScan> scans;
final LocalLibrary lib;
final Map<String, LocalEpisode> byPath;
_Library(FolderScan scan)
: grouped = groupBySeries(scan.videos),
subtitleFiles = {for (final l in scan.subtitles.values) ...l}.length;
_Library(this.scans, List<String> roots)
: lib = groupLocal([for (final s in scans) ...s.videos], roots: roots),
byPath = {} {
for (final e in lib.files) {
byPath[e.path] = e;
}
}
/// Series rows in order: a header (video null), then, for the series
/// [open] says, its episodes and extras.
List<(SeriesGroup, VideoFile?)> rows(bool Function(SeriesGroup g) open) => [
for (final g in grouped.series) ...[
(g, null),
if (open(g))
for (final v in [...g.episodes, ...g.extras]) (g, v),
],
];
int get videos => scans.fold(0, (n, s) => n + s.videos.length);
int get hidden => scans.fold(0, (n, s) => n + s.hiddenCount);
int get subtitleFiles => {
for (final s in scans)
for (final l in s.subtitles.values) ...l
}.length;
bool get truncated => scans.any((s) => s.truncated);
}
final _libraries = Expando<_Library>();
class _FilesPageState extends State<FilesPage> {
FolderAccess get _folders => AppScope.of(context).ports.folders;
ShowMatcher get _matcher => widget.matcher ?? AppScope.of(context).library;
late final ThumbCache _thumbs =
widget.thumbnails == null ? ThumbCache.shared : ThumbCache(widget.thumbnails!);
FolderStore get _store => widget.store ?? FolderStore.instance;
/// Null: Recent.
/// Null: Recent; [libraryPlace]: every folder.
String? _open = _lastOpen;
bool _showHidden = false;
String? _starting;
String? _error;
final _libs = <String, _Library>{};
@override
void initState() {
super.initState();
_showRequests.addListener(_shown);
unawaited(_matcher.ensureLoaded());
_store.ensureLoaded().then((_) {
if (!mounted) return;
final open = _open;
if (open != null && !_store.folders.value.contains(open)) {
_select(null);
} else if (open != null) {
_scan(open);
if (open != null && open != libraryPlace && !_store.folders.value.contains(open)) {
_select(libraryPlace);
} else {
_scanFor(open);
}
});
}
@@ -148,19 +161,32 @@ class _FilesPageState extends State<FilesPage> {
Future<FolderScan> _runScan(String dir) => (widget.scan ?? _folders.scan)(dir);
void _select(String? dir) {
/// The folders a place needs scanned.
List<String> _foldersOf(String? place) => place == null
? const []
: place == libraryPlace
? _store.folders.peek()
: [place];
void _scanFor(String? place) {
for (final f in _foldersOf(place)) {
if (!_scans.containsKey(f) && !_running.containsKey(f)) _scan(f);
}
}
void _select(String? place) {
setState(() {
_open = _lastOpen = dir;
_open = _lastOpen = place;
_showHidden = false;
});
if (dir != null && !_scans.containsKey(dir)) _scan(dir);
_scanFor(place);
}
Future<void> _scan(String dir, {bool rescan = false}) async {
var f = _running[dir];
if (f == null || rescan) {
f = _running[dir] = _runScan(dir);
if (rescan) _thumbs.forget([for (final v in _scans[dir]?.videos ?? const <VideoFile>[]) v.path]);
if (rescan) _thumbs.forget([for (final v in _scans[dir]?.videos ?? const <LocalFile>[]) v.path]);
}
if (mounted) setState(() {});
FolderScan result;
@@ -176,6 +202,26 @@ class _FilesPageState extends State<FilesPage> {
if (mounted) setState(() {});
}
/// The grouped library of [place], once its folders are scanned (the
/// library: those scanned so far).
_Library? _libraryOf(String place) {
final folders = _foldersOf(place);
final scans = [for (final f in folders) ?_scans[f]];
if (place != libraryPlace && scans.isEmpty) return null;
final known = _libs[place];
if (known != null && known.scans.length == scans.length && _same(known.scans, scans)) return known;
final lib = _libs[place] = _Library(scans, folders);
_matcher.remember(lib.lib);
return lib;
}
static bool _same(List<FolderScan> a, List<FolderScan> b) {
for (var i = 0; i < a.length; i++) {
if (!identical(a[i], b[i])) return false;
}
return true;
}
Future<void> _addFolder() async {
String? dir;
try {
@@ -207,7 +253,8 @@ class _FilesPageState extends State<FilesPage> {
void _removeFolder(String dir) {
_store.removeFolder(dir);
_scans.remove(dir);
if (_open == dir) _select(null);
if (_open == dir) _select(libraryPlace);
setState(() {});
}
Future<void> _play(String path) async {
@@ -271,6 +318,22 @@ class _FilesPageState extends State<FilesPage> {
child: Builder(
builder: (context) => GestureDetector(onSecondaryTap: () => _menu(context, path), child: child)));
late final _actions = FilesActions(
play: (p) => unawaited(_play(p)),
stateOf: _stateOf,
menuable: _menuable,
thumbs: _thumbs,
);
/// The last episode of [show] played here (Recent), by key.
String? _lastPlayed(_Library lib, LocalShow show) {
for (final p in _store.recent.value) {
final e = lib.byPath[p];
if (e != null && e.showKey == show.key && e.kind == LocalKind.episode) return e.key;
}
return null;
}
@override
Widget build(BuildContext context) => LayoutBuilder(
builder: (context, box) {
@@ -278,11 +341,13 @@ class _FilesPageState extends State<FilesPage> {
return SignalBuilder(builder: (context) {
final folders = _store.folders.value;
final recent = _store.recent.value;
final open = _open != null && folders.contains(_open) ? _open : null;
_matcher.revision.value;
final open = _open == null || _open == libraryPlace || folders.contains(_open) ? _open : libraryPlace;
final showKey = open == null ? null : _openShows[open];
final main = fadeThrough(
KeyedSubtree(
key: ValueKey(open ?? ''),
child: _main(context, narrow, folders, recent, open),
key: ValueKey('${open ?? ''}\n${showKey ?? ''}'),
child: _main(context, narrow, folders, recent, open, showKey),
),
);
if (narrow) return main;
@@ -302,12 +367,23 @@ class _FilesPageState extends State<FilesPage> {
Widget _sidebar(BuildContext context, List<String> folders, String? open) {
final scheme = Theme.of(context).colorScheme;
final shape = RoundedRectangleBorder(borderRadius: BorderRadius.circular(10));
Widget? status(String? s) =>
s == null ? null : Text(s, style: TextStyle(color: scheme.onSurfaceVariant, fontSize: 11));
return ListView(
padding: const EdgeInsets.fromLTRB(20, 20, 8, 20),
children: [
const Text('Folders', style: TextStyle(fontWeight: FontWeight.w600)),
const SizedBox(height: 8),
ListTile(
key: const ValueKey('place:library'),
dense: true,
selected: open == libraryPlace,
shape: shape,
leading: const Icon(Icons.video_library_outlined, size: 18),
title: const Text('Library'),
subtitle: status(_libraryStatus(folders)),
onTap: () => _select(libraryPlace),
),
ListTile(
key: const ValueKey('place:recent'),
dense: true,
selected: open == null,
shape: shape,
@@ -315,6 +391,9 @@ class _FilesPageState extends State<FilesPage> {
title: const Text('Recent'),
onTap: () => _select(null),
),
const SizedBox(height: 12),
const Text('Folders', style: TextStyle(fontWeight: FontWeight.w600)),
const SizedBox(height: 8),
for (final f in folders)
ListTile(
key: ValueKey('folder:$f'),
@@ -323,10 +402,7 @@ class _FilesPageState extends State<FilesPage> {
shape: shape,
leading: const Icon(Icons.folder_outlined, size: 18),
title: Text(baseName(f), maxLines: 1, overflow: TextOverflow.ellipsis),
subtitle: switch (_folderStatus(f)) {
final s? => Text(s, style: TextStyle(color: scheme.onSurfaceVariant, fontSize: 11)),
null => null,
},
subtitle: status(_folderStatus(f)),
trailing: IconButton(
tooltip: 'Remove folder',
icon: const Icon(Icons.close, size: 16),
@@ -364,33 +440,77 @@ class _FilesPageState extends State<FilesPage> {
return n == null ? null : '$n ${n == 1 ? 'video' : 'videos'}';
}
String? _libraryStatus(List<String> folders) {
if (folders.any(_running.containsKey)) return 'scanning…';
if (folders.isEmpty || folders.any((f) => !_scans.containsKey(f))) return null;
final n = _libraryOf(libraryPlace)?.lib.shows.length ?? 0;
return '$n ${n == 1 ? 'show' : 'shows'}';
}
List<Widget> _notices() => [
if (!widget.alone && widget.relayed) const _RelayNotice(),
AnimatedSize(
duration: easeTime,
curve: ease,
alignment: Alignment.topLeft,
child: _error == null
? const SizedBox(width: double.infinity)
: _ErrorBanner(_error!, onClose: () => setState(() => _error = null)),
),
];
Widget _main(
BuildContext context,
bool narrow,
List<String> folders,
List<String> recent,
String? open,
String? showKey,
) {
final scan = open == null ? null : _scans[open];
final scanning = open != null && _running.containsKey(open);
final lib = open == null ? null : _libraryOf(open);
final show = showKey == null ? null : lib?.lib.show(showKey);
if (open != null && lib != null && show != null) {
return ShowPage(
key: ValueKey('show:$open:${show.key}'),
show: show,
matcher: _matcher,
actions: _actions,
lastPlayed: _lastPlayed(lib, show),
backLabel: open == libraryPlace ? 'Library' : baseName(open),
onBack: () => setState(() => _openShows.remove(open)),
top: [if (narrow) _chips(folders, open), ..._notices()],
);
}
final scanning = _foldersOf(open).any(_running.containsKey);
// A folder added elsewhere (a drop, the handoff) joins the library.
if (open == libraryPlace && folders.any((f) => !_scans.containsKey(f) && !_running.containsKey(f))) {
scheduleMicrotask(() {
if (mounted) _scanFor(libraryPlace);
});
}
final top = <Widget>[
_header(context, open),
if (narrow) _chips(folders, open),
if (!widget.alone && widget.relayed) const _RelayNotice(),
AnimatedSize(
duration: easeTime,
curve: ease,
alignment: Alignment.topLeft,
child: _error == null
? const SizedBox(width: double.infinity)
: _ErrorBanner(_error!, onClose: () => setState(() => _error = null)),
),
..._notices(),
];
final slivers = <Widget>[
SliverList.list(children: top),
if (open == null)
..._recent(context, recent)
else if (scan == null)
else if (open == libraryPlace && folders.isEmpty)
SliverToBoxAdapter(
child: FilesEmpty(
Icons.video_library_outlined,
'Your library is empty',
'Add a folder with your videos: shows, seasons and movies are found by their names.',
action: FilledButton.icon(
onPressed: _addFolder,
icon: const Icon(Icons.create_new_folder_outlined),
label: const Text('Add folder'),
),
),
)
else if (lib == null)
const SliverToBoxAdapter(
child: Padding(
padding: EdgeInsets.only(top: 60),
@@ -398,7 +518,7 @@ class _FilesPageState extends State<FilesPage> {
),
)
else
..._folder(context, open, scan, scanning),
..._library(context, open, lib, scanning),
];
return CustomScrollView(
slivers: [
@@ -412,25 +532,30 @@ class _FilesPageState extends State<FilesPage> {
Widget _header(BuildContext context, String? open) {
final scheme = Theme.of(context).colorScheme;
final (icon, title, sub) = switch (open) {
null => (Icons.history, 'Recent', null),
libraryPlace => (Icons.video_library, 'Library', null),
final dir => (Icons.folder, baseName(dir), dir),
};
return Padding(
padding: const EdgeInsets.only(bottom: 14),
child: Row(
children: [
Icon(open == null ? Icons.history : Icons.folder, color: scheme.primary),
Icon(icon, color: scheme.primary),
const SizedBox(width: 10),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
open == null ? 'Recent' : baseName(open),
title,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: Theme.of(context).textTheme.headlineSmall,
),
if (open != null)
if (sub != null)
Text(
open,
sub,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(color: scheme.onSurfaceVariant, fontSize: 12),
@@ -449,6 +574,11 @@ class _FilesPageState extends State<FilesPage> {
spacing: 8,
runSpacing: 8,
children: [
ChoiceChip(
label: const Text('Library'),
selected: open == libraryPlace,
onSelected: (_) => _select(libraryPlace),
),
ChoiceChip(
label: const Text('Recent'),
selected: open == null,
@@ -506,6 +636,7 @@ class _FilesPageState extends State<FilesPage> {
_RecentRow(
key: ValueKey(path),
path: path,
matcher: _matcher,
state: _stateOf(path),
thumbs: _thumbs,
onTap: _tapOf(path),
@@ -516,21 +647,32 @@ class _FilesPageState extends State<FilesPage> {
];
}
List<Widget> _folder(BuildContext context, String dir, FolderScan scan, bool scanning) {
Widget _section(BuildContext context, String title, {String? count}) => Padding(
padding: const EdgeInsets.only(top: 22, bottom: 10),
child: Row(children: [
Text(title, style: Theme.of(context).textTheme.titleMedium),
if (count != null) ...[
const SizedBox(width: 8),
Text(count, style: Theme.of(context).textTheme.bodySmall?.copyWith(color: Colors.grey)),
],
]),
);
List<Widget> _library(BuildContext context, String place, _Library lib, bool scanning) {
final scheme = Theme.of(context).colorScheme;
final lib = _libraries[scan] ??= _Library(scan);
final series = lib.grouped.series.length;
final l = lib.lib;
final folderScan = place == libraryPlace ? null : _scans[place];
String count(int n, String one, String many) => '$n ${n == 1 ? one : many}';
final summary = [
count(scan.videos.length, 'video', 'videos') + (series > 0 ? ' in $series series' : ''),
if (scan.hiddenCount > 0)
'${count(scan.hiddenCount, 'other file', 'other files')} hidden',
count(lib.videos, 'video', 'videos') +
(l.shows.isEmpty ? '' : ' in ${count(l.shows.length, 'show', 'shows')}'),
if (lib.hidden > 0) '${count(lib.hidden, 'other file', 'other files')} hidden',
if (lib.subtitleFiles > 0) count(lib.subtitleFiles, 'subtitle file', 'subtitle files'),
if (scan.truncated) 'stopped after $maxScannedFiles files',
if (lib.truncated) 'stopped after $maxScannedFiles files',
].join(' · ');
final loose = lib.grouped.loose;
String seriesId(SeriesGroup g) => '$dir\n${g.key}';
final rows = lib.rows((g) => _expanded.contains(seriesId(g)));
final folders = _foldersOf(place);
final movies = l.movies;
final other = l.other;
return [
SliverToBoxAdapter(
child: Wrap(
@@ -539,7 +681,7 @@ class _FilesPageState extends State<FilesPage> {
Icon(Icons.filter_alt_outlined, size: 16, color: scheme.onSurfaceVariant),
const SizedBox(width: 6),
Text(summary, style: TextStyle(color: scheme.onSurfaceVariant, fontSize: 12)),
if (scan.hiddenCount > 0) ...[
if (folderScan != null && folderScan.hiddenCount > 0) ...[
const SizedBox(width: 8),
TextButton(
onPressed: () => setState(() => _showHidden = !_showHidden),
@@ -547,49 +689,55 @@ class _FilesPageState extends State<FilesPage> {
),
],
TextButton.icon(
onPressed: scanning ? null : () => _scan(dir, rescan: true),
onPressed: scanning
? null
: () {
for (final f in folders) {
_scan(f, rescan: true);
}
},
icon: scanning
? const SizedBox.square(
dimension: 14, child: CircularProgressIndicator(strokeWidth: 2))
? const SizedBox.square(dimension: 14, child: CircularProgressIndicator(strokeWidth: 2))
: const Icon(Icons.refresh, size: 16),
label: Text(scanning ? 'Scanning…' : 'Rescan'),
),
],
),
),
SliverToBoxAdapter(
child: AnimatedSize(
duration: easeTime,
curve: ease,
alignment: Alignment.topLeft,
child: !_showHidden
? const SizedBox(width: double.infinity)
: Column(
children: [
for (final f in scan.hiddenExamples)
ListTile(
dense: true,
enabled: false,
leading: const Icon(Icons.insert_drive_file_outlined, size: 18),
title: Text(f, maxLines: 1, overflow: TextOverflow.ellipsis),
trailing: Text(
kindOf(f) == FileKind.video ? 'sample' : 'not a video',
style: const TextStyle(fontSize: 12),
),
),
if (scan.hiddenCount > scan.hiddenExamples.length)
ListTile(
dense: true,
enabled: false,
title: Text('and ${scan.hiddenCount - scan.hiddenExamples.length} more'),
),
],
),
),
),
if (scan.videos.isEmpty)
if (folderScan != null)
SliverToBoxAdapter(
child: _folders.folderExists(dir)
child: AnimatedSize(
duration: easeTime,
curve: ease,
alignment: Alignment.topLeft,
child: !_showHidden
? const SizedBox(width: double.infinity)
: Column(
children: [
for (final f in folderScan.hiddenExamples)
ListTile(
dense: true,
enabled: false,
leading: const Icon(Icons.insert_drive_file_outlined, size: 18),
title: Text(f, maxLines: 1, overflow: TextOverflow.ellipsis),
trailing: Text(
kindOf(f) == FileKind.video ? 'sample' : 'not a video',
style: const TextStyle(fontSize: 12),
),
),
if (folderScan.hiddenCount > folderScan.hiddenExamples.length)
ListTile(
dense: true,
enabled: false,
title: Text('and ${folderScan.hiddenCount - folderScan.hiddenExamples.length} more'),
),
],
),
),
),
if (lib.videos == 0)
SliverToBoxAdapter(
child: place == libraryPlace || _folders.folderExists(place)
? const FilesEmpty(
Icons.search_off,
'No videos here',
@@ -601,46 +749,25 @@ class _FilesPageState extends State<FilesPage> {
'It may have been moved, renamed or be on a drive that is not connected.',
),
),
// Series: files whose names carry an episode number; a header
// click opens or closes its episodes.
SliverList.builder(
itemCount: rows.length,
itemBuilder: (context, i) {
final (group, v) = rows[i];
if (v == null) {
final id = seriesId(group);
return SeriesHeader(
key: ValueKey('series:${group.key}'),
group: group,
thumbs: _thumbs,
expanded: _expanded.contains(id),
onTap: () => setState(() => _expanded.contains(id) ? _expanded.remove(id) : _expanded.add(id)),
);
}
return _menuable(
v.path,
FileRow(
key: ValueKey(v.path),
path: v.path,
title: v.parsed.label.isEmpty ? v.parsed.series : v.parsed.label,
subtitle: v.name,
size: formatSize(v.size),
state: _stateOf(v.path),
thumbs: _thumbs,
onTap: _tapOf(v.path),
));
},
),
// Everything else (movies, clips) as a thumbnail grid.
if (loose.isNotEmpty) ...[
if (l.shows.isNotEmpty) ...[
SliverToBoxAdapter(child: _section(context, 'Shows', count: '${l.shows.length}')),
SliverGrid.builder(
gridDelegate: posterGrid(),
itemCount: l.shows.length,
itemBuilder: (context, i) => _showCard(place, lib, l.shows[i]),
),
],
if (movies.isNotEmpty) ...[
SliverToBoxAdapter(child: _section(context, 'Movies', count: '${movies.length}')),
SliverGrid.builder(
gridDelegate: posterGrid(),
itemCount: movies.length,
itemBuilder: (context, i) => _movieCard(movies[i]),
),
],
if (other.isNotEmpty) ...[
SliverToBoxAdapter(
child: Padding(
padding: const EdgeInsets.only(top: 22, bottom: 10),
child: Text(
series == 0 ? 'Videos' : 'Other videos',
style: Theme.of(context).textTheme.titleMedium,
),
),
child: _section(context, l.shows.isEmpty && movies.isEmpty ? 'Videos' : 'Other videos'),
),
SliverLayoutBuilder(builder: (context, c) {
const gap = 12.0;
@@ -654,9 +781,9 @@ class _FilesPageState extends State<FilesPage> {
crossAxisSpacing: gap,
mainAxisExtent: cardW * 9 / 16 + videoCardTextHeight,
),
itemCount: loose.length,
itemCount: other.length,
itemBuilder: (context, i) {
final v = loose[i];
final v = other[i];
return _menuable(
v.path,
VideoCard(
@@ -672,12 +799,60 @@ class _FilesPageState extends State<FilesPage> {
],
];
}
Widget _showCard(String place, _Library lib, LocalShow show) {
final m = _matcher.showMatch(show);
final last = _lastPlayed(lib, show);
final String subtitle;
if (last == null) {
final n = show.episodeCount;
subtitle = '$n ${n == 1 ? 'episode' : 'episodes'}';
} else {
final next = show.nextAfter(last);
subtitle = next == null ? 'Watched to the end' : 'Next: ${episodeNumber(next.episode)}';
}
final playing = widget.nowPlayingPath != null && show.files.any((e) => e.path == widget.nowPlayingPath);
return PosterCard(
key: ValueKey('showcard:${show.key}'),
title: m?.entry.title ?? show.title,
subtitle: subtitle,
coverUrl: m?.entry.cover,
framePath: show.episodes.firstOrNull?.path,
thumbs: _thumbs,
playing: playing,
onShown: () => _matcher.want(show),
onTap: () => setState(() => _openShows[place] = show.key),
);
}
Widget _movieCard(LocalEpisode movie) {
final m = _matcher.seasonMatch(movie);
return _menuable(
movie.path,
PosterCard(
key: ValueKey('moviecard:${movie.path}'),
title: m?.entry.title ?? (movie.parsed.year == null ? movie.title : '${movie.title} (${movie.parsed.year})'),
subtitle: [
if (movie.movieNumber != null) movie.label,
if (m?.entry.year != null) '${m!.entry.year}' else if (movie.parsed.year != null) '${movie.parsed.year}',
formatSize(movie.file.size),
].join(' · '),
coverUrl: m?.entry.cover,
framePath: movie.path,
thumbs: _thumbs,
playing: _stateOf(movie.path) == PlayState.playing,
onShown: () => _matcher.wantMovie(movie),
onTap: _tapOf(movie.path),
));
}
}
/// A Recent entry: `Series · S01E03` over the file name, or the cleaned
/// title over its folder; greyed when the file is gone.
/// A Recent entry: `Show · E02` over the file name (the library's names
/// when it knows the show), or the cleaned title over its folder; greyed
/// when the file is gone.
class _RecentRow extends StatefulWidget {
final String path;
final ShowMatcher matcher;
final PlayState state;
final ThumbCache thumbs;
final VoidCallback? onTap;
@@ -685,6 +860,7 @@ class _RecentRow extends StatefulWidget {
const _RecentRow({
super.key,
required this.path,
required this.matcher,
required this.state,
required this.thumbs,
required this.onTap,
@@ -711,12 +887,14 @@ class _RecentRowState extends State<_RecentRow> {
@override
Widget build(BuildContext context) {
final path = widget.path;
final p = parseReleaseName(baseName(path));
final e = widget.matcher.episodeAt(path) ??
readLocalFile(LocalFile(path, 0, DateTime.fromMillisecondsSinceEpoch(0)));
final missing = _stat?.type == FileSystemEntityType.notFound;
final episode = p.episode != null || p.extra != null;
final episode = e.kind == LocalKind.episode || e.kind == LocalKind.extra;
final name = widget.matcher.seasonMatch(e)?.entry.title ?? e.title;
return FileRow(
path: path,
title: episode ? '${p.series} · ${p.label}' : cleanTitle(p),
title: episode ? '$name · ${e.label}' : (e.kind == LocalKind.other ? cleanTitle(e.parsed) : name),
subtitle: missing ? 'File not found' : (episode ? baseName(path) : parentName(path)),
size: _stat == null || missing ? null : formatSize(_stat!.size),
state: widget.state,
@@ -0,0 +1,712 @@
import 'package:flutter/material.dart';
import 'package:flutter/rendering.dart';
import 'package:signals/signals_flutter.dart';
import 'package:sync_relay_core/core.dart';
import '../../library/show_matcher.dart';
import '../../schedule/anilist.dart' show AniListEntry;
import '../../shell/avatar.dart' show Pill;
import 'file_tiles.dart';
import 'thumb.dart';
// The Files library: shows and movies as poster cards (like the Jellyfin
// browser's), a show's own page (banner, the next episode with what is
// missing on the way, seasons, extras, movies) and the corrections
// ("Wrong show?", "Episode numbers…"). Names and art come from the
// library's AniList match when there is one, else from the files.
/// What the page hands its views: playing, the right-click menu, the
/// thumbnails, what plays now.
class FilesActions {
final void Function(String path) play;
final PlayState Function(String path) stateOf;
final Widget Function(String path, Widget child) menuable;
final ThumbCache thumbs;
const FilesActions({required this.play, required this.stateOf, required this.menuable, required this.thumbs});
}
/// "66–107 aren't in your folders", "66 isn't in your folders".
String missingText(EpisodeGap gap) =>
gap.first == gap.last ? '${gap.label} isn\'t in your folders' : '${gap.label} aren\'t in your folders';
/// The next-episode prompt: "66–107 aren't in your folders — skip to
/// 108?", or at the start "1–55 … — start at 56?"; null when nothing is
/// skipped.
String? skipPrompt(LocalNext next, {required bool started}) {
final gap = next.skipped;
if (gap == null) return null;
final n = next.episode.episode;
return started ? '${missingText(gap)} — skip to $n?' : '${missingText(gap)} — start at $n?';
}
/// A show's label for an episode: `56`, `S3 E1`.
String episodeNumber(LocalEpisode e) =>
e.season == null ? '${e.episode}' : 'S${e.season} E${e.episode}';
/// The 2:3 picture of a show or movie: its AniList cover, else a frame of
/// [framePath].
class PosterImage extends StatelessWidget {
final String? url;
final String? framePath;
final ThumbCache thumbs;
const PosterImage({super.key, this.url, this.framePath, required this.thumbs});
@override
Widget build(BuildContext context) {
final frame = framePath == null
? const ThumbImage(null)
: ThumbBuilder(cache: thumbs, path: framePath!, builder: (context, t, _) => ThumbImage(t));
final u = url;
if (u == null) return frame;
return Image.network(
u,
fit: BoxFit.cover,
gaplessPlayback: true,
errorBuilder: (_, _, _) => frame,
loadingBuilder: (context, child, progress) => progress == null ? child : frame,
);
}
}
/// A show (or movie) as a poster card: picture, title, one line under it.
class PosterCard extends StatefulWidget {
final String title;
final String subtitle;
final String? coverUrl;
final String? framePath;
final ThumbCache thumbs;
final VoidCallback onTap;
final bool playing;
/// Called once when the card is first built (the library matches what
/// is on screen).
final VoidCallback? onShown;
const PosterCard({
super.key,
required this.title,
required this.subtitle,
required this.thumbs,
required this.onTap,
this.coverUrl,
this.framePath,
this.playing = false,
this.onShown,
});
@override
State<PosterCard> createState() => _PosterCardState();
}
class _PosterCardState extends State<PosterCard> {
bool _hover = false;
@override
void initState() {
super.initState();
widget.onShown?.call();
}
@override
Widget build(BuildContext context) {
final scheme = Theme.of(context).colorScheme;
final t = Theme.of(context).textTheme;
return InkWell(
onTap: widget.onTap,
onHover: (h) => setState(() => _hover = h),
borderRadius: BorderRadius.circular(12),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Expanded(
child: AnimatedContainer(
duration: const Duration(milliseconds: 150),
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(12),
border: Border.all(
color: widget.playing ? scheme.primary : (_hover ? scheme.onSurfaceVariant : Colors.transparent),
width: 2,
),
),
child: ClipRRect(
borderRadius: BorderRadius.circular(10),
child: PosterImage(url: widget.coverUrl, framePath: widget.framePath, thumbs: widget.thumbs),
),
),
),
const SizedBox(height: 6),
Text(widget.title,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: t.titleSmall?.copyWith(color: widget.playing ? scheme.primary : null)),
Text(widget.subtitle,
maxLines: 1, overflow: TextOverflow.ellipsis, style: t.bodySmall?.copyWith(color: Colors.grey)),
],
),
);
}
}
/// Title and meta lines under a [PosterCard]'s picture.
const posterTextHeight = 46.0;
/// Poster cards in a grid: about 170 px wide each.
SliverGridDelegate posterGrid() => const _PosterGrid();
class _PosterGrid extends SliverGridDelegate {
const _PosterGrid();
@override
SliverGridLayout getLayout(SliverConstraints constraints) {
const gap = 12.0;
final w = constraints.crossAxisExtent;
final cols = (w / 170).floor().clamp(2, 12);
final cardW = (w - gap * (cols - 1)) / cols;
return SliverGridRegularTileLayout(
crossAxisCount: cols,
mainAxisStride: cardW * 1.5 + posterTextHeight + gap,
crossAxisStride: cardW + gap,
childMainAxisExtent: cardW * 1.5 + posterTextHeight,
childCrossAxisExtent: cardW,
reverseCrossAxis: false,
);
}
@override
bool shouldRelayout(covariant SliverGridDelegate oldDelegate) => false;
}
/// One show's page: header (banner, proper name, what the files are,
/// the match), the next episode with what is missing on the way, the
/// season's episodes with their holes, extras and the show's movies.
class ShowPage extends StatefulWidget {
final LocalShow show;
final ShowMatcher matcher;
final FilesActions actions;
/// The last episode of this show played here ([LocalEpisode.key]).
final String? lastPlayed;
final String backLabel;
final VoidCallback onBack;
/// Notices above the page (the relay notice, errors).
final List<Widget> top;
const ShowPage({
super.key,
required this.show,
required this.matcher,
required this.actions,
required this.lastPlayed,
required this.backLabel,
required this.onBack,
this.top = const [],
});
@override
State<ShowPage> createState() => _ShowPageState();
}
class _ShowPageState extends State<ShowPage> {
int? _season;
bool _seasonPicked = false;
@override
void initState() {
super.initState();
widget.matcher.want(widget.show);
}
LocalShow get show => widget.show;
@override
Widget build(BuildContext context) => SignalBuilder(builder: (context) {
widget.matcher.revision.value;
final busy = widget.matcher.busy.value.contains(show.key);
final m = widget.matcher.showMatch(show);
final next = show.nextAfter(widget.lastPlayed);
final seasons = show.seasons;
if (!_seasonPicked) _season = next?.episode.season ?? seasons.firstOrNull?.number;
final season = show.season(_season) ?? seasons.firstOrNull;
return CustomScrollView(
key: PageStorageKey('show:${show.key}'),
slivers: [
SliverPadding(
padding: const EdgeInsets.fromLTRB(12, 12, 20, 24),
sliver: SliverMainAxisGroup(slivers: [
SliverToBoxAdapter(
child: Align(
alignment: Alignment.centerLeft,
child: TextButton.icon(
onPressed: widget.onBack,
icon: const Icon(Icons.arrow_back, size: 18),
label: Text(widget.backLabel),
),
),
),
SliverList.list(children: widget.top),
SliverToBoxAdapter(child: _header(context, m, next, busy)),
if (seasons.length > 1)
SliverToBoxAdapter(
child: Padding(
padding: const EdgeInsets.only(top: 14),
child: Wrap(spacing: 8, runSpacing: 8, children: [
for (final s in seasons)
ChoiceChip(
key: ValueKey('season:${s.number}'),
label: Text(s.title),
selected: identical(s, season),
onSelected: (_) => setState(() {
_season = s.number;
_seasonPicked = true;
}),
),
]),
),
),
if (season != null) ..._episodes(context, season),
if (show.extras.isNotEmpty) ...[
_section(context, 'Extras', show.extras.length),
SliverList.list(children: [for (final e in show.extras) _row(e, e.label, e.name)]),
],
if (show.movies.isNotEmpty) ...[
_section(context, show.movies.length == 1 ? 'Movie' : 'Movies', show.movies.length),
SliverList.list(children: [for (final e in show.movies) _row(e, e.label, e.name)]),
],
]),
),
],
);
});
Widget _section(BuildContext context, String title, int n) => SliverToBoxAdapter(
child: Padding(
padding: const EdgeInsets.only(top: 22, bottom: 6),
child: Row(children: [
Text(title, style: Theme.of(context).textTheme.titleMedium),
const SizedBox(width: 8),
Text('$n', style: Theme.of(context).textTheme.bodySmall?.copyWith(color: Colors.grey)),
]),
),
);
Widget _row(LocalEpisode e, String title, String subtitle) => widget.actions.menuable(
e.path,
FileRow(
key: ValueKey(e.path),
path: e.path,
title: title,
subtitle: subtitle,
size: formatSize(e.file.size),
state: widget.actions.stateOf(e.path),
thumbs: widget.actions.thumbs,
onTap: () => widget.actions.play(e.path),
));
List<Widget> _episodes(BuildContext context, LocalSeason season) {
final rows = <Widget>[];
var expect = 1;
for (final e in season.episodes) {
final n = e.episode!;
final gap = season.gaps.where((g) => g.first >= expect && g.last < n).firstOrNull;
if (gap != null) rows.add(_GapRow(missingText(gap)));
if (n >= expect) expect = n + 1;
final offset = widget.matcher.offset(show.key, season.number);
final shown = n + offset;
final name = widget.matcher.episodeTitle(e);
// Like the Jellyfin browser's rows: "12. Name", else "Episode 12".
final title = name == null ? 'Episode $shown' : '$shown. $name';
rows.add(_row(e, title, e.name));
}
final tail = season.gaps.where((g) => g.first >= expect).firstOrNull;
if (tail != null) rows.add(_GapRow(missingText(tail)));
return [
_section(context, season.title, season.distinct.length),
SliverList.list(children: rows),
];
}
Widget _header(BuildContext context, SeasonMatch? m, LocalNext? next, bool busy) {
final theme = Theme.of(context);
final t = theme.textTheme;
final scheme = theme.colorScheme;
final entry = m?.entry;
final title = entry?.title ?? show.title;
final count = show.episodeCount;
final meta = [
if (show.seasons.length > 1) '${show.seasons.length} seasons',
'$count ${count == 1 ? 'episode' : 'episodes'}',
if (show.extras.isNotEmpty) '${show.extras.length} ${show.extras.length == 1 ? 'extra' : 'extras'}',
if (show.movies.isNotEmpty) '${show.movies.length} ${show.movies.length == 1 ? 'movie' : 'movies'}',
?show.group,
if (show.folders.length > 1) 'in ${show.folders.length} folders',
].join(' · ');
final String matchLine;
if (entry != null) {
final what = [
if (entry.format.isNotEmpty) entry.format.replaceAll('_', ' '),
if (entry.year != null) '${entry.year}',
if (entry.episodes != null) '${entry.episodes} eps',
].join(', ');
matchLine = [
'AniList: ${entry.romaji.isNotEmpty && entry.romaji != title ? entry.romaji : entry.title}'
'${what.isEmpty ? '' : ' ($what)'}',
if (title != show.title) 'files say "${show.title}"',
].join(' · ');
} else {
matchLine = busy ? 'Looking it up on AniList…' : 'Not matched on AniList';
}
final started = widget.lastPlayed != null;
final prompt = next == null ? null : skipPrompt(next, started: started);
final nextLabel = next == null
? 'Watched to the end'
: next.skipped != null
? (started ? 'Skip to ${next.episode.episode}' : 'Start at ${next.episode.episode}')
: '${started ? 'Next' : 'Play'}: ${episodeNumber(next.episode)}';
return LayoutBuilder(builder: (context, box) {
final h = (box.maxWidth * 0.28).clamp(220.0, 340.0);
final cover = entry?.cover;
return ClipRRect(
borderRadius: BorderRadius.circular(12),
child: SizedBox(
height: h,
child: Stack(fit: StackFit.expand, children: [
PosterImage(
url: entry?.banner ?? entry?.cover,
framePath: show.episodes.firstOrNull?.path,
thumbs: widget.actions.thumbs),
DecoratedBox(
decoration: BoxDecoration(
gradient: LinearGradient(
begin: Alignment.centerLeft,
end: Alignment.centerRight,
colors: [
scheme.surface.withValues(alpha: 0.96),
scheme.surface.withValues(alpha: 0.8),
scheme.surface.withValues(alpha: 0.15),
],
stops: const [0, 0.5, 1],
),
),
),
Padding(
padding: const EdgeInsets.all(20),
child: Row(crossAxisAlignment: CrossAxisAlignment.start, children: [
if (cover != null && box.maxWidth > 560) ...[
ClipRRect(
borderRadius: BorderRadius.circular(8),
child: SizedBox(
width: (h - 40) / 1.5,
child: PosterImage(url: cover, thumbs: widget.actions.thumbs),
),
),
const SizedBox(width: 18),
],
Expanded(
child: SingleChildScrollView(
child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
Row(children: [
Flexible(
child: Text(title,
maxLines: 2, overflow: TextOverflow.ellipsis, style: t.headlineMedium),
),
if (m?.pinned != null) ...[
const SizedBox(width: 8),
Pill(m!.pinned == PinScope.folder ? 'pinned for this folder' : 'pinned', scheme.primary),
],
]),
const SizedBox(height: 6),
Text(meta, style: t.bodyMedium?.copyWith(color: Colors.grey)),
const SizedBox(height: 2),
Text(matchLine,
maxLines: 2,
overflow: TextOverflow.ellipsis,
style: t.bodySmall?.copyWith(color: scheme.onSurfaceVariant)),
if (prompt != null) ...[
const SizedBox(height: 10),
Row(children: [
Icon(Icons.info_outline, size: 16, color: scheme.tertiary),
const SizedBox(width: 6),
Flexible(child: Text(prompt, style: t.bodyMedium?.copyWith(color: scheme.tertiary))),
]),
],
const SizedBox(height: 12),
Wrap(spacing: 8, runSpacing: 8, children: [
FilledButton.icon(
key: const ValueKey('show-next'),
onPressed: next == null ? null : () => widget.actions.play(next.episode.path),
icon: const Icon(Icons.play_arrow),
label: Text(nextLabel),
),
OutlinedButton.icon(
onPressed: () => showWrongShowDialog(context, show, widget.matcher),
icon: const Icon(Icons.manage_search, size: 18),
label: const Text('Wrong show?'),
),
TextButton(
onPressed: () => showEpisodeNumbersDialog(context, show, widget.matcher),
child: const Text('Episode numbers…'),
),
]),
]),
),
),
]),
),
]),
),
);
});
}
}
class _GapRow extends StatelessWidget {
final String text;
const _GapRow(this.text);
@override
Widget build(BuildContext context) {
final scheme = Theme.of(context).colorScheme;
return Padding(
padding: const EdgeInsets.fromLTRB(20, 6, 8, 6),
child: Row(children: [
Icon(Icons.more_vert, size: 18, color: scheme.onSurfaceVariant),
const SizedBox(width: 10),
Expanded(
child: Text(text,
maxLines: 2,
overflow: TextOverflow.ellipsis,
style: TextStyle(color: scheme.onSurfaceVariant, fontStyle: FontStyle.italic, fontSize: 13)),
),
]),
);
}
}
/// "Wrong show?": search AniList, pick the right entry, pin it for the
/// show everywhere or for one of its folders.
Future<void> showWrongShowDialog(BuildContext context, LocalShow show, ShowMatcher matcher) =>
showDialog<void>(context: context, builder: (context) => WrongShowDialog(show: show, matcher: matcher));
class WrongShowDialog extends StatefulWidget {
final LocalShow show;
final ShowMatcher matcher;
const WrongShowDialog({super.key, required this.show, required this.matcher});
@override
State<WrongShowDialog> createState() => _WrongShowDialogState();
}
class _WrongShowDialogState extends State<WrongShowDialog> {
late final _query = TextEditingController(text: widget.show.title.replaceAll(RegExp(r'[._]+'), ' '));
List<AniListEntry>? _hits;
String? _error;
bool _searching = false;
/// Null: the whole show; else one of its folders.
String? _folder;
@override
void initState() {
super.initState();
_search();
}
@override
void dispose() {
_query.dispose();
super.dispose();
}
Future<void> _search() async {
setState(() {
_searching = true;
_error = null;
});
try {
final hits = await widget.matcher.search(_query.text);
if (mounted) setState(() => _hits = hits);
} catch (e) {
if (mounted) setState(() => _error = 'AniList did not answer ($e)');
} finally {
if (mounted) setState(() => _searching = false);
}
}
void _pin(AniListEntry e) {
final folder = _folder;
folder == null ? widget.matcher.pinShow(widget.show.key, e) : widget.matcher.pinFolder(folder, e);
Navigator.of(context).pop();
}
@override
Widget build(BuildContext context) {
final show = widget.show;
final m = widget.matcher;
final showPin = m.showPin(show.key);
final folderPins = [for (final f in show.folders) if (m.folderPin(f)?.folder == f) f];
final scheme = Theme.of(context).colorScheme;
return AlertDialog(
title: const Text('Wrong show?'),
content: SizedBox(
width: 520,
child: Column(mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [
Text('Search AniList and pick what "${show.title}" really is. '
'A pin wins over the automatic match and survives rescans.',
style: TextStyle(color: scheme.onSurfaceVariant)),
const SizedBox(height: 12),
TextField(
controller: _query,
autofocus: true,
decoration: InputDecoration(
isDense: true,
border: const OutlineInputBorder(),
hintText: 'Title on AniList',
suffixIcon: IconButton(tooltip: 'Search', icon: const Icon(Icons.search), onPressed: _search),
),
onSubmitted: (_) => _search(),
),
if (show.folders.length > 1 || folderPins.isNotEmpty) ...[
const SizedBox(height: 10),
DropdownButton<String?>(
value: _folder,
isExpanded: true,
items: [
const DropdownMenuItem(value: null, child: Text('For this show everywhere')),
for (final f in show.folders)
DropdownMenuItem(value: f, child: Text('Only the folder "${baseName(f)}"', overflow: TextOverflow.ellipsis)),
],
onChanged: (v) => setState(() => _folder = v),
),
],
const SizedBox(height: 8),
ConstrainedBox(
constraints: const BoxConstraints(maxHeight: 340),
child: _searching && _hits == null
? const Padding(padding: EdgeInsets.all(24), child: Center(child: CircularProgressIndicator()))
: _error != null
? Text(_error!, style: TextStyle(color: scheme.error))
: (_hits ?? const []).isEmpty
? const Padding(padding: EdgeInsets.all(12), child: Text('Nothing found'))
: ListView(shrinkWrap: true, children: [
for (final e in _hits!)
ListTile(
key: ValueKey('hit:${e.id}'),
dense: true,
leading: SizedBox(
width: 32,
height: 48,
child: ClipRRect(
borderRadius: BorderRadius.circular(4),
child: e.coverSmall == null
? const ColoredBox(color: Colors.black26)
: Image.network(e.coverSmall!,
fit: BoxFit.cover,
errorBuilder: (_, _, _) => const ColoredBox(color: Colors.black26)),
),
),
title: Text(e.title, maxLines: 1, overflow: TextOverflow.ellipsis),
subtitle: Text([
if (e.romaji.isNotEmpty && e.romaji != e.title) e.romaji,
if (e.format.isNotEmpty) e.format.replaceAll('_', ' '),
if (e.year != null) '${e.year}',
if (e.episodes != null) '${e.episodes} eps',
].join(' · '), maxLines: 1, overflow: TextOverflow.ellipsis),
onTap: () => _pin(e),
),
]),
),
]),
),
actions: [
if (showPin != null)
TextButton(
onPressed: () {
m.unpinShow(show.key);
Navigator.of(context).pop();
},
child: const Text('Remove the pin'),
),
for (final f in folderPins)
TextButton(
onPressed: () {
m.unpinFolder(f);
Navigator.of(context).pop();
},
child: Text('Unpin "${baseName(f)}"'),
),
TextButton(onPressed: () => Navigator.of(context).pop(), child: const Text('Cancel')),
],
);
}
}
/// "Episode numbers…": which episode the first file of each season is
/// ("these files are episodes N..M").
Future<void> showEpisodeNumbersDialog(BuildContext context, LocalShow show, ShowMatcher matcher) =>
showDialog<void>(context: context, builder: (context) => _EpisodeNumbersDialog(show: show, matcher: matcher));
class _EpisodeNumbersDialog extends StatefulWidget {
final LocalShow show;
final ShowMatcher matcher;
const _EpisodeNumbersDialog({required this.show, required this.matcher});
@override
State<_EpisodeNumbersDialog> createState() => _EpisodeNumbersDialogState();
}
class _EpisodeNumbersDialogState extends State<_EpisodeNumbersDialog> {
late final _fields = {
for (final s in widget.show.seasons)
s.number: TextEditingController(
text: '${s.distinct.first.episode! + widget.matcher.offset(widget.show.key, s.number)}'),
};
@override
void dispose() {
for (final c in _fields.values) {
c.dispose();
}
super.dispose();
}
void _save() {
for (final s in widget.show.seasons) {
final first = s.distinct.first.episode!;
final want = int.tryParse(_fields[s.number]!.text.trim());
if (want != null && want > 0) widget.matcher.setOffset(widget.show.key, s.number, want - first);
}
Navigator.of(context).pop();
}
@override
Widget build(BuildContext context) => AlertDialog(
title: const Text('Episode numbers'),
content: SizedBox(
width: 420,
child: Column(mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [
const Text('When the files count differently from AniList, say which episode the first file is; '
'the others follow.'),
const SizedBox(height: 12),
for (final s in widget.show.seasons)
Padding(
padding: const EdgeInsets.only(bottom: 8),
child: Row(children: [
Expanded(
child: Text('${s.title}: the file numbered ${s.distinct.first.episode} is episode',
maxLines: 2),
),
const SizedBox(width: 8),
SizedBox(
width: 72,
child: TextField(
key: ValueKey('offset:${s.number}'),
controller: _fields[s.number],
keyboardType: TextInputType.number,
decoration: const InputDecoration(isDense: true, border: OutlineInputBorder()),
),
),
]),
),
]),
),
actions: [
TextButton(onPressed: () => Navigator.of(context).pop(), child: const Text('Cancel')),
FilledButton(onPressed: _save, child: const Text('Save')),
],
);
}
@@ -1,5 +1,6 @@
import 'package:sync_relay_core/core.dart';
import '../library/media_refs.dart';
import '../screens/home/picked_media.dart';
import 'accounts.dart';
@@ -89,5 +90,6 @@ JellyfinMedia pickFor(JellyfinAccount account, JellyfinItem item) {
title: item.name,
subtitle: subtitle,
runtime: item.runTime,
ref: jellyfinMediaRef(item),
);
}
@@ -7,6 +7,7 @@ import '../screens/home/picked_media.dart';
import '../session/party.dart' show isPresent;
import 'accounts.dart';
import 'format.dart';
import '../library/media_refs.dart';
// Jellyfin media in a session: turning picks and pasted links into refs
// (the wire carries only `{serverId, itemId, ...}`), the hosted server's
@@ -28,7 +29,8 @@ HelloMedia mediaForPick(JellyfinMedia m) => HelloMedia(
name: m.title.isEmpty ? 'Jellyfin item' : m.title,
size: 0,
duration: 0,
jellyfin: refForPick(m));
jellyfin: refForPick(m),
ref: m.ref ?? jellyfinPickRef(m.title, m.subtitle));
/// The pick a ref stands for on a linked server (for highlighting it in
/// the browser); null when this install has no account there.
@@ -78,7 +80,8 @@ final _sourceId = RegExp(r'^[A-Za-z0-9-]{1,64}$');
/// is one.
Future<HelloMedia> mediaForLink(JellyfinAccounts accounts, String url,
{Duration timeout = const Duration(seconds: 6)}) async {
final plain = HelloMedia(name: linkName(url), size: 0, duration: 0, url: url);
final name = linkName(url);
final plain = HelloMedia(name: name, size: 0, duration: 0, url: url, ref: linkMediaRef(name));
final link = parseJellyfinLink(url);
if (link == null) return plain;
await accounts.ensureLoaded();
@@ -103,9 +106,11 @@ Future<HelloMedia> mediaForLink(JellyfinAccounts accounts, String url,
);
final account = accounts.account(serverId);
final client = accounts.clientFor(serverId);
MediaRef? what;
if (account != null && client != null) {
try {
final pick = pickFor(account, await client.item(link.itemId).timeout(timeout));
what = pick.ref;
ref = refForPick(JellyfinMedia(
serverId: serverId,
baseUrl: account.baseUrl,
@@ -120,7 +125,11 @@ Future<HelloMedia> mediaForLink(JellyfinAccounts accounts, String url,
}
}
return HelloMedia(
name: ref.title.isEmpty ? 'Jellyfin item' : ref.title, size: 0, duration: 0, jellyfin: ref);
name: ref.title.isEmpty ? 'Jellyfin item' : ref.title,
size: 0,
duration: 0,
jellyfin: ref,
ref: what ?? jellyfinPickRef(ref.title, ref.subtitle ?? ''));
}
/// Display name for a link: last path segment, decoded, never the query
@@ -139,27 +148,28 @@ String linkName(String url) {
return uri.host.isNotEmpty ? uri.host : 'link';
}
/// The hosted server's relay login: the account for [serverId] (the
/// hosted item's server), else the default account, when its token is not
/// known to be expired. The device fields are exactly the ones this app
/// sends with that token ([JellyfinAccounts.clientInfo]): Jellyfin rewrites
/// a token's device from the request header, so the relay must not differ.
JellyfinCredential? relayCredentialFor(JellyfinAccounts accounts, {String? serverId}) {
bool usable(JellyfinAccount? a) =>
a != null && accounts.statusOf(a.serverId) != JellyfinAccountStatus.expired;
var a = serverId == null ? null : accounts.account(serverId);
if (!usable(a)) a = accounts.defaultAccount;
if (!usable(a)) return null;
/// The hosted server's relay logins: one per linked Jellyfin account
/// whose token is not known to be expired (the default account first),
/// so `/j` relays an item with its own server's login. The device fields
/// are exactly the ones this app sends with those tokens
/// ([JellyfinAccounts.clientInfo]): Jellyfin rewrites a token's device
/// from the request header, so the relay must not differ.
List<JellyfinCredential> relayCredentialsFor(JellyfinAccounts accounts) {
final info = accounts.clientInfo;
return JellyfinCredential(
baseUrl: a!.baseUrl,
serverId: a.serverId,
token: a.token,
client: info.client,
device: info.device,
deviceId: info.deviceId,
version: info.version,
);
final first = accounts.defaultAccount;
return [
for (final a in [?first, ...accounts.accounts.where((a) => a.serverId != first?.serverId)])
if (accounts.statusOf(a.serverId) != JellyfinAccountStatus.expired)
JellyfinCredential(
baseUrl: a.baseUrl,
serverId: a.serverId,
token: a.token,
client: info.client,
device: info.device,
deviceId: info.deviceId,
version: info.version,
),
];
}
/// `/j/...` URL of the current Jellyfin media on the host this client
@@ -0,0 +1,81 @@
import 'package:flutter/foundation.dart';
import 'package:hive_ce/hive.dart';
// Where the library's matches live (device-owned, one Hive box): AniList
// entries seen, automatic matches, the user's pins and episode offsets.
// Plain JSON-ish values under string keys; reads are synchronous once
// loaded, writes go through.
const libraryBoxName = 'library';
abstract interface class LibraryStore {
/// Loads the stored values (once).
Future<void> ensureLoaded();
Object? get(String key);
void put(String key, Object value);
void delete(String key);
/// Every key starting with [prefix].
Iterable<String> keys(String prefix);
}
/// For tests and as the fallback when Hive is not available.
class MemoryLibraryStore implements LibraryStore {
final Map<String, Object> values;
MemoryLibraryStore([Map<String, Object>? values]) : values = values ?? {};
@override
Future<void> ensureLoaded() async {}
@override
Object? get(String key) => values[key];
@override
void put(String key, Object value) => values[key] = value;
@override
void delete(String key) => values.remove(key);
@override
Iterable<String> keys(String prefix) => values.keys.where((k) => k.startsWith(prefix)).toList();
}
/// The `library` box; until it opens (or when it cannot) it acts as an
/// empty memory store, so matching still works for the run.
class HiveLibraryStore implements LibraryStore {
Box? _box;
final _memory = MemoryLibraryStore();
Future<void>? _load;
@override
Future<void> ensureLoaded() => _load ??= () async {
try {
final box = _box = await Hive.openBox(libraryBoxName);
// Writes made before the box opened.
for (final e in _memory.values.entries) {
await box.put(e.key, e.value);
}
_memory.values.clear();
} catch (e, st) {
debugPrint('open library store failed: $e\n$st');
}
}();
@override
Object? get(String key) => _box?.get(key) ?? _memory.get(key);
@override
void put(String key, Object value) {
final box = _box;
if (box == null) return _memory.put(key, value);
box.put(key, value).catchError((Object e) => debugPrint('library store put $key: $e'));
}
@override
void delete(String key) {
_memory.delete(key);
_box?.delete(key).catchError((Object e) => debugPrint('library store delete $key: $e'));
}
@override
Iterable<String> keys(String prefix) => [
...?_box?.keys.whereType<String>().where((k) => k.startsWith(prefix)),
..._memory.keys(prefix),
];
}
@@ -0,0 +1,80 @@
import 'package:sync_relay_core/core.dart';
// `media.ref` (PROTOCOL.md "Media identity") for each source: what a media
// is, independent of where its bytes come from. Local files get theirs
// from the library (`ShowMatcher.refFor`, L1 + L2); these are the other
// two: Jellyfin items by their provider ids, links best effort from their
// name.
/// A Jellyfin episode, movie or video: kind, series (or movie) title,
/// season and episode as Jellyfin numbers them, and the AniList / TVDB ids
/// its metadata plugins set ([series]' when given: an episode's own ids
/// are the episode's, not the show's). Null for anything that is not
/// played (series, seasons, folders).
MediaRef? jellyfinMediaRef(JellyfinItem item, {JellyfinItem? series}) {
final kind = switch (item.kind) {
JellyfinItemKind.episode => 'episode',
JellyfinItemKind.movie || JellyfinItemKind.video => 'movie',
_ => null,
};
if (kind == null) return null;
final episode = kind == 'episode';
// An episode's Tvdb id names the episode; the show's comes from the
// series item. AniList ids are only ever set on shows and movies.
final owner = episode ? series : item;
final special = episode && item.parentIndexNumber == 0;
return MediaRef(
kind: special ? 'extra' : kind,
title: episode ? (item.seriesName ?? series?.name ?? item.name) : item.name,
anilistId: owner?.providerId('AniList'),
tvdbId: owner?.providerId('Tvdb'),
season: episode && !special ? item.parentIndexNumber : null,
ep: episode && !special ? item.indexNumber : null,
);
}
final _sxe = RegExp(r'S(\d{1,3})\s?E(\d{1,4})$', caseSensitive: false);
/// A picked Jellyfin item from its card's lines alone ([title], and
/// [subtitle] "Series · S1E6" for episodes, the year for movies): kind,
/// title and numbers, no ids.
MediaRef jellyfinPickRef(String title, String subtitle) {
final sub = subtitle.trim();
final cut = sub.lastIndexOf(' · ');
final label = cut < 0 ? sub : sub.substring(cut + 3).trim();
final m = _sxe.firstMatch(label);
if (m == null) return MediaRef(kind: 'movie', title: title);
final season = int.parse(m.group(1)!);
return MediaRef(
kind: season == 0 ? 'extra' : 'episode',
title: cut < 0 ? title : sub.substring(0, cut).trim(),
season: season == 0 ? null : season,
ep: season == 0 ? null : int.parse(m.group(2)!),
);
}
/// A link, best effort: its name read like a release name (`Show - 05`
/// is an episode, `OP1` an extra); anything else a movie of that title.
MediaRef linkMediaRef(String name) {
final p = parseReleaseName(name);
final title = p.series.trim().isEmpty ? name.trim() : p.series.trim();
return switch (p.kind) {
ReleaseKind.episode => MediaRef(
kind: 'episode',
title: title,
season: p.season,
ep: p.season == null ? null : p.episode,
absolute: p.season == null ? p.episode : null),
ReleaseKind.extra => MediaRef(kind: 'extra', title: title, season: p.season),
ReleaseKind.movie || ReleaseKind.other => MediaRef(kind: 'movie', title: title),
};
}
/// The episode line a ref gives (`S3 E13`, `E108`), or null (movies;
/// extras keep the name's own marker).
String? refEpisodeLine(MediaRef ref) {
if (ref.kind != 'episode') return null;
if (ref.season != null && ref.ep != null) return 'S${ref.season} E${ref.ep}';
final n = ref.absolute ?? ref.ep ?? ref.anilistEp;
return n == null ? null : 'E$n';
}
@@ -0,0 +1,656 @@
import 'dart:async';
import 'package:flutter/foundation.dart' show debugPrint, visibleForTesting;
import 'package:signals/signals.dart';
import 'package:sync_relay_core/core.dart';
import '../schedule/anilist.dart';
import '../schedule/animeflow.dart';
import 'library_store.dart';
// L2 of the library (ARCHITECTURE.md "Media identity"): local shows (L1)
// matched to AniList, device-owned and cached. A season of a show is
// searched once (title and synonyms, the season's number or name as
// hints) and the answer kept by show key, season and season name; its
// episodes map onto AniList entries along the run (a season AniList
// splits into cours continues on the SEQUEL entry), or onto AnimeFlow's
// `anilist_map` when signed in. The user's pins (a show everywhere, or one
// folder) and episode offsets override everything and survive rescans.
// Requests are lazy (only for shows on screen), one at a time and spaced
// out for AniList's rate limit; everything answers offline from the cache.
/// Where AnimeFlow places an AniList entry: the TVDB season runs of its
/// series ([AniListRun]) and the series' TVDB id.
class FlowShow {
final List<AniListRun> runs;
final int? tvdbId;
const FlowShow(this.runs, {this.tvdbId});
}
/// AnimeFlow's knowledge of a show, when signed in.
abstract interface class FlowLookup {
/// The series [anilistId] belongs to; null when AnimeFlow has none (or
/// cannot be asked).
Future<FlowShow?> forAniList(int anilistId);
}
/// [FlowLookup] over the AnimeFlow app API (`/library`, `/series`), read
/// once per app run.
class AnimeFlowLookup implements FlowLookup {
final AnimeFlowClient client;
AnimeFlowLookup(this.client);
Future<(List<AnimeFlowLibraryItem>, List<AnimeFlowSeries>)>? _load;
Future<(List<AnimeFlowLibraryItem>, List<AnimeFlowSeries>)> _fetch() async =>
(await client.library(), await client.series());
@override
Future<FlowShow?> forAniList(int anilistId) async {
final load = _load ??= _fetch();
final (List<AnimeFlowLibraryItem>, List<AnimeFlowSeries>) data;
try {
data = await load;
} catch (e) {
_load = null;
return null;
}
final (library, series) = data;
for (final item in library) {
final has = item.anilistMap.any((r) => r.anilistId == anilistId) || item.anilistIds.values.contains(anilistId);
if (!has) continue;
final s = series
.where((s) =>
(item.seriesId != null && s.seriesId == item.seriesId) ||
(s.jellyfinSeriesId != null && s.jellyfinSeriesId == item.jellyfinSeriesId))
.firstOrNull;
return FlowShow(item.anilistMap, tvdbId: s?.tvdbId);
}
return null;
}
}
/// What a pin covers.
enum PinScope { show, folder }
/// The AniList entry a season (or a movie, or an extra's show) is.
class SeasonMatch {
final AniListEntry entry;
/// Set by the user ("Wrong show?"), not by the search.
final PinScope? pinned;
const SeasonMatch(this.entry, {this.pinned});
}
/// An episode on AniList.
class EpisodeTarget {
final int anilistId;
final int? anilistEp;
const EpisodeTarget(this.anilistId, this.anilistEp);
@override
bool operator ==(Object other) => other is EpisodeTarget && other.anilistId == anilistId && other.anilistEp == anilistEp;
@override
int get hashCode => Object.hash(anilistId, anilistEp);
@override
String toString() => 'EpisodeTarget($anilistId, $anilistEp)';
}
/// The library's L2: see the file comment. Made by the composition root.
class ShowMatcher {
ShowMatcher({
required this.store,
required this.aniList,
this.flow,
this.spacing = const Duration(milliseconds: 800),
this.retryNoneAfter = const Duration(days: 3),
DateTime Function()? now,
Future<void> Function(Duration)? delay,
}) : _now = now ?? DateTime.now,
_delay = delay ?? Future<void>.delayed;
final LibraryStore store;
final AniListLookup aniList;
final FlowLookup? flow;
/// Between two AniList requests.
final Duration spacing;
/// A search that found nothing is asked again after this.
final Duration retryNoneAfter;
final DateTime Function() _now;
final Future<void> Function(Duration) _delay;
/// Bumps whenever something new is known (a match, an entry, a pin).
final revision = signal(0);
/// Shows being matched right now, by show key.
final busy = signal<Set<String>>(const {});
// Files seen in scans this run, for refs of files put up.
final _byPath = <String, LocalEpisode>{};
final _entries = <int, AniListEntry>{};
final _flows = <int, FlowShow?>{};
final _jobs = <String, Future<void> Function()>{};
final _failed = <String, DateTime>{};
bool _pumping = false;
DateTime? _lastRequest;
DateTime? _pausedUntil;
/// Loads the cache (once); reads before it finishes see nothing.
Future<void> ensureLoaded() => store.ensureLoaded().then((_) => _bump());
// ------------------------------------------------------------- keys
static String matchKey(String showKey, int? season, String? seasonName) =>
'$showKey|${season ?? ''}|${normalizeSeries(seasonName ?? '')}';
static String _movieKey(LocalEpisode e) => '${e.showKey}|movie|${e.movieNumber ?? ''}';
String _keyOf(LocalEpisode e) =>
e.kind == LocalKind.movie ? _movieKey(e) : matchKey(e.showKey, e.season, e.seasonName);
// ------------------------------------------------------------- reads
/// Remembers [library]'s files, so [refForPath] knows their show.
void remember(LocalLibrary library) {
for (final e in library.files) {
_byPath[e.path] = e;
}
}
AniListEntry? entry(int id) {
final known = _entries[id];
if (known != null) return known;
final raw = store.get('entry:$id');
final e = raw is Map ? AniListEntry.fromJson(raw['e']) : null;
if (e != null) _entries[id] = e;
return e;
}
/// The user's pin for [showKey] (everywhere), if any.
int? showPin(String showKey) => _int(store.get('pin:show:$showKey'));
/// The pin of [folder] or the nearest folder above it, if any.
({String folder, int id})? folderPin(String folder) {
var f = folder;
while (f.isNotEmpty) {
final id = _int(store.get('pin:folder:$f'));
if (id != null) return (folder: f, id: id);
final cut = f.lastIndexOf(RegExp(r'[/\\]'));
if (cut <= 0) break;
f = f.substring(0, cut);
}
return null;
}
/// "These files are episodes N..": added to every file's number of
/// [showKey]'s season [season].
int offset(String showKey, int? season) => _int(store.get('offset:$showKey|${season ?? ''}')) ?? 0;
/// The entry [e]'s season (or movie, or the show of an extra) is: a
/// folder pin, else the show's pin, else the search's answer.
SeasonMatch? seasonMatch(LocalEpisode e) {
if (e.kind == LocalKind.other) return null;
final fp = folderPin(e.folder);
if (fp != null) {
final en = entry(fp.id);
return en == null ? null : SeasonMatch(en, pinned: PinScope.folder);
}
final sp = showPin(e.showKey);
if (sp != null) {
final en = entry(sp);
return en == null ? null : SeasonMatch(en, pinned: PinScope.show);
}
final auto = store.get('auto:${_keyOf(e)}');
final id = auto is Map ? _int(auto['id']) : null;
if (id == null) {
// An extra of no matched season: the show's first one.
if (e.kind == LocalKind.extra && e.season != null) {
return seasonMatch(LocalEpisode(
file: e.file, parsed: e.parsed, kind: LocalKind.extra, showKey: e.showKey, title: e.title, extra: e.extra));
}
return null;
}
final en = entry(id);
return en == null ? null : SeasonMatch(en);
}
/// What the show is as a whole: its first season's match (else a
/// movie's or an extra's).
SeasonMatch? showMatch(LocalShow show) {
for (final e in [
for (final s in show.seasons) s.episodes.first,
...show.movies,
...show.extras,
]) {
final m = seasonMatch(e);
if (m != null) return m;
}
return null;
}
/// The proper name of [show]: its match's title, else as found.
String titleOf(LocalShow show) => showMatch(show)?.entry.title ?? show.title;
/// The AniList entry and episode [e] is; for extras and movies only the
/// entry. Null before a match.
EpisodeTarget? target(LocalEpisode e) {
final m = seasonMatch(e);
if (m == null) return null;
if (e.kind != LocalKind.episode) return EpisodeTarget(m.entry.id, null);
final n = e.episode! + offset(e.showKey, e.season);
if (n < 1) return EpisodeTarget(m.entry.id, null);
final fs = _flowOf(m.entry.id);
if (fs != null && e.season != null) {
final runs = fs.runs.where((r) => r.season == e.season && r.firstEpisode <= n).toList()
..sort((a, b) => b.firstEpisode.compareTo(a.firstEpisode));
if (runs.isNotEmpty) return EpisodeTarget(runs.first.anilistId, n - runs.first.offset);
}
// Along the run: past an entry's episodes, its sequel's.
var cur = m.entry;
var k = n;
for (var hop = 0; hop < 30; hop++) {
final count = cur.episodes;
final next = cur.sequel;
if (count == null || k <= count || next == null) break;
k -= count;
final nx = entry(next);
if (nx == null) {
_wantEntry(next);
return EpisodeTarget(next, k);
}
cur = nx;
}
return EpisodeTarget(cur.id, k);
}
/// AniList's title of [e]'s episode, when it knows one.
String? episodeTitle(LocalEpisode e) {
final t = target(e);
final ep = t?.anilistEp;
if (t == null || ep == null) return null;
return entry(t.anilistId)?.episodeTitles[ep];
}
/// What [e] is on the wire (`media.ref`): the match when there is one,
/// else what L1 read (kind, title, numbers).
MediaRef refFor(LocalEpisode e) {
final m = seasonMatch(e);
final t = target(e);
final n = e.episode == null ? null : e.episode! + offset(e.showKey, e.season);
final tvdb = m == null ? null : _flowOf(m.entry.id)?.tvdbId;
return MediaRef(
kind: switch (e.kind) {
LocalKind.episode => 'episode',
LocalKind.extra => 'extra',
LocalKind.movie || LocalKind.other => 'movie',
},
title: m?.entry.title ?? e.title,
anilistId: t?.anilistId ?? m?.entry.id,
anilistEp: t?.anilistEp,
tvdbId: tvdb,
season: e.kind == LocalKind.episode ? e.season : null,
ep: e.kind == LocalKind.episode && e.season != null ? n : null,
absolute: e.kind == LocalKind.episode && e.season == null ? n : null,
);
}
/// [refFor] the file at [path]: as seen in a scan this run, else read
/// from its name and folder alone.
MediaRef refForPath(String path) =>
refFor(_byPath[path] ?? readLocalFile(LocalFile(path, 0, DateTime.fromMillisecondsSinceEpoch(0))));
/// The library's reading of [path] (null: not seen in a scan this run).
LocalEpisode? episodeAt(String path) => _byPath[path];
// ------------------------------------------------------------- matching
/// Matches [show]'s seasons and movies that have no answer yet, in the
/// background (see the file comment).
void want(LocalShow show) {
final seen = <String>{};
for (final e in [
for (final s in show.seasons) s.episodes.first,
...show.movies,
if (show.seasons.isEmpty) ...show.extras.take(1),
]) {
final key = _keyOf(e);
if (!seen.add(key)) continue;
final pinned = folderPin(e.folder)?.id ?? showPin(e.showKey);
if (pinned != null) {
if (entry(pinned) == null) _wantEntry(pinned);
continue;
}
_wantMatch(key, show, e);
}
final m = showMatch(show);
if (m != null) _wantFlow(m.entry.id);
}
/// [want] for each standalone movie of [library] and each of [shows].
void wantMovie(LocalEpisode movie) {
if (movie.kind != LocalKind.movie) return;
_wantMatch(_movieKey(movie), null, movie);
}
void _wantMatch(String key, LocalShow? show, LocalEpisode e) {
final auto = store.get('auto:$key');
if (auto is Map) {
final id = _int(auto['id']);
if (id != null) {
if (entry(id) == null) _wantEntry(id);
return;
}
final at = _int(auto['at']) ?? 0;
if (_now().difference(DateTime.fromMillisecondsSinceEpoch(at)) < retryNoneAfter) return;
}
if (_recentlyFailed(key)) return;
_enqueue('match:$key', () => _match(key, show?.title ?? e.title, e));
_setBusy(e.showKey, true);
}
void _wantEntry(int id) {
if (_recentlyFailed('entry:$id')) return;
_enqueue('entry:$id', () async {
final en = await _ask(() => aniList.anime(id));
if (en != null) _keepEntry(en);
});
}
void _wantFlow(int id) {
final f = flow;
if (f == null || _flows.containsKey(id)) return;
final stored = store.get('flow:$id');
if (stored is Map) {
_flows[id] = _flowFromJson(stored);
final at = _int(stored['at']) ?? 0;
if (_now().difference(DateTime.fromMillisecondsSinceEpoch(at)) < const Duration(days: 1)) return;
} else {
_flows[id] = null;
}
unawaited(f.forAniList(id).then((fs) {
if (fs == null) return;
_flows[id] = fs;
store.put('flow:$id', {
'at': _now().millisecondsSinceEpoch,
'tvdb': fs.tvdbId,
'runs': [
for (final r in fs.runs) [r.season, r.anilistId, r.firstEpisode, r.offset]
],
});
_bump();
}).catchError((Object e) => debugPrint('library: animeflow lookup $id: $e')));
}
FlowShow? _flowOf(int id) {
if (!_flows.containsKey(id)) {
final stored = store.get('flow:$id');
if (stored is Map) _flows[id] = _flowFromJson(stored);
}
return _flows[id];
}
static FlowShow? _flowFromJson(Map j) {
final runs = <AniListRun>[];
final raw = j['runs'];
if (raw is List) {
for (final r in raw) {
if (r is List && r.length == 4 && r.every((v) => v is int)) {
runs.add(AniListRun(
season: r[0] as int, anilistId: r[1] as int, firstEpisode: r[2] as int, offset: r[3] as int));
}
}
}
return runs.isEmpty ? null : FlowShow(runs, tvdbId: _int(j['tvdb']));
}
/// One search for the season [e] stands for; keeps the best entry (or
/// "none") under [key].
Future<void> _match(String key, String title, LocalEpisode e) async {
try {
final plain = title.replaceAll(RegExp(r'[._]+'), ' ').replaceAll(RegExp(r'\s+'), ' ').trim();
final movie = e.kind == LocalKind.movie;
final season = movie ? null : e.season;
final name = movie ? null : e.seasonName;
final query = [
plain,
if (name != null) name else if (season != null && season > 1) 'season $season',
].join(' ');
var hits = await _ask(() => aniList.searchAnime(query));
var best = pickMatch(hits, title: plain, season: season, seasonName: name, movie: movie);
if (best == null && query != plain) {
hits = await _ask(() => aniList.searchAnime(plain));
best = pickMatch(hits, title: plain, season: season, seasonName: name, movie: movie);
// Found the show but not the season: walk its sequels.
if (best != null && season != null && season > 1 && name == null && seasonMarker(best) != season) {
var cur = best;
for (var hop = 1; hop < season; hop++) {
final next = cur.sequel;
if (next == null) break;
final nx = entry(next) ?? await _ask(() => aniList.anime(next));
if (nx == null) break;
_keepEntry(nx);
cur = nx;
}
best = cur;
}
}
for (final h in hits) {
if (h.id == best?.id) _keepEntry(h);
}
store.put('auto:$key', {'id': best?.id, 'at': _now().millisecondsSinceEpoch});
if (best != null) _wantFlow(best.id);
_bump();
} finally {
_setBusy(e.showKey, false);
}
}
/// "Wrong show?": AniList's answers for [query], best first.
Future<List<AniListEntry>> search(String query) async {
final hits = await _ask(() => aniList.searchAnime(query));
return hits;
}
// ------------------------------------------------------------- corrections
/// Pins [showKey] to [e] everywhere (every folder, every rescan).
void pinShow(String showKey, AniListEntry e) {
_keepEntry(e);
store.put('pin:show:$showKey', e.id);
_wantFlow(e.id);
_bump();
}
/// Pins every video in [folder] (and below) to [e].
void pinFolder(String folder, AniListEntry e) {
_keepEntry(e);
store.put('pin:folder:$folder', e.id);
_wantFlow(e.id);
_bump();
}
void unpinShow(String showKey) {
store.delete('pin:show:$showKey');
_bump();
}
void unpinFolder(String folder) {
store.delete('pin:folder:$folder');
_bump();
}
/// "These files are episodes N..": [offset] is added to the file
/// numbers of [showKey]'s season [season] (0 removes it).
void setOffset(String showKey, int? season, int offset) {
final key = 'offset:$showKey|${season ?? ''}';
offset == 0 ? store.delete(key) : store.put(key, offset);
_bump();
}
/// Forgets the automatic match of [show] so it is searched again.
void rematch(LocalShow show) {
for (final k in store.keys('auto:${show.key}|').toList()) {
store.delete(k);
}
want(show);
_bump();
}
// ------------------------------------------------------------- plumbing
void _keepEntry(AniListEntry e) {
_entries[e.id] = e;
store.put('entry:${e.id}', {'at': _now().millisecondsSinceEpoch, 'e': e.toJson()});
_bump();
}
void _bump() => revision.value++;
void _setBusy(String showKey, bool on) {
final now = {...busy.peek()};
if (on ? now.add(showKey) : now.remove(showKey)) busy.value = now;
}
bool _recentlyFailed(String key) {
final at = _failed[key];
return at != null && _now().difference(at) < const Duration(minutes: 5);
}
void _enqueue(String key, Future<void> Function() job) {
if (_jobs.containsKey(key)) return;
_jobs[key] = () => job().catchError((Object e) {
_failed[key] = _now();
debugPrint('library: $key failed: $e');
if (e is AniListException && e.isRateLimited) _pausedUntil = _now().add(const Duration(minutes: 1));
});
if (!_pumping) unawaited(_pump());
}
Future<void> _pump() async {
_pumping = true;
try {
while (_jobs.isNotEmpty) {
final key = _jobs.keys.first;
final job = _jobs[key]!;
await job();
_jobs.remove(key);
}
} finally {
_pumping = false;
}
}
/// One AniList request, spaced from the last one (and after a rate
/// limit's pause).
Future<T> _ask<T>(Future<T> Function() request) async {
final paused = _pausedUntil;
if (paused != null && paused.isAfter(_now())) await _delay(paused.difference(_now()));
final last = _lastRequest;
if (last != null) {
final wait = spacing - _now().difference(last);
if (wait > Duration.zero) await _delay(wait);
}
_lastRequest = _now();
return request();
}
@visibleForTesting
bool get idle => _jobs.isEmpty && !_pumping;
static int? _int(Object? v) => v is int ? v : null;
}
// ------------------------------------------------------------------ scoring
/// The season number an entry's names carry (`Season 2`, `2nd Season`,
/// `Part 2` is not one); null when none does.
int? seasonMarker(AniListEntry e) {
for (final n in e.names) {
final s = _seasonOf(normalizeSeries(n));
if (s != null) return s;
}
return null;
}
final _seasonWords = RegExp(r'\b(?:season (\d{1,2})|(\d{1,2})(?:st|nd|rd|th) season|s(\d{1,2}))\b');
final _roman = {'ii': 2, 'iii': 3, 'iv': 4, 'v': 5, 'vi': 6};
int? _seasonOf(String normalized) {
final m = _seasonWords.firstMatch(normalized);
if (m != null) return int.parse(m.group(1) ?? m.group(2) ?? m.group(3)!);
final last = normalized.split(' ').last;
return _roman[last];
}
/// How alike two normalized titles are, 0..1.
double titleSimilarity(String a, String b) {
if (a.isEmpty || b.isEmpty) return 0;
if (a == b) return 1;
final short = a.length <= b.length ? a : b;
final long = a.length <= b.length ? b : a;
final ratio = short.length / long.length;
if (long.startsWith('$short ')) return 0.8 + 0.2 * ratio;
if (long.contains(' $short ') || long.endsWith(' $short')) return 0.6 + 0.2 * ratio;
// Dice over letter pairs, spaces ignored.
Set<String> pairs(String s) {
final t = s.replaceAll(' ', '');
return {for (var i = 0; i + 1 < t.length; i++) t.substring(i, i + 2)};
}
final pa = pairs(a), pb = pairs(b);
if (pa.isEmpty || pb.isEmpty) return 0;
return 2 * pa.intersection(pb).length / (pa.length + pb.length);
}
/// Scores [e] as the answer for a show called [title] (its season
/// [season] or its season named [seasonName]; a [movie]); [rank]: its
/// place in AniList's own order.
double scoreMatch(AniListEntry e,
{required String title, int? season, String? seasonName, bool movie = false, int rank = 0}) {
final q = normalizeSeries(title);
final full = seasonName == null ? q : normalizeSeries('$title $seasonName');
var best = 0.0;
for (final n in e.names) {
final nn = normalizeSeries(n);
final s = [titleSimilarity(nn, q), titleSimilarity(nn, full)].reduce((a, b) => a > b ? a : b);
if (s > best) best = s;
}
var score = best;
if (seasonName != null) {
final sub = normalizeSeries(seasonName);
if (e.names.any((n) => normalizeSeries(n).contains(sub))) score += 0.25;
} else if (!movie) {
final wanted = season ?? 1;
final marker = seasonMarker(e);
if (marker != null && marker == wanted) {
score += 0.2;
} else if (marker != null) {
score -= 0.25;
} else if (wanted > 1) {
score -= 0.1;
}
}
if (movie) {
score += e.isMovie ? 0.15 : -0.25;
} else {
score += e.isSeries ? 0.05 : (e.isMovie ? -0.3 : -0.1);
}
return score - rank * 0.02;
}
/// The best of [hits] for a show called [title], if any is good enough.
AniListEntry? pickMatch(List<AniListEntry> hits,
{required String title, int? season, String? seasonName, bool movie = false}) {
AniListEntry? best;
var bestScore = 0.55;
for (var i = 0; i < hits.length; i++) {
final s = scoreMatch(hits[i], title: title, season: season, seasonName: seasonName, movie: movie, rank: i);
if (s > bestScore) {
best = hits[i];
bestScore = s;
}
}
return best;
}
@@ -7,11 +7,11 @@ import 'package:flutter/foundation.dart';
import 'package:signals/signals_flutter.dart';
import 'package:sync_relay_core/core.dart';
import '../files/name_parser.dart';
import '../files/thumbnails.dart';
import '../jellyfin/accounts.dart';
import '../jellyfin/format.dart' show pickFor;
import '../jellyfin/session_media.dart';
import '../library/media_refs.dart';
import '../screens/home/picked_media.dart';
import '../session/app_events.dart' show AddedToUpNext;
import '../session/media_art.dart';
@@ -164,15 +164,22 @@ class QueueController {
if (bytes.length <= RelayConstants.maxPosterBytes) thumb = bytes;
} catch (_) {}
}
final ep = [
if (parsed.season != null) 'S${parsed.season}',
if (parsed.episode != null) 'E${parsed.episode}',
].join();
// The library's name for it when it knows the show (what friends
// see in Up next), else the release name's.
final what = coord.fileRef?.call(path);
final ep = what != null && what.kind == 'episode'
? (refEpisodeLine(what) ?? '').replaceAll(' ', '')
: [
if (parsed.season != null) 'S${parsed.season}',
if (parsed.episode != null) 'E${parsed.episode}',
if (parsed.episode == null) ?parsed.extra,
].join();
final title = what != null && what.title.isNotEmpty ? what.title : parsed.series.trim();
return (
item: QueueItem(
media: HelloMedia(name: name, size: await file.length(), duration: 0),
media: HelloMedia(name: name, size: await file.length(), duration: 0, ref: what),
how: QueueHow.file,
title: parsed.series.trim().isEmpty ? name : parsed.series.trim(),
title: title.isEmpty ? name : title,
sub: ep,
duration: (shot?.duration.inMilliseconds ?? 0) / 1000,
key: _newKey(),
@@ -187,7 +194,7 @@ class QueueController {
if (ref != null) {
return (
item: QueueItem(
media: HelloMedia(name: linked.name, size: 0, duration: 0, jellyfin: ref),
media: linked,
how: QueueHow.jellyfin,
title: ref.title.isEmpty ? linked.name : ref.title,
sub: ref.subtitle ?? '',
@@ -234,7 +241,8 @@ class QueueController {
size: 0,
duration: 0,
url: how == QueueHow.through ? null : url,
relay: how == QueueHow.server ? const MediaRelay(fetch: true) : null),
relay: how == QueueHow.server ? const MediaRelay(fetch: true) : null,
ref: linkMediaRef(title)),
how: how,
title: title,
sub: site,
-1
View File
@@ -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';
+219 -3
View File
@@ -8,8 +8,10 @@ import 'package:sync_relay_core/core.dart';
// AniList airing schedule (public GraphQL, no login): every episode airing
// in a time range, with covers, banners and popularity; and a title search
// (cover + banner of a show, for the media art of files and links).
// in a time range, with covers, banners and popularity; a title search
// (cover + banner of a show, for the media art of files and links); and
// the library's lookups (search with synonyms and relations, one entry by
// id; see ARCHITECTURE.md "Media identity").
//
// AniList allows ~90 requests/min (sometimes degraded to 30), so the raw
// pages are cached: in memory for 10 min (tab switches) and on disk for an
@@ -105,6 +107,177 @@ class AniListMedia {
}
}
/// An anime as the library matches it ([AniListClient.searchAnime],
/// [AniListClient.anime]): titles and synonyms to match on, its format
/// and length, art, and the next TV entry of its run ([sequel]) to number
/// across split seasons and cours. JSON round-trips for the library's
/// cache ([toJson]).
class AniListEntry {
final int id;
final String romaji;
final String english;
final String native;
final List<String> synonyms;
/// `TV`, `TV_SHORT`, `MOVIE`, `OVA`, `ONA`, `SPECIAL`, `MUSIC`; '' unknown.
final String format;
/// Null while it airs with no announced count.
final int? episodes;
final int? year;
final String? cover;
final String? coverSmall;
final String? banner;
/// The next TV / TV_SHORT / ONA entry (`SEQUEL` relation).
final int? sequel;
final int? prequel;
/// Episode titles AniList knows (its streaming episodes), by number.
final Map<int, String> episodeTitles;
const AniListEntry({
required this.id,
this.romaji = '',
this.english = '',
this.native = '',
this.synonyms = const [],
this.format = '',
this.episodes,
this.year,
this.cover,
this.coverSmall,
this.banner,
this.sequel,
this.prequel,
this.episodeTitles = const {},
});
/// English title, else romaji.
String get title => english.isNotEmpty ? english : (romaji.isNotEmpty ? romaji : native);
/// Every name it goes by, for matching.
List<String> get names => [
for (final t in [english, romaji, native, ...synonyms])
if (t.trim().isNotEmpty) t,
];
bool get isMovie => format == 'MOVIE';
/// A run of episodes a season or cour is made of.
bool get isSeries => format == 'TV' || format == 'TV_SHORT' || format == 'ONA';
static const graphQLFields = 'id title{romaji english native} synonyms format episodes seasonYear '
'coverImage{extraLarge large} bannerImage '
'relations{edges{relationType node{id format type}}} streamingEpisodes{title}';
static final _streamingTitle = RegExp(r'^\s*(?:Episode|Ep\.?)\s*(\d{1,4})\s*[-–:]\s*(.+)$', caseSensitive: false);
/// From an AniList `Media` object; null when it has no id.
static AniListEntry? fromGraphQL(Object? m) {
if (m is! Map || m['id'] is! int) return null;
String str(Object? v) => v is String ? v : '';
final t = m['title'] is Map ? m['title'] as Map : const {};
final cov = m['coverImage'] is Map ? m['coverImage'] as Map : const {};
final large = cov['large'] is String ? cov['large'] as String : null;
int? related(String type) {
final edges = m['relations'] is Map ? (m['relations'] as Map)['edges'] : null;
if (edges is! List) return null;
for (final e in edges) {
if (e is! Map || e['relationType'] != type) continue;
final node = e['node'];
if (node is Map && node['id'] is int && node['type'] != 'MANGA' &&
const {'TV', 'TV_SHORT', 'ONA'}.contains(node['format'])) {
return node['id'] as int;
}
}
return null;
}
final titles = <int, String>{};
final streaming = m['streamingEpisodes'];
if (streaming is List) {
for (final s in streaming) {
final match = s is Map && s['title'] is String ? _streamingTitle.firstMatch(s['title'] as String) : null;
if (match != null) titles.putIfAbsent(int.parse(match.group(1)!), () => match.group(2)!.trim());
}
}
return AniListEntry(
id: m['id'] as int,
romaji: str(t['romaji']),
english: str(t['english']),
native: str(t['native']),
synonyms: [
if (m['synonyms'] is List)
for (final s in m['synonyms'] as List)
if (s is String && s.trim().isNotEmpty) s.trim(),
],
format: str(m['format']),
episodes: m['episodes'] is int ? m['episodes'] as int : null,
year: m['seasonYear'] is int ? m['seasonYear'] as int : null,
cover: cov['extraLarge'] is String ? cov['extraLarge'] as String : large,
coverSmall: large,
banner: m['bannerImage'] is String ? m['bannerImage'] as String : null,
sequel: related('SEQUEL'),
prequel: related('PREQUEL'),
episodeTitles: titles,
);
}
Map<String, Object?> toJson() => {
'id': id,
'romaji': romaji,
'english': english,
'native': native,
'synonyms': synonyms,
'format': format,
'episodes': episodes,
'year': year,
'cover': cover,
'coverSmall': coverSmall,
'banner': banner,
'sequel': sequel,
'prequel': prequel,
'episodeTitles': {for (final e in episodeTitles.entries) '${e.key}': e.value},
};
static AniListEntry? fromJson(Object? j) {
if (j is! Map || j['id'] is! int) return null;
String str(Object? v) => v is String ? v : '';
String? opt(Object? v) => v is String ? v : null;
int? n(Object? v) => v is int ? v : null;
final titles = j['episodeTitles'];
return AniListEntry(
id: j['id'] as int,
romaji: str(j['romaji']),
english: str(j['english']),
native: str(j['native']),
synonyms: [if (j['synonyms'] is List) ...(j['synonyms'] as List).whereType<String>()],
format: str(j['format']),
episodes: n(j['episodes']),
year: n(j['year']),
cover: opt(j['cover']),
coverSmall: opt(j['coverSmall']),
banner: opt(j['banner']),
sequel: n(j['sequel']),
prequel: n(j['prequel']),
episodeTitles: {
if (titles is Map)
for (final e in titles.entries)
if (int.tryParse('${e.key}') case final k? when e.value is String) k: e.value as String,
},
);
}
@override
String toString() => 'AniListEntry($id "$title" $format ${episodes ?? '?'} eps)';
}
/// What the library asks AniList ([AniListClient]; tests fake it).
abstract interface class AniListLookup {
/// Anime matching [query], AniList's best first.
Future<List<AniListEntry>> searchAnime(String query);
/// One anime by id; null when AniList has none.
Future<AniListEntry?> anime(int id);
}
/// "#e4a15d" -> opaque [Color]; anything else -> null.
Color? parseHexColor(Object? s) {
if (s is! String || s.length != 7 || !s.startsWith('#')) return null;
@@ -142,7 +315,7 @@ class _Cached {
const _Cached(this.fetched, this.from, this.to, this.raw);
}
class AniListClient {
class AniListClient implements AniListLookup {
AniListClient({
String url = aniListDefaultUrl,
HttpClient? http,
@@ -306,6 +479,49 @@ coverImage{extraLarge large} bannerImage}}''';
}
}
static final _listQuery = 'query(\$search:String){Page(perPage:8){media(search:\$search,type:ANIME,'
'isAdult:false,sort:SEARCH_MATCH){${AniListEntry.graphQLFields}}}}';
static final _byIdQuery = 'query(\$id:Int){Media(id:\$id,type:ANIME){${AniListEntry.graphQLFields}}}';
/// Up to 8 anime matching [query], AniList's best first (the library's
/// matching and its "Wrong show?" search). Not cached here: the library
/// keeps what it needs.
@override
Future<List<AniListEntry>> searchAnime(String query) async {
final q = query.trim();
if (q.isEmpty) return const [];
final (data, _, _) = await _graphql(_listQuery, {'search': q});
final page = data?['Page'];
final media = page is Map ? page['media'] : null;
return [if (media is List) for (final m in media) ?AniListEntry.fromGraphQL(m)];
}
@override
Future<AniListEntry?> anime(int id) async {
try {
final (data, _, _) = await _graphql(_byIdQuery, {'id': id});
return AniListEntry.fromGraphQL(data?['Media']);
} on AniListException catch (e) {
if (e.statusCode == 404) return null;
rethrow;
}
}
static const byIdArtQuery = r'''
query($id:Int){Media(id:$id,type:ANIME){id title{romaji english} coverImage{extraLarge large} bannerImage}}''';
/// Cover and banner of AniList entry [id] (the media art of a file the
/// library matched); null when there is none.
Future<AniListMedia?> mediaById(int id) async {
try {
final (data, _, _) = await _graphql(byIdArtQuery, {'id': id});
return AniListMedia.fromJson(data?['Media']);
} on AniListException catch (e) {
if (e.statusCode == 404) return null;
rethrow;
}
}
static String _errorText(Object? j) {
final errs = j is Map ? j['errors'] : null;
if (errs is! List || errs.isEmpty) return '';
@@ -1,3 +1,5 @@
import 'package:sync_relay_core/core.dart' show MediaRef;
/// What the media picker selected. Null selection = nothing (hosting
/// without media is allowed).
sealed class PickedMedia {
@@ -72,6 +74,9 @@ final class JellyfinMedia extends PickedMedia {
/// "Series · S1E6" for episodes, the year for movies; may be empty.
final String subtitle;
final Duration? runtime;
/// What it is (`media.ref`), when it was picked from the item itself
/// (with its provider ids); not stored, not part of equality.
final MediaRef? ref;
const JellyfinMedia({
required this.serverId,
required this.baseUrl,
@@ -80,6 +85,7 @@ final class JellyfinMedia extends PickedMedia {
required this.title,
this.subtitle = '',
this.runtime,
this.ref,
});
@override
+13 -1
View File
@@ -7,7 +7,6 @@ import 'package:flutter/foundation.dart';
import 'package:image/image.dart' as img;
import 'package:sync_relay_core/core.dart';
import '../files/name_parser.dart';
import '../schedule/anilist.dart';
// Art for media this client swapped in (`art`, see PROTOCOL.md "Media
@@ -58,6 +57,11 @@ Future<ArtBytes?> aniListArt(AniListClient aniList, String name,
if (!RegExp(r'\p{L}', unicode: true).hasMatch(series)) return null;
final match = await aniList.searchMedia(series);
if (match == null) return null;
return _artOf(match, fetch);
}
Future<ArtBytes> _artOf(AniListMedia match,
Future<Uint8List?> Function(Uri url, Map<String, String> headers, int maxBytes) fetch) async {
Future<Uint8List?> get(String? url, int maxBytes) async {
final uri = url == null ? null : Uri.tryParse(url);
return uri == null ? null : fetch(uri, const {}, maxBytes);
@@ -71,6 +75,14 @@ Future<ArtBytes?> aniListArt(AniListClient aniList, String name,
return (poster: poster, banner: banner);
}
/// Cover and banner of AniList entry [id] (a file the library matched);
/// null when AniList has none.
Future<ArtBytes?> aniListArtById(AniListClient aniList, int id,
{Future<Uint8List?> Function(Uri url, Map<String, String> headers, int maxBytes) fetch = fetchImage}) async {
final match = await aniList.mediaById(id);
return match == null ? null : _artOf(match, fetch);
}
/// A web video's thumbnail at [url] as both cover and banner (the same
/// bytes, so the same hash: what tells clients it is a thumbnail, see
/// `NowPlaying.thumbnail`); shrunk to a JPEG under the cover cap when
@@ -1,6 +1,6 @@
import 'package:sync_relay_core/core.dart' show hasMediaExtension;
import 'package:sync_relay_core/core.dart' show MediaRef, hasMediaExtension, parseReleaseName;
import '../files/name_parser.dart';
import '../library/media_refs.dart' show refEpisodeLine;
import '../jellyfin/session_media.dart';
import 'now_playing.dart';
@@ -26,12 +26,29 @@ NowPlaying? nowPlayingOf(Map<String, Object?>? media) {
if (web) {
return NowPlaying(title: name, posterHash: poster, bannerHash: banner, thumbnail: true);
}
final what = _mediaRef(media['ref']);
final (title, episode) = ref != null
? jellyfinTitle(ref.title.isNotEmpty ? ref.title : name, ref.subtitle)
: fileTitle(name);
: what != null && what.title.isNotEmpty
? refTitle(what, name)
: fileTitle(name);
return NowPlaying(title: title, episode: episode, posterHash: poster, bannerHash: banner);
}
MediaRef? _mediaRef(Object? j) {
try {
return MediaRef.fromJson(j);
} catch (_) {
return null;
}
}
/// Files and links that say what they are (`media.ref`, from the
/// sender's library): its proper title and `S3 E13` / `E108`; an extra
/// keeps the name's own marker (OP1, NCED2).
(String, String?) refTitle(MediaRef ref, String name) =>
(ref.title, refEpisodeLine(ref) ?? (ref.kind == 'extra' ? fileTitle(name).$2 : null));
/// [media] (`state.media`, not Jellyfin) is a web page's video, whose art
/// will be its thumbnail: a shared http(s) link that is no media file (the
/// everyone-opens-it mode), or a relayed one (passed on by a member or
@@ -12,6 +12,7 @@ import '../jellyfin/accounts.dart';
import '../jellyfin/progress.dart';
import '../jellyfin/session_media.dart';
import '../jellyfin/track_prefs.dart';
import '../library/media_refs.dart';
import '../playback/media_marks.dart';
import '../playback/playback_controller.dart';
import '../platform/ports/link_resolver.dart';
@@ -118,6 +119,9 @@ class SessionCoordinator {
/// Set by the hosting app: makes a local file the hosted one (File swap);
/// null when this app is not the host.
Future<({String name, int size})?> Function(String path)? hostFile;
/// What a local file is (`media.ref`, from the library: `ShowMatcher`),
/// set by the composition root; null: files go up without one.
MediaRef? Function(String path)? fileRef;
/// The app's yt-dlp and link probes (tests pass their own).
final LinkResolver ytDlp;
/// The link to the server (socket, reconnects, clock offset, its
@@ -1449,8 +1453,11 @@ class SessionCoordinator {
final uri = thumb == null ? null : Uri.tryParse(thumb);
bytes = uri == null ? null : await thumbnailArt(uri);
} else {
// The library's match names the AniList entry; else a title search.
final known = ref == null ? media.ref?.anilistId : null;
final name = ref == null ? media.name : jellyfinTitle(ref.title, ref.subtitle).$1;
bytes = await aniListArt(_aniList ??= AniListClient(), name);
final aniList = _aniList ??= AniListClient();
bytes = await (known != null ? aniListArtById(aniList, known) : null) ?? await aniListArt(aniList, name);
}
final poster = bytes?.poster, banner = bytes?.banner;
if (poster == null && banner == null) return;
@@ -1491,8 +1498,9 @@ class SessionCoordinator {
// A page is named by its title, never its URL tail ("1", "watch").
final uri = Uri.tryParse(url) ?? Uri();
final name = probe?.title ?? (probe != null || hasMediaExtension(uri) || uri.host.isEmpty ? media.name : uri.host);
final what = linkMediaRef(name);
if (relay == LinkRelay.direct) {
_swap(HelloMedia(name: name, size: 0, duration: 0, url: url), queueId: queueId);
_swap(HelloMedia(name: name, size: 0, duration: 0, url: url, ref: what), queueId: queueId);
return null;
}
if (relay == LinkRelay.me) {
@@ -1526,8 +1534,8 @@ class SessionCoordinator {
await check?.close();
}
_pendingOrigin = _LinkOrigin(url, probe, y);
_swap(HelloMedia(
name: name, size: size, duration: 0, relay: MediaRelay(tracks: specs)), link: url, queueId: queueId);
_swap(HelloMedia(name: name, size: size, duration: 0, relay: MediaRelay(tracks: specs), ref: what),
link: url, queueId: queueId);
return null;
}
// The server reads it with its own yt-dlp; only a page this app knows
@@ -1537,7 +1545,12 @@ class SessionCoordinator {
return probe.relayProblem;
}
_swap(HelloMedia(
name: name, size: max(0, probe?.size ?? 0), duration: 0, url: url, relay: const MediaRelay(fetch: true)),
name: name,
size: max(0, probe?.size ?? 0),
duration: 0,
url: url,
relay: const MediaRelay(fetch: true),
ref: what),
queueId: queueId);
return null;
}
@@ -1563,18 +1576,23 @@ class SessionCoordinator {
/// when sent.
Future<String?> swapFile(String path, {String? queueId}) async {
markInput();
final what = fileRef?.call(path);
final host = hostFile;
if (host != null) {
final file = await host(path);
if (file == null) return 'file not found — check the path';
_swap(HelloMedia(name: file.name, size: file.size, duration: 0), queueId: queueId);
_swap(HelloMedia(name: file.name, size: file.size, duration: 0, ref: what), queueId: queueId);
return null;
}
final file = File(path);
if (!await file.exists()) return 'file not found — check the path';
_pendingOrigin = _FileOrigin(path);
_swap(HelloMedia(
name: file.uri.pathSegments.last, size: await file.length(), duration: 0, relay: const MediaRelay()),
name: file.uri.pathSegments.last,
size: await file.length(),
duration: 0,
relay: const MediaRelay(),
ref: what),
queueId: queueId);
return null;
}
@@ -8,7 +8,7 @@ import 'package:signals/signals.dart';
import 'package:sync_relay_core/core.dart';
import '../jellyfin/accounts.dart';
import '../jellyfin/session_media.dart' show relayCredentialFor;
import '../jellyfin/session_media.dart' show relayCredentialsFor;
import '../state/connect_prefs.dart';
import '../state/server_isolate.dart';
import 'invite.dart';
@@ -60,7 +60,7 @@ class YourServer {
_interfaces = interfaces ?? machineInterfaces;
final ConnectStore forms;
/// Linked Jellyfin servers (default: the app's): the relay login.
/// Linked Jellyfin servers (default: the app's): the relay logins.
final JellyfinAccounts? jellyfin;
final Future<String> Function() _ytDlpDir;
final String? Function() _logDir;
@@ -156,8 +156,8 @@ class YourServer {
port: want,
secret: form.hostSecret,
// Guests without an account watch Jellyfin items through the
// server, with this app's own sign-in.
jellyfin: relayCredentialFor(_accounts),
// server, with this app's own sign-in on the item's server.
jellyfin: relayCredentialsFor(_accounts),
);
final invite = await inviteFor(bound, password: form.hostSecret.isNotEmpty);
shared.value = invite;
@@ -159,9 +159,10 @@ class ServerIsolate {
/// Opens the server to the network ([SyncServer.share]): a listener on
/// every interface at [port], the password [secret], the Jellyfin relay
/// login [jellyfin]. Returns the bound port; throws a [StateError] with
/// logins [jellyfin] (one per Jellyfin server). Returns the bound port;
/// throws a [StateError] with
/// the server's reason (a port in use) when it cannot.
Future<int> share({required int port, required String secret, JellyfinCredential? jellyfin,
Future<int> share({required int port, required String secret, List<JellyfinCredential>? jellyfin,
Duration timeout = const Duration(seconds: 10)}) async {
if (_exited.isCompleted) throw StateError('the server is not running');
final reply = ReceivePort('server share');
@@ -170,7 +171,7 @@ class ServerIsolate {
't': 'share',
'port': port,
'secret': secret,
'jellyfin': ?jellyfin?.toMap(),
if (jellyfin != null) 'jellyfin': [for (final c in jellyfin) c.toMap()],
'reply': reply.sendPort,
});
final msg = await reply.first.timeout(timeout);