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:
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();
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in new issue
Block a user