From 2eb6840ec48fb7bacbb744a462592015f8fbe44d Mon Sep 17 00:00:00 2001 From: foxyz Date: Fri, 9 Oct 2026 00:01:28 +0300 Subject: [PATCH] Schedule: "on Jellyfin" for shows AnimeFlow doesn't track, AniList episode numbers From real use: a show on Jellyfin read "not in AnimeFlow". AnimeFlow's new GET /library lists every Jellyfin series with AniList ids per TVDB season; matchSchedule uses it for untracked shows (status inLibrary, Put it up, no Request) and to fill jellyfinSeriesId. A failing or missing /library never fails the schedule. Episodes may carry anilist_episode (split cours, absolute numbering), matched first. Co-Authored-By: Claude Opus 5.5 --- apps/desktop/lib/src/schedule/animeflow.dart | 42 ++++++++++++++++ .../lib/src/schedule/schedule_model.dart | 50 ++++++++++++++++--- .../lib/src/schedule/ui/schedule_card.dart | 1 + .../lib/src/schedule/ui/schedule_page.dart | 7 +-- apps/desktop/test/schedule_model_test.dart | 41 +++++++++++++++ 5 files changed, 130 insertions(+), 11 deletions(-) diff --git a/apps/desktop/lib/src/schedule/animeflow.dart b/apps/desktop/lib/src/schedule/animeflow.dart index 073a54a..e93eafb 100644 --- a/apps/desktop/lib/src/schedule/animeflow.dart +++ b/apps/desktop/lib/src/schedule/animeflow.dart @@ -142,6 +142,32 @@ enum EpisodeStatus { static EpisodeStatus parse(Object? s) => values.where((v) => v.name == s).firstOrNull ?? upcoming; } +/// A series on Jellyfin (`GET /library`). +class AnimeFlowLibraryItem { + final String jellyfinSeriesId; + final String name; + /// Set when AnimeFlow tracks it. + final int? seriesId; + /// AniList entry per TVDB season. + final Map anilistIds; + const AnimeFlowLibraryItem({ + required this.jellyfinSeriesId, + required this.name, + this.seriesId, + this.anilistIds = const {}, + }); + + static AnimeFlowLibraryItem fromJson(Map j) => AnimeFlowLibraryItem( + jellyfinSeriesId: _str(j['jellyfin_series_id']) ?? '', + name: _str(j['name']) ?? '', + seriesId: _int(j['series_id']), + anilistIds: { + for (final e in ((j['anilist_ids'] as Map?) ?? const {}).entries) + if (int.tryParse('${e.key}') case final season? when _int(e.value) != null) season: _int(e.value)!, + }, + ); +} + class AnimeFlowEpisode { /// 0 = specials. final int season; @@ -152,6 +178,9 @@ class AnimeFlowEpisode { /// The AniList entry of this episode's season (one AnimeFlow series may /// span several AniList entries). final int? anilistId; + /// The episode number within [anilistId] when AnimeFlow knows it (split + /// cours, absolute numbering); prefer it over [episode] for matching. + final int? anilistEpisode; final EpisodeStatus status; /// 0..1, only while downloading. final double? progress; @@ -165,6 +194,7 @@ class AnimeFlowEpisode { this.title, this.airDate, this.anilistId, + this.anilistEpisode, required this.status, this.progress, this.reason, @@ -177,6 +207,7 @@ class AnimeFlowEpisode { title: _str(j['episode_title']), airDate: _time(j['air_date']), anilistId: _int(j['anilist_id']), + anilistEpisode: _int(j['anilist_episode']), status: EpisodeStatus.parse(j['status']), progress: (j['progress'] as num?)?.toDouble(), reason: _str(j['reason']), @@ -610,6 +641,17 @@ class AnimeFlowClient { Future> series() async => [for (final s in _items(await _call('GET', '/series', etag: true))) AnimeFlowSeries.fromJson(s)]; + /// Every series on Jellyfin (as the caller sees it), tracked by + /// AnimeFlow or not [view]. Empty on servers without the endpoint. + Future> library() async { + try { + return [for (final s in _items(await _call('GET', '/library', etag: true))) AnimeFlowLibraryItem.fromJson(s)]; + } on AnimeFlowException catch (e) { + if (e.isNotFound) return const []; + rethrow; + } + } + /// One series with all its episodes [view]. Future seriesDetail(int seriesId) async { final j = await _call('GET', '/series/$seriesId', etag: true); diff --git a/apps/desktop/lib/src/schedule/schedule_model.dart b/apps/desktop/lib/src/schedule/schedule_model.dart index e0dfa7a..d1d0b58 100644 --- a/apps/desktop/lib/src/schedule/schedule_model.dart +++ b/apps/desktop/lib/src/schedule/schedule_model.dart @@ -16,7 +16,10 @@ enum ScheduleStatus { failed, unmonitored, /// AnimeFlow does not track this series (or is unreachable). - notTracked; + notTracked, + /// Not tracked by AnimeFlow, but the series is on Jellyfin (`/library`): + /// the episode may be there; Put it up finds out. + inLibrary; static ScheduleStatus of(EpisodeStatus s) => switch (s) { EpisodeStatus.available => available, @@ -45,6 +48,8 @@ class ScheduleEntry { final int? season; /// Play target once available. final String? jellyfinSeriesId; + /// The series is on Jellyfin (tracked or not). + bool get onJellyfin => jellyfinSeriesId != null; const ScheduleEntry({ required this.airing, @@ -89,9 +94,18 @@ List matchSchedule( List airings, { required List schedule, List series = const [], + List library = const [], DateTime? now, }) { final at = now ?? DateTime.now(); + // AniList id -> (Jellyfin series, its TVDB season) from /library. + final lib = {}; + for (final item in library) { + if (item.jellyfinSeriesId.isEmpty) continue; + for (final e in item.anilistIds.entries) { + lib[e.value] ??= (item.jellyfinSeriesId, e.key); + } + } final refs = {}; for (final s in series) { final id = s.anilistId; @@ -105,6 +119,9 @@ List matchSchedule( if (id == null) continue; final ref = refs[id] ??= _SeriesRef(it.seriesId, it.jellyfinSeriesId, it.followed); if (it.followed) ref.followed = true; + // AnimeFlow's AniList episode number first: TVDB numbering can run on + // across split cours. + if (e.anilistEpisode != null) byEp.putIfAbsent('$id#${e.anilistEpisode}', () => it); byEp.putIfAbsent('$id#${e.episode}', () => it); if (e.absolute != null) byAbs.putIfAbsent('$id#${e.absolute}', () => it); if (e.airDate != null) byDay.putIfAbsent('$id@${_day(e.airDate!)}', () => it); @@ -126,17 +143,26 @@ List matchSchedule( followed: it.followed || (ref?.followed ?? false), seriesId: it.seriesId, season: e.season, - jellyfinSeriesId: it.jellyfinSeriesId ?? ref?.jellyfinSeriesId, + jellyfinSeriesId: it.jellyfinSeriesId ?? ref?.jellyfinSeriesId ?? lib[id]?.$1, + ); + } + if (ref == null) { + final onJf = lib[id]; + if (onJf == null) return ScheduleEntry(airing: a, status: ScheduleStatus.notTracked); + return ScheduleEntry( + airing: a, + status: a.airingAt.isAfter(at) ? ScheduleStatus.upcoming : ScheduleStatus.inLibrary, + season: onJf.$2, + jellyfinSeriesId: onJf.$1, ); } - if (ref == null) return ScheduleEntry(airing: a, status: ScheduleStatus.notTracked); return ScheduleEntry( airing: a, status: a.airingAt.isAfter(at) ? ScheduleStatus.upcoming : ScheduleStatus.missing, tracked: true, followed: ref.followed, seriesId: ref.seriesId, - jellyfinSeriesId: ref.jellyfinSeriesId, + jellyfinSeriesId: ref.jellyfinSeriesId ?? lib[id]?.$1, ); }(), ]; @@ -207,7 +233,7 @@ 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, now: now) + ? 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, @@ -220,28 +246,36 @@ class ScheduleRepository { ); } - Future<({AnimeFlowMe? me, List schedule, List series, Object? error})> + Future<({AnimeFlowMe? me, List schedule, List series, List library, Object? error})> _animeFlow(DateTime from, DateTime to, bool force) async { final c = animeFlow; if (c == null) { return (me: null, schedule: const [], series: const [], + library: const [], error: 'AnimeFlow is not configured'); } try { if (force) c.clearCache(); final me = await c.me(); final (sched, series) = await (c.schedule(from, to), c.series()).wait; - return (me: me, schedule: sched.items, series: series, error: null); + // The library only adds "on Jellyfin" for untracked shows: its + // failure never fails the schedule. + List library = const []; + try { + library = await c.library(); + } catch (_) {} + return (me: me, schedule: sched.items, series: series, library: library, error: null); } on ParallelWaitError catch (e) { final errs = e.errors as (AsyncError?, AsyncError?); return ( me: null, schedule: const [], series: const [], + library: const [], error: (errs.$1 ?? errs.$2)!.error, ); } catch (e) { - return (me: null, schedule: const [], series: const [], error: e); + return (me: null, schedule: const [], series: const [], library: const [], error: e); } } diff --git a/apps/desktop/lib/src/schedule/ui/schedule_card.dart b/apps/desktop/lib/src/schedule/ui/schedule_card.dart index a8159a3..3636987 100644 --- a/apps/desktop/lib/src/schedule/ui/schedule_card.dart +++ b/apps/desktop/lib/src/schedule/ui/schedule_card.dart @@ -47,6 +47,7 @@ String countdownText(DateTime at, DateTime now) { ScheduleStatus.failed => ('failed${e.reason == null ? '' : ' ยท ${e.reason}'}', Colors.redAccent, Icons.error_outline), 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), }; 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 5ba3472..b7995b8 100644 --- a/apps/desktop/lib/src/schedule/ui/schedule_page.dart +++ b/apps/desktop/lib/src/schedule/ui/schedule_page.dart @@ -188,7 +188,7 @@ class _SchedulePageState extends State { bool _followed(ScheduleEntry e) => (e.seriesId == null ? null : _followOverride[e.seriesId]) ?? e.followed; bool _shows(ScheduleEntry e) => switch (_filter) { - ScheduleFilter.jellyfin => e.tracked, + ScheduleFilter.jellyfin => e.tracked || e.onJellyfin, ScheduleFilter.following => _followed(e), ScheduleFilter.all => _niche || e.tracked || e.airing.popularity >= SchedulePage.nicheBelow, }; @@ -214,7 +214,7 @@ class _SchedulePageState extends State { } (CardAction, String?) _actionFor(ScheduleEntry e) { - if (e.status == ScheduleStatus.available) { + if (e.status == ScheduleStatus.available || e.status == ScheduleStatus.inLibrary) { if (!widget.online) return (CardAction.playOffline, 'Join or host a server to put it up'); if (e.jellyfinSeriesId == null) return (CardAction.putUp, 'Not linked to a Jellyfin series yet'); return (CardAction.putUp, null); @@ -226,7 +226,8 @@ class _SchedulePageState extends State { ? CardAction.request : CardAction.none; } else { - kind = me != null ? CardAction.requestSeries : CardAction.none; + // On Jellyfin already (owner added it by hand): nothing to request. + kind = me != null && !e.onJellyfin ? CardAction.requestSeries : CardAction.none; } if (kind == CardAction.none) return (kind, null); if (_isRequested(e)) return (CardAction.requested, null); diff --git a/apps/desktop/test/schedule_model_test.dart b/apps/desktop/test/schedule_model_test.dart index d8c42d6..2981f03 100644 --- a/apps/desktop/test/schedule_model_test.dart +++ b/apps/desktop/test/schedule_model_test.dart @@ -41,6 +41,47 @@ AnimeFlowScheduleItem item(int? anilistId, int ep, EpisodeStatus status, void main() { group('matchSchedule', () { + test('shows on Jellyfin but not in AnimeFlow come from /library', () { + final out = matchSchedule( + [ + airing(172192, 1, DateTime(2026, 10, 6, 18)), // aired + airing(172192, 2, DateTime(2026, 10, 13, 18)), // upcoming + airing(999, 1, DateTime(2026, 10, 6, 18)), // nowhere + ], + schedule: const [], + library: const [ + AnimeFlowLibraryItem(jellyfinSeriesId: 'jfR', name: 'Returner', anilistIds: {1: 163142, 2: 172192}), + ], + now: now, + ); + expect(out.map((e) => e.status), + [ScheduleStatus.inLibrary, ScheduleStatus.upcoming, ScheduleStatus.notTracked]); + expect(out[0].jellyfinSeriesId, 'jfR'); + expect(out[0].season, 2, reason: 'the TVDB season of that AniList entry'); + expect(out[0].tracked, isFalse); + expect(out[1].onJellyfin, isTrue); + expect(out[2].onJellyfin, isFalse); + }); + + test("AnimeFlow's anilist_episode wins over TVDB numbering (split cour)", () { + // S2E13 in TVDB is part 2 (AniList 300) episode 1. + final out = matchSchedule( + [airing(300, 1, DateTime(2026, 10, 7, 20))], + schedule: [ + AnimeFlowScheduleItem( + episode: const AnimeFlowEpisode( + season: 2, episode: 13, anilistId: 300, anilistEpisode: 1, status: EpisodeStatus.available), + seriesId: 7, + title: 'Show', + jellyfinSeriesId: 'jf7', + ), + ], + now: now, + ); + expect(out.single.status, ScheduleStatus.available); + expect(out.single.season, 2); + }); + test('by AniList id + episode, per season ids', () { // One AnimeFlow series (7) spans two AniList entries: S1 = 100, S2 = 200. final out = matchSchedule(