Schedule: On Jellyfin only when the episode itself is there; Aired · not on Jellyfin yet otherwise

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 <noreply@anthropic.com>
This commit is contained in:
foxyzandClaude Opus 5.5 committed 2026-10-10 20:15:18 +03:00
1 parent cc45b86271
commit 884f208330
7 files changed
+197 -18

No files matched your search

@@ -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<ScheduleEntry> matchSchedule(
DateTime? now,
}) {
final at = now ?? DateTime.now();
// AniList id -> (Jellyfin series, its TVDB season) from /library.
final lib = <int, (String, int)>{};
// AniList id -> (Jellyfin series, its TVDB season, TVDB - AniList
// episode offset) from /library.
final lib = <int, (String, int, int)>{};
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 = <int, _SeriesRef>{};
@@ -156,6 +182,7 @@ List<ScheduleEntry> 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<ScheduleEntry> 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<ScheduleEntry> confirmOnJellyfin(List<ScheduleEntry> entries, Map<String, Set<(int, int)>> 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<Set<(int, int)>?> 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<List<ScheduleEntry>> _confirm(List<ScheduleEntry> entries) async {
final lookup = jellyfinEpisodes;
final ids = {
for (final e in entries)
if (e.status == ScheduleStatus.inLibrary && e.jellyfinSeriesId != null) e.jellyfinSeriesId!,
};
final found = <String, Set<(int, int)>>{};
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<AnimeFlowScheduleItem> schedule, List<AnimeFlowSeries> series, List<AnimeFlowLibraryItem> library, Object? error})>
_animeFlow(DateTime from, DateTime to, bool force) async {
final c = animeFlow;
@@ -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<Set<(int, int)>?> 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<JellyfinMedia> resolveJellyfinEpisode(JellyfinAccounts accounts, AnimeFlowClient? flow, ScheduleEntry e) async {
@@ -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 {
@@ -97,18 +97,22 @@ class _SchedulePageState extends State<SchedulePage> {
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();