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:
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;
|
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 {
|
class AnimeFlowEpisode {
|
||||||
/// 0 = specials.
|
/// 0 = specials.
|
||||||
final int season;
|
final int season;
|
||||||
@@ -152,6 +178,9 @@ class AnimeFlowEpisode {
|
|||||||
/// The AniList entry of this episode's season (one AnimeFlow series may
|
/// The AniList entry of this episode's season (one AnimeFlow series may
|
||||||
/// span several AniList entries).
|
/// span several AniList entries).
|
||||||
final int? anilistId;
|
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;
|
final EpisodeStatus status;
|
||||||
/// 0..1, only while downloading.
|
/// 0..1, only while downloading.
|
||||||
final double? progress;
|
final double? progress;
|
||||||
@@ -165,6 +194,7 @@ class AnimeFlowEpisode {
|
|||||||
this.title,
|
this.title,
|
||||||
this.airDate,
|
this.airDate,
|
||||||
this.anilistId,
|
this.anilistId,
|
||||||
|
this.anilistEpisode,
|
||||||
required this.status,
|
required this.status,
|
||||||
this.progress,
|
this.progress,
|
||||||
this.reason,
|
this.reason,
|
||||||
@@ -177,6 +207,7 @@ class AnimeFlowEpisode {
|
|||||||
title: _str(j['episode_title']),
|
title: _str(j['episode_title']),
|
||||||
airDate: _time(j['air_date']),
|
airDate: _time(j['air_date']),
|
||||||
anilistId: _int(j['anilist_id']),
|
anilistId: _int(j['anilist_id']),
|
||||||
|
anilistEpisode: _int(j['anilist_episode']),
|
||||||
status: EpisodeStatus.parse(j['status']),
|
status: EpisodeStatus.parse(j['status']),
|
||||||
progress: (j['progress'] as num?)?.toDouble(),
|
progress: (j['progress'] as num?)?.toDouble(),
|
||||||
reason: _str(j['reason']),
|
reason: _str(j['reason']),
|
||||||
@@ -610,6 +641,17 @@ class AnimeFlowClient {
|
|||||||
Future<List<AnimeFlowSeries>> series() async =>
|
Future<List<AnimeFlowSeries>> series() async =>
|
||||||
[for (final s in _items(await _call('GET', '/series', etag: true))) AnimeFlowSeries.fromJson(s)];
|
[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].
|
/// One series with all its episodes [view].
|
||||||
Future<AnimeFlowSeries> seriesDetail(int seriesId) async {
|
Future<AnimeFlowSeries> seriesDetail(int seriesId) async {
|
||||||
final j = await _call('GET', '/series/$seriesId', etag: true);
|
final j = await _call('GET', '/series/$seriesId', etag: true);
|
||||||
|
|||||||
@@ -16,7 +16,10 @@ enum ScheduleStatus {
|
|||||||
failed,
|
failed,
|
||||||
unmonitored,
|
unmonitored,
|
||||||
/// AnimeFlow does not track this series (or is unreachable).
|
/// 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) {
|
static ScheduleStatus of(EpisodeStatus s) => switch (s) {
|
||||||
EpisodeStatus.available => available,
|
EpisodeStatus.available => available,
|
||||||
@@ -45,6 +48,8 @@ class ScheduleEntry {
|
|||||||
final int? season;
|
final int? season;
|
||||||
/// Play target once available.
|
/// Play target once available.
|
||||||
final String? jellyfinSeriesId;
|
final String? jellyfinSeriesId;
|
||||||
|
/// The series is on Jellyfin (tracked or not).
|
||||||
|
bool get onJellyfin => jellyfinSeriesId != null;
|
||||||
|
|
||||||
const ScheduleEntry({
|
const ScheduleEntry({
|
||||||
required this.airing,
|
required this.airing,
|
||||||
@@ -89,9 +94,18 @@ List<ScheduleEntry> matchSchedule(
|
|||||||
List<Airing> airings, {
|
List<Airing> airings, {
|
||||||
required List<AnimeFlowScheduleItem> schedule,
|
required List<AnimeFlowScheduleItem> schedule,
|
||||||
List<AnimeFlowSeries> series = const [],
|
List<AnimeFlowSeries> series = const [],
|
||||||
|
List<AnimeFlowLibraryItem> library = const [],
|
||||||
DateTime? now,
|
DateTime? now,
|
||||||
}) {
|
}) {
|
||||||
final at = now ?? 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>{};
|
final refs = <int, _SeriesRef>{};
|
||||||
for (final s in series) {
|
for (final s in series) {
|
||||||
final id = s.anilistId;
|
final id = s.anilistId;
|
||||||
@@ -105,6 +119,9 @@ List<ScheduleEntry> matchSchedule(
|
|||||||
if (id == null) continue;
|
if (id == null) continue;
|
||||||
final ref = refs[id] ??= _SeriesRef(it.seriesId, it.jellyfinSeriesId, it.followed);
|
final ref = refs[id] ??= _SeriesRef(it.seriesId, it.jellyfinSeriesId, it.followed);
|
||||||
if (it.followed) ref.followed = true;
|
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);
|
byEp.putIfAbsent('$id#${e.episode}', () => it);
|
||||||
if (e.absolute != null) byAbs.putIfAbsent('$id#${e.absolute}', () => it);
|
if (e.absolute != null) byAbs.putIfAbsent('$id#${e.absolute}', () => it);
|
||||||
if (e.airDate != null) byDay.putIfAbsent('$id@${_day(e.airDate!)}', () => 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),
|
followed: it.followed || (ref?.followed ?? false),
|
||||||
seriesId: it.seriesId,
|
seriesId: it.seriesId,
|
||||||
season: e.season,
|
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(
|
return ScheduleEntry(
|
||||||
airing: a,
|
airing: a,
|
||||||
status: a.airingAt.isAfter(at) ? ScheduleStatus.upcoming : ScheduleStatus.missing,
|
status: a.airingAt.isAfter(at) ? ScheduleStatus.upcoming : ScheduleStatus.missing,
|
||||||
tracked: true,
|
tracked: true,
|
||||||
followed: ref.followed,
|
followed: ref.followed,
|
||||||
seriesId: ref.seriesId,
|
seriesId: ref.seriesId,
|
||||||
jellyfinSeriesId: ref.jellyfinSeriesId,
|
jellyfinSeriesId: ref.jellyfinSeriesId ?? lib[id]?.$1,
|
||||||
);
|
);
|
||||||
}(),
|
}(),
|
||||||
];
|
];
|
||||||
@@ -207,7 +233,7 @@ class ScheduleRepository {
|
|||||||
final flow = await flowF;
|
final flow = await flowF;
|
||||||
final airings = [...ani.airings]..sort((a, b) => a.airingAt.compareTo(b.airingAt));
|
final airings = [...ani.airings]..sort((a, b) => a.airingAt.compareTo(b.airingAt));
|
||||||
final entries = flow.error == null
|
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)];
|
: [for (final a in airings) untrackedEntry(a, now)];
|
||||||
return ScheduleSnapshot(
|
return ScheduleSnapshot(
|
||||||
from: start,
|
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 {
|
_animeFlow(DateTime from, DateTime to, bool force) async {
|
||||||
final c = animeFlow;
|
final c = animeFlow;
|
||||||
if (c == null) {
|
if (c == null) {
|
||||||
return (me: null, schedule: const <AnimeFlowScheduleItem>[], series: const <AnimeFlowSeries>[],
|
return (me: null, schedule: const <AnimeFlowScheduleItem>[], series: const <AnimeFlowSeries>[],
|
||||||
|
library: const <AnimeFlowLibraryItem>[],
|
||||||
error: 'AnimeFlow is not configured');
|
error: 'AnimeFlow is not configured');
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
if (force) c.clearCache();
|
if (force) c.clearCache();
|
||||||
final me = await c.me();
|
final me = await c.me();
|
||||||
final (sched, series) = await (c.schedule(from, to), c.series()).wait;
|
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) {
|
} on ParallelWaitError catch (e) {
|
||||||
final errs = e.errors as (AsyncError?, AsyncError?);
|
final errs = e.errors as (AsyncError?, AsyncError?);
|
||||||
return (
|
return (
|
||||||
me: null,
|
me: null,
|
||||||
schedule: const <AnimeFlowScheduleItem>[],
|
schedule: const <AnimeFlowScheduleItem>[],
|
||||||
series: const <AnimeFlowSeries>[],
|
series: const <AnimeFlowSeries>[],
|
||||||
|
library: const <AnimeFlowLibraryItem>[],
|
||||||
error: (errs.$1 ?? errs.$2)!.error,
|
error: (errs.$1 ?? errs.$2)!.error,
|
||||||
);
|
);
|
||||||
} catch (e) {
|
} 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.failed => ('failed${e.reason == null ? '' : ' · ${e.reason}'}', Colors.redAccent, Icons.error_outline),
|
||||||
ScheduleStatus.unmonitored => ('not monitored', Colors.grey, Icons.visibility_off_outlined),
|
ScheduleStatus.unmonitored => ('not monitored', Colors.grey, Icons.visibility_off_outlined),
|
||||||
ScheduleStatus.notTracked => ('not in AnimeFlow', Colors.grey, Icons.public),
|
ScheduleStatus.notTracked => ('not in AnimeFlow', Colors.grey, Icons.public),
|
||||||
|
ScheduleStatus.inLibrary => ('on Jellyfin', Colors.greenAccent, Icons.video_library_outlined),
|
||||||
};
|
};
|
||||||
|
|
||||||
class DayColumn extends StatelessWidget {
|
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 _followed(ScheduleEntry e) => (e.seriesId == null ? null : _followOverride[e.seriesId]) ?? e.followed;
|
||||||
|
|
||||||
bool _shows(ScheduleEntry e) => switch (_filter) {
|
bool _shows(ScheduleEntry e) => switch (_filter) {
|
||||||
ScheduleFilter.jellyfin => e.tracked,
|
ScheduleFilter.jellyfin => e.tracked || e.onJellyfin,
|
||||||
ScheduleFilter.following => _followed(e),
|
ScheduleFilter.following => _followed(e),
|
||||||
ScheduleFilter.all => _niche || e.tracked || e.airing.popularity >= SchedulePage.nicheBelow,
|
ScheduleFilter.all => _niche || e.tracked || e.airing.popularity >= SchedulePage.nicheBelow,
|
||||||
};
|
};
|
||||||
@@ -214,7 +214,7 @@ class _SchedulePageState extends State<SchedulePage> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
(CardAction, String?) _actionFor(ScheduleEntry e) {
|
(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 (!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');
|
if (e.jellyfinSeriesId == null) return (CardAction.putUp, 'Not linked to a Jellyfin series yet');
|
||||||
return (CardAction.putUp, null);
|
return (CardAction.putUp, null);
|
||||||
@@ -226,7 +226,8 @@ class _SchedulePageState extends State<SchedulePage> {
|
|||||||
? CardAction.request
|
? CardAction.request
|
||||||
: CardAction.none;
|
: CardAction.none;
|
||||||
} else {
|
} 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 (kind == CardAction.none) return (kind, null);
|
||||||
if (_isRequested(e)) return (CardAction.requested, null);
|
if (_isRequested(e)) return (CardAction.requested, null);
|
||||||
|
|||||||
@@ -41,6 +41,47 @@ AnimeFlowScheduleItem item(int? anilistId, int ep, EpisodeStatus status,
|
|||||||
|
|
||||||
void main() {
|
void main() {
|
||||||
group('matchSchedule', () {
|
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', () {
|
test('by AniList id + episode, per season ids', () {
|
||||||
// One AnimeFlow series (7) spans two AniList entries: S1 = 100, S2 = 200.
|
// One AnimeFlow series (7) spans two AniList entries: S1 = 100, S2 = 200.
|
||||||
final out = matchSchedule(
|
final out = matchSchedule(
|
||||||
|
|||||||
Reference in new issue
Block a user