Schedule: watched marks and progress on episodes, "waiting for release" instead of a Request for missing ones, air dates matched as Japan days

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
foxyzandClaude Opus 5.5 committed 2026-10-12 18:46:41 +03:00
1 parent 1ec4fc8b13
commit a407d3cbb0
9 files changed
+379 -117

No files matched your search

+48 -7
View File
@@ -102,11 +102,43 @@ void main() {
);
expect(matched.map((e) => e.jellyfinEpisode), [13, 14, 3]);
final out = confirmOnJellyfin(matched, {
'jfJ': {(6, 12), (6, 13), (1, 14)},
'jfJ': const [
JellyfinEpisodeRef(6, 12),
JellyfinEpisodeRef(6, 13, watch: EpisodeWatch(progress: 0.4)),
JellyfinEpisodeRef(1, 14),
],
});
expect(out.map((e) => e.status),
[ScheduleStatus.inLibrary, ScheduleStatus.notOnJellyfin, ScheduleStatus.aired]);
expect(out[1].jellyfinSeriesId, 'jfJ');
expect(out.map((e) => e.watch), [const EpisodeWatch(progress: 0.4), null, null]);
});
test("confirmOnJellyfin: AnimeFlow's available episodes get this user's watched state", () {
final matched = matchSchedule(
[
airing(100, 3, DateTime(2026, 10, 1, 18)), // S1E3 by number
airing(100, 4, DateTime.utc(2026, 10, 7, 14)), // numbering misses: premiere day
airing(200, 1, DateTime(2026, 10, 1, 18)), // series not looked up
],
schedule: [
item(100, 3, EpisodeStatus.available),
item(100, 40, EpisodeStatus.available, absolute: 4),
item(200, 1, EpisodeStatus.available),
],
now: now,
);
expect(matched.map((e) => e.jellyfinEpisode), [3, 40, 1]);
final out = confirmOnJellyfin(matched, {
'jf7': [
const JellyfinEpisodeRef(1, 3, watch: EpisodeWatch(played: true)),
// Midnight JST of Oct 7.
JellyfinEpisodeRef(1, 4, premiere: DateTime.utc(2026, 10, 6, 15), watch: const EpisodeWatch(progress: 0.5)),
],
});
expect(out.map((e) => e.status), everyElement(ScheduleStatus.available));
expect(out.map((e) => e.watch),
[const EpisodeWatch(played: true), const EpisodeWatch(progress: 0.5), null]);
});
test("AnimeFlow's anilist_episode wins over TVDB numbering (split cour)", () {
@@ -170,15 +202,22 @@ void main() {
expect(out.single.tracked, isFalse);
});
test('absolute number, then same local air day', () {
test('absolute number, then the same Japan air day', () {
final out = matchSchedule(
[
airing(300, 1100, DateTime(2026, 10, 5, 9)), // AniList counts absolute
airing(400, 13, DateTime(2026, 10, 6, 23, 30)), // numbering differs
// Numbering differs; Oct 6 23:30 JST, the evening before in
// Europe and America.
airing(400, 13, DateTime.utc(2026, 10, 6, 14, 30)),
// Oct 8 01:30 JST, a late-night slot listed on Oct 7.
airing(401, 13, DateTime.utc(2026, 10, 7, 16, 30)),
],
schedule: [
item(300, 5, EpisodeStatus.available, season: 22, absolute: 1100),
item(400, 1, EpisodeStatus.failed, season: 2, air: DateTime(2026, 10, 6, 0, 15), reason: 'No release'),
// Air dates are midnight JST (Oct 6, 15:00 UTC the day before)...
item(400, 1, EpisodeStatus.failed, season: 2, air: DateTime.utc(2026, 10, 5, 15), reason: 'No release'),
// ... or midnight UTC from older servers.
item(401, 1, EpisodeStatus.available, air: DateTime.utc(2026, 10, 7)),
],
now: now,
);
@@ -186,12 +225,14 @@ void main() {
expect(out[0].season, 22);
expect(out[1].status, ScheduleStatus.failed);
expect(out[1].reason, 'No release');
expect(out[2].status, ScheduleStatus.available);
});
test('a different local day does not match', () {
test('a different Japan day does not match', () {
final out = matchSchedule(
[airing(400, 13, DateTime(2026, 10, 7, 0, 30))],
schedule: [item(400, 1, EpisodeStatus.available, air: DateTime(2026, 10, 6, 23, 50))],
// Oct 7 20:00 JST; the air date is Oct 6 (midnight JST).
[airing(400, 13, DateTime.utc(2026, 10, 7, 11))],
schedule: [item(400, 1, EpisodeStatus.available, air: DateTime.utc(2026, 10, 5, 15))],
now: now,
);
// Tracked (the id is in the schedule) but unmatched and aired.
+27 -11
View File
@@ -50,6 +50,7 @@ class FakeJf implements JellyfinHttp {
'SeriesName': 'Avail Show',
'IndexNumber': 2,
'ParentIndexNumber': 1,
'UserData': {'Played': true},
},
{
'Id': 'ep3',
@@ -58,6 +59,8 @@ class FakeJf implements JellyfinHttp {
'SeriesName': 'Avail Show',
'IndexNumber': 3,
'ParentIndexNumber': 1,
'RunTimeTicks': 1000,
'UserData': {'PlaybackPositionTicks': 250},
},
],
'TotalRecordCount': 2,
@@ -91,6 +94,7 @@ List<ScheduleEntry> entries() => [
seriesId: 7,
season: 1,
jellyfinSeriesId: 'jf7',
watch: const EpisodeWatch(played: true),
),
ScheduleEntry(
airing: airing(222, 'Missing Show', 5, day(0, 10)),
@@ -99,9 +103,8 @@ List<ScheduleEntry> entries() => [
seriesId: 8,
),
ScheduleEntry(
airing: airing(333, 'Failed Show', 2, day(0, 11)),
status: ScheduleStatus.failed,
reason: 'no seeders',
airing: airing(333, 'Unmonitored Show', 2, day(0, 11)),
status: ScheduleStatus.unmonitored,
tracked: true,
seriesId: 9,
season: 1,
@@ -283,8 +286,13 @@ void main() {
expect(find.byKey(ValueKey('day-${w.year}-${w.month}-${w.day}')), findsNothing);
expect(card(111, 3), findsOneWidget);
expect(find.text('on Jellyfin'), findsOneWidget);
expect(find.text('missing'), findsOneWidget);
expect(find.text('failed · no seeders'), findsOneWidget);
// AnimeFlow's "missing": waiting for a release, nothing to request;
// only the unmonitored one has Request.
expect(find.text('waiting for release'), findsOneWidget);
expect(action(222, 5), findsNothing);
expect(action(333, 2), findsOneWidget);
expect(find.byKey(const ValueKey('watched-111-3')), findsOneWidget);
expect(find.text('not monitored'), findsOneWidget);
expect(find.text('downloading 42%'), findsOneWidget);
expect(find.text('Put it up'), findsOneWidget);
// On Jellyfin: untracked entries are hidden.
@@ -316,7 +324,14 @@ void main() {
});
testWidgets('request on a tracked episode sends AnimeFlow numbering', (tester) async {
await pumpPage(tester);
await pumpPage(
tester,
snap: () => snapshot(
list: [
for (final e in entries()) e.airing.anilistId == 222 ? e.copyWith(status: ScheduleStatus.unmonitored) : e,
],
),
);
await tester.tap(action(222, 5));
await io(tester);
final post = server.seen.where((r) => r.method == 'POST' && r.uri.path.endsWith('/requests')).single;
@@ -384,7 +399,7 @@ void main() {
testWidgets('without the request scope the button is disabled with a hint', (tester) async {
await pumpPage(tester, snap: () => snapshot(me: viewer));
final b = widgetOf<OutlinedButton>(tester, action(222, 5));
final b = widgetOf<OutlinedButton>(tester, action(333, 2));
expect(b.onPressed, isNull);
expect(find.byWidgetPredicate((w) => w is Tooltip && (w.message ?? '').contains('owner')), findsWidgets);
expect(find.text('My requests'), findsOneWidget);
@@ -437,12 +452,13 @@ void main() {
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'));
final eps = await tester.runAsync(() => jellyfinScheduleEpisodes(accounts, flow, 'jf7'));
expect(eps!.map((e) => (e.season, e.episode)), [(1, 2), (1, 3)]);
expect(eps.map((e) => e.watch), [const EpisodeWatch(played: true), const EpisodeWatch(progress: 0.25)]);
await tester.runAsync(() => jellyfinScheduleEpisodes(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);
expect(await tester.runAsync(() => jellyfinScheduleEpisodes(none, flow, 'jf7')), isNull);
await pumpPage(
tester,