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 <noreply@anthropic.com>
This commit is contained in:
foxyzandClaude Opus 5.5 committed 2026-10-09 00:01:28 +03:00
1 parent 677ece81c4
commit 2eb6840ec4
5 files changed
+130 -11

No files matched your search

@@ -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<int, int> 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<List<AnimeFlowSeries>> 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<List<AnimeFlowLibraryItem>> 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<AnimeFlowSeries> seriesDetail(int seriesId) async {
final j = await _call('GET', '/series/$seriesId', etag: true);
@@ -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<ScheduleEntry> matchSchedule(
List<Airing> airings, {
required List<AnimeFlowScheduleItem> schedule,
List<AnimeFlowSeries> series = const [],
List<AnimeFlowLibraryItem> library = const [],
DateTime? now,
}) {
final at = now ?? DateTime.now();
// AniList id -> (Jellyfin series, its TVDB season) from /library.
final lib = <int, (String, int)>{};
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 = <int, _SeriesRef>{};
for (final s in series) {
final id = s.anilistId;
@@ -105,6 +119,9 @@ List<ScheduleEntry> 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<ScheduleEntry> 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<AnimeFlowScheduleItem> schedule, List<AnimeFlowSeries> series, Object? error})>
Future<({AnimeFlowMe? me, List<AnimeFlowScheduleItem> schedule, List<AnimeFlowSeries> series, List<AnimeFlowLibraryItem> library, Object? error})>
_animeFlow(DateTime from, DateTime to, bool force) async {
final c = animeFlow;
if (c == null) {
return (me: null, schedule: const <AnimeFlowScheduleItem>[], series: const <AnimeFlowSeries>[],
library: const <AnimeFlowLibraryItem>[],
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<AnimeFlowLibraryItem> 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 <AnimeFlowScheduleItem>[],
series: const <AnimeFlowSeries>[],
library: const <AnimeFlowLibraryItem>[],
error: (errs.$1 ?? errs.$2)!.error,
);
} catch (e) {
return (me: null, schedule: const <AnimeFlowScheduleItem>[], series: const <AnimeFlowSeries>[], error: e);
return (me: null, schedule: const <AnimeFlowScheduleItem>[], series: const <AnimeFlowSeries>[], library: const <AnimeFlowLibraryItem>[], error: e);
}
}
@@ -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 {
@@ -188,7 +188,7 @@ class _SchedulePageState extends State<SchedulePage> {
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<SchedulePage> {
}
(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<SchedulePage> {
? 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);