From 884f20833028cc485cf9de8d36f94a390258e317 Mon Sep 17 00:00:00 2001 From: foxyz Date: Sat, 10 Oct 2026 20:15:18 +0300 Subject: [PATCH] =?UTF-8?q?Schedule:=20On=20Jellyfin=20only=20when=20the?= =?UTF-8?q?=20episode=20itself=20is=20there;=20Aired=20=C2=B7=20not=20on?= =?UTF-8?q?=20Jellyfin=20yet=20otherwise?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit For shows AnimeFlow doesn't track, the app now looks the episode up in the linked Jellyfin (one /Shows/{id}/Episodes per series, 3 min memo; TVDB number via anilist_map offsets). No linked account: a neutral Aired. Co-Authored-By: Claude Opus 5.5 --- .../lib/src/schedule/schedule_model.dart | 92 +++++++++++++++++-- .../lib/src/schedule/ui/schedule_actions.dart | 21 ++++- .../lib/src/schedule/ui/schedule_card.dart | 2 + .../lib/src/schedule/ui/schedule_page.dart | 16 ++-- apps/desktop/test/schedule_model_test.dart | 30 ++++++ apps/desktop/test/schedule_page_test.dart | 46 ++++++++++ docs/ARCHITECTURE.md | 8 +- 7 files changed, 197 insertions(+), 18 deletions(-) diff --git a/apps/desktop/lib/src/schedule/schedule_model.dart b/apps/desktop/lib/src/schedule/schedule_model.dart index 6b6c188..219ef03 100644 --- a/apps/desktop/lib/src/schedule/schedule_model.dart +++ b/apps/desktop/lib/src/schedule/schedule_model.dart @@ -17,9 +17,16 @@ enum ScheduleStatus { unmonitored, /// AnimeFlow does not track this series (or is unreachable). notTracked, - /// Not tracked by AnimeFlow, but the series is on Jellyfin (`/library`): - /// the episode may be there; Put it up finds out. - inLibrary; + /// Not tracked by AnimeFlow, the series is on Jellyfin (`/library`) and + /// so is the episode ([confirmOnJellyfin]; [matchSchedule] alone sets + /// it from the series). + inLibrary, + /// Not tracked, the series is on Jellyfin, the episode aired but is not + /// there yet. + notOnJellyfin, + /// Not tracked, the series is on Jellyfin, the episode aired; whether + /// it is there is unknown (no linked account, lookup failed). + aired; static ScheduleStatus of(EpisodeStatus s) => switch (s) { EpisodeStatus.available => available, @@ -44,8 +51,12 @@ class ScheduleEntry { final bool followed; /// AnimeFlow's series id (follow, requests). final int? seriesId; - /// The AnimeFlow season of the matched episode (requests). + /// The AnimeFlow season of the matched episode (requests); untracked + /// on Jellyfin: the TVDB season of the AniList entry. final int? season; + /// Untracked on Jellyfin: the TVDB episode number in [season] (AniList + /// episode + the `anilist_map` run's offset). + final int? jellyfinEpisode; /// Play target once available. final String? jellyfinSeriesId; /// The series is on Jellyfin (tracked or not). @@ -60,9 +71,23 @@ class ScheduleEntry { this.followed = false, this.seriesId, this.season, + this.jellyfinEpisode, this.jellyfinSeriesId, }); + ScheduleEntry withStatus(ScheduleStatus s) => ScheduleEntry( + airing: airing, + status: s, + progress: progress, + reason: reason, + tracked: tracked, + followed: followed, + seriesId: seriesId, + season: season, + jellyfinEpisode: jellyfinEpisode, + jellyfinSeriesId: jellyfinSeriesId, + ); + @override String toString() => 'ScheduleEntry(${airing.anilistId}#${airing.episode} $status)'; } @@ -98,15 +123,16 @@ List matchSchedule( DateTime? now, }) { final at = now ?? DateTime.now(); - // AniList id -> (Jellyfin series, its TVDB season) from /library. - final lib = {}; + // AniList id -> (Jellyfin series, its TVDB season, TVDB - AniList + // episode offset) from /library. + final lib = {}; for (final item in library) { if (item.jellyfinSeriesId.isEmpty) continue; for (final r in item.anilistMap) { - lib[r.anilistId] ??= (item.jellyfinSeriesId, r.season); + lib[r.anilistId] ??= (item.jellyfinSeriesId, r.season, r.offset); } for (final e in item.anilistIds.entries) { - lib[e.value] ??= (item.jellyfinSeriesId, e.key); + lib[e.value] ??= (item.jellyfinSeriesId, e.key, 0); } } final refs = {}; @@ -156,6 +182,7 @@ List matchSchedule( airing: a, status: a.airingAt.isAfter(at) ? ScheduleStatus.upcoming : ScheduleStatus.inLibrary, season: onJf.$2, + jellyfinEpisode: a.episode + onJf.$3, jellyfinSeriesId: onJf.$1, ); } @@ -171,6 +198,27 @@ List matchSchedule( ]; } +/// Checks [matchSchedule]'s [ScheduleStatus.inLibrary] entries (series +/// on Jellyfin) against the episodes Jellyfin has: [episodes] maps a +/// Jellyfin series id to its (season, episode) numbers. Found stays +/// inLibrary, absent becomes [ScheduleStatus.notOnJellyfin], a series +/// missing from [episodes] (not looked up) [ScheduleStatus.aired]. +List confirmOnJellyfin(List entries, Map> episodes) => [ + for (final e in entries) + if (e.status != ScheduleStatus.inLibrary) + e + else + switch (episodes[e.jellyfinSeriesId]) { + null => e.withStatus(ScheduleStatus.aired), + final have when have.contains((e.season, e.jellyfinEpisode)) => e, + _ => e.withStatus(ScheduleStatus.notOnJellyfin), + }, + ]; + +/// The (season, episode) numbers Jellyfin has of a series; null when it +/// cannot tell (no linked account for that server). +typedef JellyfinEpisodeLookup = Future?> Function(String jellyfinSeriesId); + /// One loaded window. Errors are display text; [entries] is empty only /// when AniList failed with nothing cached. class ScheduleSnapshot { @@ -206,12 +254,15 @@ class ScheduleSnapshot { /// Loads AniList and AnimeFlow for a window and merges them. class ScheduleRepository { - ScheduleRepository({required this.aniList, this.animeFlow, DateTime Function()? now}) + ScheduleRepository({required this.aniList, this.animeFlow, this.jellyfinEpisodes, DateTime Function()? now}) : _now = now ?? DateTime.now; final AniListClient aniList; /// Null = not configured (AniList only). final AnimeFlowClient? animeFlow; + /// Confirms "on Jellyfin" per episode ([confirmOnJellyfin]); null = + /// never confirmed (aired entries read [ScheduleStatus.aired]). + final JellyfinEpisodeLookup? jellyfinEpisodes; final DateTime Function() _now; /// Last week's Monday 00:00 local .. 4 weeks later. @@ -236,7 +287,8 @@ class ScheduleRepository { final flow = await flowF; final airings = [...ani.airings]..sort((a, b) => a.airingAt.compareTo(b.airingAt)); final entries = flow.error == null - ? matchSchedule(airings, schedule: flow.schedule, series: flow.series, library: flow.library, now: now) + ? await _confirm( + matchSchedule(airings, schedule: flow.schedule, series: flow.series, library: flow.library, now: now)) : [for (final a in airings) untrackedEntry(a, now)]; return ScheduleSnapshot( from: start, @@ -249,6 +301,26 @@ class ScheduleRepository { ); } + /// Looks up the episodes of every series an inLibrary entry is on, in + /// parallel; a failed or impossible lookup leaves that series out. + Future> _confirm(List entries) async { + final lookup = jellyfinEpisodes; + final ids = { + for (final e in entries) + if (e.status == ScheduleStatus.inLibrary && e.jellyfinSeriesId != null) e.jellyfinSeriesId!, + }; + final found = >{}; + if (lookup != null) { + await Future.wait([ + for (final id in ids) + lookup(id).then((eps) { + if (eps != null) found[id] = eps; + }, onError: (_) {}), + ]); + } + return confirmOnJellyfin(entries, found); + } + Future<({AnimeFlowMe? me, List schedule, List series, List library, Object? error})> _animeFlow(DateTime from, DateTime to, bool force) async { final c = animeFlow; diff --git a/apps/desktop/lib/src/schedule/ui/schedule_actions.dart b/apps/desktop/lib/src/schedule/ui/schedule_actions.dart index 72bcf6a..29b0fe1 100644 --- a/apps/desktop/lib/src/schedule/ui/schedule_actions.dart +++ b/apps/desktop/lib/src/schedule/ui/schedule_actions.dart @@ -78,7 +78,7 @@ Future<({int season, int episode})> flowNumbering(AnimeFlowClient? client, Sched } } if (ep != null) return (season: ep.season, episode: ep.episode); - return (season: e.season ?? 1, episode: e.airing.episode); + return (season: e.season ?? 1, episode: e.jellyfinEpisode ?? e.airing.episode); } /// The AniDB id of an untracked AniList entry, from AnimeFlow's search: @@ -112,6 +112,25 @@ JellyfinAccount? jellyfinAccountFor(JellyfinAccounts accounts, AnimeFlowClient? return accounts.defaultAccount; } +/// The (season, episode) numbers of the episodes Jellyfin has of +/// [jellyfinSeriesId], seen by the account AnimeFlow's ids belong to +/// ([jellyfinAccountFor]); null without a linked account. One request per +/// series, memoised a few minutes ([JellyfinAccounts.cached]). +Future?> jellyfinEpisodeNumbers( + JellyfinAccounts accounts, + AnimeFlowClient? flow, + String jellyfinSeriesId, +) async { + await accounts.ensureLoaded(validate: false); + final account = jellyfinAccountFor(accounts, flow); + final client = account == null ? null : accounts.clientFor(account.serverId); + if (account == null || client == null) return null; + return accounts.cached(account.serverId, 'schedule-episodes:$jellyfinSeriesId', () async => { + for (final x in await client.episodes(jellyfinSeriesId)) + if (x.parentIndexNumber != null && x.indexNumber != null) (x.parentIndexNumber!, x.indexNumber!), + }); +} + /// The Jellyfin episode of an available [e] as a media pick. Throws /// [ScheduleActionException] (or a client error) when it cannot be found. Future resolveJellyfinEpisode(JellyfinAccounts accounts, AnimeFlowClient? flow, ScheduleEntry e) async { diff --git a/apps/desktop/lib/src/schedule/ui/schedule_card.dart b/apps/desktop/lib/src/schedule/ui/schedule_card.dart index a7ff554..b7566cc 100644 --- a/apps/desktop/lib/src/schedule/ui/schedule_card.dart +++ b/apps/desktop/lib/src/schedule/ui/schedule_card.dart @@ -49,6 +49,8 @@ String countdownText(DateTime at, DateTime now) { ScheduleStatus.unmonitored => ('not monitored', Colors.grey, Icons.visibility_off_outlined), ScheduleStatus.notTracked => ('not in AnimeFlow', Colors.grey, Icons.public), ScheduleStatus.inLibrary => ('on Jellyfin', Colors.greenAccent, Icons.video_library_outlined), + ScheduleStatus.notOnJellyfin => ('aired · not on Jellyfin yet', Colors.grey, Icons.hourglass_empty), + ScheduleStatus.aired => ('aired', Colors.grey, Icons.done), }; class DayColumn extends StatelessWidget { diff --git a/apps/desktop/lib/src/schedule/ui/schedule_page.dart b/apps/desktop/lib/src/schedule/ui/schedule_page.dart index 6a3854e..e12397f 100644 --- a/apps/desktop/lib/src/schedule/ui/schedule_page.dart +++ b/apps/desktop/lib/src/schedule/ui/schedule_page.dart @@ -97,18 +97,22 @@ class _SchedulePageState extends State { void initState() { super.initState(); _ownsRepo = widget.repository == null; - _repo = - widget.repository ?? - ScheduleRepository( - aniList: AniListClient(), - animeFlow: AnimeFlowClient.fromAccounts(JellyfinAccounts.instance), - ); + _repo = widget.repository ?? _ownRepo(); _load(); _timer = Timer.periodic(SchedulePage.refreshEvery, (_) { if (mounted && !_refreshing && TickerMode.valuesOf(context).enabled) _load(); }); } + ScheduleRepository _ownRepo() { + final flow = AnimeFlowClient.fromAccounts(_accounts); + return ScheduleRepository( + aniList: AniListClient(), + animeFlow: flow, + jellyfinEpisodes: (id) => jellyfinEpisodeNumbers(_accounts, flow, id), + ); + } + @override void dispose() { _timer?.cancel(); diff --git a/apps/desktop/test/schedule_model_test.dart b/apps/desktop/test/schedule_model_test.dart index 1720379..8122afa 100644 --- a/apps/desktop/test/schedule_model_test.dart +++ b/apps/desktop/test/schedule_model_test.dart @@ -80,6 +80,36 @@ void main() { expect(out.single.season, 6); }); + test('confirmOnJellyfin: the episode (with the run offset) must be on Jellyfin', () { + final lib = AnimeFlowLibraryItem.fromJson({ + 'jellyfin_series_id': 'jfJ', + 'name': 'JoJo', + 'anilist_map': [ + {'season': 6, 'anilist_id': 210482, 'first_episode': 13, 'offset': 12}, + ], + }); + final matched = matchSchedule( + [ + airing(210482, 1, DateTime(2026, 10, 1, 18)), // S6E13: there + airing(210482, 2, DateTime(2026, 10, 8, 11)), // S6E14: not yet + airing(172192, 3, DateTime(2026, 10, 6, 18)), // series not looked up + ], + schedule: const [], + library: [ + lib, + const AnimeFlowLibraryItem(jellyfinSeriesId: 'jfR', name: 'Returner', anilistIds: {2: 172192}), + ], + now: now, + ); + expect(matched.map((e) => e.jellyfinEpisode), [13, 14, 3]); + final out = confirmOnJellyfin(matched, { + 'jfJ': {(6, 12), (6, 13), (1, 14)}, + }); + expect(out.map((e) => e.status), + [ScheduleStatus.inLibrary, ScheduleStatus.notOnJellyfin, ScheduleStatus.aired]); + expect(out[1].jellyfinSeriesId, 'jfJ'); + }); + test("AnimeFlow's anilist_episode wins over TVDB numbering (split cour)", () { // S2E13 in TVDB is part 2 (AniList 300) episode 1. final out = matchSchedule( diff --git a/apps/desktop/test/schedule_page_test.dart b/apps/desktop/test/schedule_page_test.dart index 0856527..493bc3c 100644 --- a/apps/desktop/test/schedule_page_test.dart +++ b/apps/desktop/test/schedule_page_test.dart @@ -8,6 +8,7 @@ import 'package:watch_together/src/jellyfin/accounts.dart'; import 'package:watch_together/src/schedule/anilist.dart'; import 'package:watch_together/src/schedule/animeflow.dart'; import 'package:watch_together/src/schedule/schedule_model.dart'; +import 'package:watch_together/src/schedule/ui/schedule_actions.dart'; import 'package:watch_together/src/schedule/ui/schedule_page.dart'; import 'package:watch_together/src/screens/home/picked_media.dart'; @@ -434,6 +435,51 @@ void main() { await done(tester); }); + testWidgets('untracked on Jellyfin: episode lookup, aired-not-there has no Play', (tester) async { + final jf = FakeJf(); + final accounts = JellyfinAccounts( + store: MemoryJellyfinStore({ + 'accounts': jsonEncode([ + { + 'serverId': 'srv', + 'serverName': 'Home', + 'urls': ['http://jf.test'], + 'userId': 'uid', + 'userName': 'me', + 'token': 'tok', + }, + ]), + }), + httpFactory: () => jf, + deviceName: 'Test', + ); + final eps = await tester.runAsync(() => jellyfinEpisodeNumbers(accounts, flow, 'jf7')); + expect(eps, {(1, 2), (1, 3)}); + await tester.runAsync(() => jellyfinEpisodeNumbers(accounts, flow, 'jf7')); + expect(jf.seen.where((u) => u.path.endsWith('/Episodes')), hasLength(1), reason: 'memoised'); + final none = JellyfinAccounts(store: MemoryJellyfinStore(), deviceName: 'Test'); + expect(await tester.runAsync(() => jellyfinEpisodeNumbers(none, flow, 'jf7')), isNull); + + await pumpPage( + tester, + snap: () => snapshot( + list: [ + ScheduleEntry( + airing: airing(777, 'Clover', 2, day(0, 9)), + status: ScheduleStatus.notOnJellyfin, + season: 1, + jellyfinEpisode: 2, + jellyfinSeriesId: 'jfC', + ), + ], + ), + ); + expect(card(777, 2), findsOneWidget); + expect(find.text('aired · not on Jellyfin yet'), findsOneWidget); + expect(action(777, 2), findsNothing); + await done(tester); + }); + testWidgets('solo: the button says Play and works', (tester) async { await pumpPage(tester, solo: true); expect(find.text('Put it up'), findsNothing); diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 8f6bd11..1c74b45 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -819,7 +819,13 @@ always scrolls the page). Space never presses a focused button against AnimeFlow (`animeflow.dart`, app API with the Jellyfin token; `/library` marks shows on Jellyfin it doesn't track, through every AniList entry of a season (`anilist_map`: a season AniList splits has several)) into a status per - episode (`schedule_model.dart`); requests and the owner's inbox are in + episode (`schedule_model.dart`). An aired episode of such a show reads + "on Jellyfin" only when Jellyfin has it: the season plus AniList + episode + the run's offset, checked against the series' episodes seen + by the linked account (`jellyfinEpisodeNumbers`, one request per series + memoised 3 min; `confirmOnJellyfin` is the pure step); absent reads + "aired · not on Jellyfin yet", unknown (no linked account, lookup + failed) "aired", neither with Put it up. Requests and the owner's inbox are in `requests_sheet.dart`. It refreshes every minute; mouse drag and Shift+wheel scroll the board sideways, the plain wheel scrolls a column. Settings (`settings_tab.dart`) has a left nav with icons