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
+354 -92

No files matched your search

+12
View File
@@ -203,6 +203,18 @@ Foxyz left"). A Jellyfin item needs someone with an account on that
server. Up next is kept by the server while it runs; restarting the server. Up next is kept by the server while it runs; restarting the
server empties it. server empties it.
## Schedule
The week's airing anime, one column per day (AniList), each episode
marked with what the Jellyfin server has of it when AnimeFlow is
connected (Settings, Jellyfin & integrations): **on Jellyfin** (Put it
up, right-click to queue), **downloading**, **waiting for release**
(aired in Japan, AnimeFlow downloads it by itself once a release
appears), **failed**, **not monitored** (Request asks AnimeFlow for it),
or the time left until it airs. A show AnimeFlow does not follow can be asked for with **Request
series**. Episodes you already watched on Jellyfin carry a check on the
cover; one you started shows how far you got.
## Dedicated server ## Dedicated server
For an always-on box (no display needed): the release tarball also contains For an always-on box (no display needed): the release tarball also contains
@@ -198,6 +198,8 @@ class AnimeFlowEpisode {
final int episode; final int episode;
final int? absolute; final int? absolute;
final String? title; final String? title;
/// Date-only: midnight Japan time of the broadcast day. Compare it as a
/// Japan day (`sameAirDay` in schedule_model.dart), never a local one.
final DateTime? airDate; final DateTime? airDate;
/// 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).
+111 -29
View File
@@ -12,8 +12,12 @@ enum ScheduleStatus {
available, available,
downloading, downloading,
upcoming, upcoming,
/// AnimeFlow's `missing`: aired, monitored, no release grabbed yet;
/// AnimeFlow grabs it by itself once one appears (nothing to request).
/// Also an aired episode of a tracked series AnimeFlow has no row for.
missing, missing,
failed, failed,
/// AnimeFlow does not monitor it: the only status worth a Request.
unmonitored, unmonitored,
/// AnimeFlow does not track this series (or is unreachable). /// AnimeFlow does not track this series (or is unreachable).
notTracked, notTracked,
@@ -53,11 +57,15 @@ class ScheduleEntry {
/// The AnimeFlow season of the matched episode (requests); untracked /// The AnimeFlow season of the matched episode (requests); untracked
/// on Jellyfin: the TVDB season of the AniList entry. /// on Jellyfin: the TVDB season of the AniList entry.
final int? season; final int? season;
/// Untracked on Jellyfin: the TVDB episode number in [season] (AniList /// The TVDB episode number in [season]: tracked, AnimeFlow's own;
/// episode + the `anilist_map` run's offset). /// untracked on Jellyfin, AniList episode + the `anilist_map` run's
/// offset.
final int? jellyfinEpisode; final int? jellyfinEpisode;
/// Play target once available. /// Play target once available.
final String? jellyfinSeriesId; final String? jellyfinSeriesId;
/// This user's progress on the Jellyfin episode ([confirmOnJellyfin]);
/// null when it is not on Jellyfin or was not looked up.
final EpisodeWatch? watch;
/// The series is on Jellyfin (tracked or not). /// The series is on Jellyfin (tracked or not).
bool get onJellyfin => jellyfinSeriesId != null; bool get onJellyfin => jellyfinSeriesId != null;
@@ -71,11 +79,12 @@ class ScheduleEntry {
this.season, this.season,
this.jellyfinEpisode, this.jellyfinEpisode,
this.jellyfinSeriesId, this.jellyfinSeriesId,
this.watch,
}); });
ScheduleEntry withStatus(ScheduleStatus s) => ScheduleEntry( ScheduleEntry copyWith({ScheduleStatus? status, EpisodeWatch? watch}) => ScheduleEntry(
airing: airing, airing: airing,
status: s, status: status ?? this.status,
progress: progress, progress: progress,
reason: reason, reason: reason,
tracked: tracked, tracked: tracked,
@@ -83,6 +92,7 @@ class ScheduleEntry {
season: season, season: season,
jellyfinEpisode: jellyfinEpisode, jellyfinEpisode: jellyfinEpisode,
jellyfinSeriesId: jellyfinSeriesId, jellyfinSeriesId: jellyfinSeriesId,
watch: watch ?? this.watch,
); );
@override @override
@@ -95,11 +105,59 @@ ScheduleEntry untrackedEntry(Airing a, DateTime now) => ScheduleEntry(
status: a.airingAt.isAfter(now) ? ScheduleStatus.upcoming : ScheduleStatus.notTracked, status: a.airingAt.isAfter(now) ? ScheduleStatus.upcoming : ScheduleStatus.notTracked,
); );
String _day(DateTime d) { /// This user's state of a Jellyfin episode (its `UserData`).
final l = d.toLocal(); class EpisodeWatch {
return '${l.year}-${l.month}-${l.day}'; final bool played;
/// 0..1: the resume point of a partly watched episode (0 = none).
final double progress;
const EpisodeWatch({this.played = false, this.progress = 0});
@override
bool operator ==(Object other) => other is EpisodeWatch && other.played == played && other.progress == progress;
@override
int get hashCode => Object.hash(played, progress);
@override
String toString() => 'EpisodeWatch(${played ? 'played' : '$progress'})';
} }
/// One episode Jellyfin has of a series, as the schedule needs it.
class JellyfinEpisodeRef {
final int season;
final int episode;
/// Jellyfin's date-only premiere date.
final DateTime? premiere;
final EpisodeWatch watch;
const JellyfinEpisodeRef(this.season, this.episode, {this.premiere, this.watch = const EpisodeWatch()});
}
// Air dates (AnimeFlow's `air_date`, Jellyfin's premiere date) are
// date-only: the Japanese broadcast day, sent as midnight Japan time (or
// midnight UTC by older servers). Both land on the right day read as a
// Japan (UTC+9, no DST) calendar day, never as a local one: midnight JST
// is the evening before in Europe and America.
const _jst = Duration(hours: 9);
/// The Japan calendar day of [t].
DateTime _jstDay(DateTime t) {
final j = t.toUtc().add(_jst);
return DateTime.utc(j.year, j.month, j.day);
}
String _dayKey(DateTime d) => '${d.year}-${d.month}-${d.day}';
/// The Japan days whose air date fits a broadcast at [airingAt]: its own
/// and, for a late-night slot (before 05:00 JST, "25:30"), the evening
/// before, which listings often give.
List<DateTime> _airDays(DateTime airingAt) {
final j = airingAt.toUtc().add(_jst);
final d = DateTime.utc(j.year, j.month, j.day);
return [d, if (j.hour < 5) DateTime.utc(j.year, j.month, j.day - 1)];
}
/// The date-only [airDate] is the broadcast day of [airingAt] (see
/// [_airDays]).
bool sameAirDay(DateTime airDate, DateTime airingAt) => _airDays(airingAt).contains(_jstDay(airDate));
class _SeriesRef { class _SeriesRef {
final int seriesId; final int seriesId;
final String? jellyfinSeriesId; final String? jellyfinSeriesId;
@@ -155,7 +213,7 @@ List<ScheduleEntry> matchSchedule(
} }
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@${_dayKey(_jstDay(e.airDate!))}', () => it);
} }
return [ return [
for (final a in airings) for (final a in airings)
@@ -164,7 +222,9 @@ List<ScheduleEntry> matchSchedule(
final ref = refs[id]; final ref = refs[id];
final it = numbered.contains(id) final it = numbered.contains(id)
? byAni['$id#${a.episode}'] ? byAni['$id#${a.episode}']
: byEp['$id#${a.episode}'] ?? byAbs['$id#${a.episode}'] ?? byDay['$id@${_day(a.airingAt)}']; : byEp['$id#${a.episode}'] ??
byAbs['$id#${a.episode}'] ??
_airDays(a.airingAt).map((d) => byDay['$id@${_dayKey(d)}']).nonNulls.firstOrNull;
if (it != null) { if (it != null) {
final e = it.episode; final e = it.episode;
return ScheduleEntry( return ScheduleEntry(
@@ -175,6 +235,7 @@ List<ScheduleEntry> matchSchedule(
tracked: true, tracked: true,
seriesId: it.seriesId, seriesId: it.seriesId,
season: e.season, season: e.season,
jellyfinEpisode: e.episode,
jellyfinSeriesId: it.jellyfinSeriesId ?? ref?.jellyfinSeriesId ?? lib[id]?.$1, jellyfinSeriesId: it.jellyfinSeriesId ?? ref?.jellyfinSeriesId ?? lib[id]?.$1,
); );
} }
@@ -200,26 +261,45 @@ List<ScheduleEntry> matchSchedule(
]; ];
} }
/// Checks [matchSchedule]'s [ScheduleStatus.inLibrary] entries (series /// The episodes [confirmOnJellyfin] looks up: on Jellyfin, untracked
/// on Jellyfin) against the episodes Jellyfin has: [episodes] maps a /// ([ScheduleStatus.inLibrary]) or available through AnimeFlow.
/// Jellyfin series id to its (season, episode) numbers. Found stays bool needsJellyfinLookup(ScheduleEntry e) =>
/// inLibrary, absent becomes [ScheduleStatus.notOnJellyfin], a series e.jellyfinSeriesId != null && (e.status == ScheduleStatus.inLibrary || e.status == ScheduleStatus.available);
/// missing from [episodes] (not looked up) [ScheduleStatus.aired].
List<ScheduleEntry> confirmOnJellyfin(List<ScheduleEntry> entries, Map<String, Set<(int, int)>> episodes) => [ /// Checks [matchSchedule]'s entries against the episodes Jellyfin has:
/// [episodes] maps a Jellyfin series id to its episodes. An
/// [ScheduleStatus.inLibrary] one found stays inLibrary, absent becomes
/// [ScheduleStatus.notOnJellyfin], a series missing from [episodes] (not
/// looked up) [ScheduleStatus.aired]. Found episodes (inLibrary or
/// available) get this user's [ScheduleEntry.watch]: (season, episode),
/// else the one premiered that day.
List<ScheduleEntry> confirmOnJellyfin(List<ScheduleEntry> entries, Map<String, List<JellyfinEpisodeRef>> episodes) => [
for (final e in entries) for (final e in entries)
if (e.status != ScheduleStatus.inLibrary) if (!needsJellyfinLookup(e))
e e
else else
switch (episodes[e.jellyfinSeriesId]) { switch ((episodes[e.jellyfinSeriesId], e.status)) {
null => e.withStatus(ScheduleStatus.aired), (null, ScheduleStatus.inLibrary) => e.copyWith(status: ScheduleStatus.aired),
final have when have.contains((e.season, e.jellyfinEpisode)) => e, (null, _) => e,
_ => e.withStatus(ScheduleStatus.notOnJellyfin), (final have?, _) => switch (_episodeOf(have, e)) {
final x? => e.copyWith(watch: x.watch),
null when e.status == ScheduleStatus.inLibrary => e.copyWith(status: ScheduleStatus.notOnJellyfin),
null => e,
},
}, },
]; ];
/// The (season, episode) numbers Jellyfin has of a series; null when it JellyfinEpisodeRef? _episodeOf(List<JellyfinEpisodeRef> have, ScheduleEntry e) {
/// cannot tell (no linked account for that server). final byNumber = have.where((x) => x.season == e.season && x.episode == e.jellyfinEpisode).firstOrNull;
typedef JellyfinEpisodeLookup = Future<Set<(int, int)>?> Function(String jellyfinSeriesId); if (byNumber != null || !e.tracked) return byNumber;
// AnimeFlow's numbering missed (specials, renumbered seasons): the
// episode premiered that day, when only one did.
return have.where((x) => x.premiere != null && sameAirDay(x.premiere!, e.airing.airingAt)).singleOrNull;
}
/// The episodes Jellyfin has of a series, as the linked account sees
/// them; null when it cannot tell (no linked account for that server).
typedef JellyfinEpisodeLookup = Future<List<JellyfinEpisodeRef>?> Function(String jellyfinSeriesId);
/// One loaded window. Errors are display text; [entries] is empty only /// One loaded window. Errors are display text; [entries] is empty only
/// when AniList failed with nothing cached. /// when AniList failed with nothing cached.
@@ -262,8 +342,9 @@ class ScheduleRepository {
final AniListClient aniList; final AniListClient aniList;
/// Null = not configured (AniList only). /// Null = not configured (AniList only).
final AnimeFlowClient? animeFlow; final AnimeFlowClient? animeFlow;
/// Confirms "on Jellyfin" per episode ([confirmOnJellyfin]); null = /// Confirms "on Jellyfin" and this user's watched state per episode
/// never confirmed (aired entries read [ScheduleStatus.aired]). /// ([confirmOnJellyfin]); null = never confirmed (aired entries read
/// [ScheduleStatus.aired], nothing shows as watched).
final JellyfinEpisodeLookup? jellyfinEpisodes; final JellyfinEpisodeLookup? jellyfinEpisodes;
final DateTime Function() _now; final DateTime Function() _now;
@@ -303,15 +384,16 @@ class ScheduleRepository {
); );
} }
/// Looks up the episodes of every series an inLibrary entry is on, in /// Looks up the episodes of every series an entry on Jellyfin is on
/// parallel; a failed or impossible lookup leaves that series out. /// ([needsJellyfinLookup]), in parallel, one request per series; a
/// failed or impossible lookup leaves that series out.
Future<List<ScheduleEntry>> _confirm(List<ScheduleEntry> entries) async { Future<List<ScheduleEntry>> _confirm(List<ScheduleEntry> entries) async {
final lookup = jellyfinEpisodes; final lookup = jellyfinEpisodes;
final ids = { final ids = {
for (final e in entries) for (final e in entries)
if (e.status == ScheduleStatus.inLibrary && e.jellyfinSeriesId != null) e.jellyfinSeriesId!, if (needsJellyfinLookup(e)) e.jellyfinSeriesId!,
}; };
final found = <String, Set<(int, int)>>{}; final found = <String, List<JellyfinEpisodeRef>>{};
if (lookup != null) { if (lookup != null) {
await Future.wait([ await Future.wait([
for (final id in ids) for (final id in ids)
@@ -1,7 +1,7 @@
import 'package:sync_relay_core/core.dart'; import 'package:sync_relay_core/core.dart';
import '../../jellyfin/accounts.dart'; import '../../jellyfin/accounts.dart';
import '../../jellyfin/format.dart' show pickFor; import '../../jellyfin/format.dart' show pickFor, progressOf;
import '../../screens/home/picked_media.dart'; import '../../screens/home/picked_media.dart';
import '../anilist.dart'; import '../anilist.dart';
import '../animeflow.dart'; import '../animeflow.dart';
@@ -47,14 +47,9 @@ String friendlyError(Object e) => switch (e) {
_ => '$e', _ => '$e',
}; };
bool _sameDay(DateTime a, DateTime b) {
final x = a.toLocal(), y = b.toLocal();
return x.year == y.year && x.month == y.month && x.day == y.day;
}
/// AnimeFlow's episode for [e] (its season and numbering), from the /// AnimeFlow's episode for [e] (its season and numbering), from the
/// series detail: same AniList id and episode, else absolute number, else /// series detail: same AniList id and episode, else absolute number, else
/// the same air day. Null when the entry is not tracked or nothing fits. /// the same air day ([sameAirDay]). Null when the entry is not tracked or nothing fits.
Future<AnimeFlowEpisode?> flowEpisodeFor(AnimeFlowClient client, ScheduleEntry e) async { Future<AnimeFlowEpisode?> flowEpisodeFor(AnimeFlowClient client, ScheduleEntry e) async {
final id = e.seriesId; final id = e.seriesId;
if (id == null) return null; if (id == null) return null;
@@ -63,7 +58,7 @@ Future<AnimeFlowEpisode?> flowEpisodeFor(AnimeFlowClient client, ScheduleEntry e
final mine = eps.where((x) => x.anilistId == null || x.anilistId == a.anilistId).toList(); final mine = eps.where((x) => x.anilistId == null || x.anilistId == a.anilistId).toList();
return mine.where((x) => x.anilistId == a.anilistId && x.episode == a.episode).firstOrNull ?? return mine.where((x) => x.anilistId == a.anilistId && x.episode == a.episode).firstOrNull ??
mine.where((x) => x.absolute == a.episode).firstOrNull ?? mine.where((x) => x.absolute == a.episode).firstOrNull ??
mine.where((x) => x.airDate != null && _sameDay(x.airDate!, a.airingAt)).firstOrNull; mine.where((x) => x.airDate != null && sameAirDay(x.airDate!, a.airingAt)).firstOrNull;
} }
/// The (season, episode) AnimeFlow uses for [e]; falls back to the /// The (season, episode) AnimeFlow uses for [e]; falls back to the
@@ -112,11 +107,13 @@ JellyfinAccount? jellyfinAccountFor(JellyfinAccounts accounts, AnimeFlowClient?
return accounts.defaultAccount; return accounts.defaultAccount;
} }
/// The (season, episode) numbers of the episodes Jellyfin has of /// The episodes Jellyfin has of [jellyfinSeriesId] (numbers, premiere
/// [jellyfinSeriesId], seen by the account AnimeFlow's ids belong to /// date, this user's watched state), seen by the account AnimeFlow's ids
/// ([jellyfinAccountFor]); null without a linked account. One request per /// belong to ([jellyfinAccountFor]): the user's own; null without a
/// series, memoised a few minutes ([JellyfinAccounts.cached]). /// linked account. One request per series, memoised a few minutes
Future<Set<(int, int)>?> jellyfinEpisodeNumbers( /// ([JellyfinAccounts.cached]; a stop report drops it,
/// [JellyfinAccounts.watched]).
Future<List<JellyfinEpisodeRef>?> jellyfinScheduleEpisodes(
JellyfinAccounts accounts, JellyfinAccounts accounts,
AnimeFlowClient? flow, AnimeFlowClient? flow,
String jellyfinSeriesId, String jellyfinSeriesId,
@@ -125,10 +122,20 @@ Future<Set<(int, int)>?> jellyfinEpisodeNumbers(
final account = jellyfinAccountFor(accounts, flow); final account = jellyfinAccountFor(accounts, flow);
final client = account == null ? null : accounts.clientFor(account.serverId); final client = account == null ? null : accounts.clientFor(account.serverId);
if (account == null || client == null) return null; if (account == null || client == null) return null;
return accounts.cached(account.serverId, 'schedule-episodes:$jellyfinSeriesId', () async => { return accounts.cached(
account.serverId,
'schedule-episodes:$jellyfinSeriesId',
() async => [
for (final x in await client.episodes(jellyfinSeriesId)) for (final x in await client.episodes(jellyfinSeriesId))
if (x.parentIndexNumber != null && x.indexNumber != null) (x.parentIndexNumber!, x.indexNumber!), if (x.parentIndexNumber != null && x.indexNumber != null)
}); JellyfinEpisodeRef(
x.parentIndexNumber!,
x.indexNumber!,
premiere: x.premiereDate,
watch: EpisodeWatch(played: x.userData.played, progress: x.userData.played ? 0 : progressOf(x)),
),
],
);
} }
/// The Jellyfin episode of an available [e] as a media pick. Throws /// The Jellyfin episode of an available [e] as a media pick. Throws
@@ -153,7 +160,7 @@ Future<JellyfinMedia> resolveJellyfinEpisode(JellyfinAccounts accounts, AnimeFlo
final a = e.airing; final a = e.airing;
final item = final item =
eps.where((x) => x.parentIndexNumber == n.season && x.indexNumber == n.episode).firstOrNull ?? eps.where((x) => x.parentIndexNumber == n.season && x.indexNumber == n.episode).firstOrNull ??
eps.where((x) => x.premiereDate != null && _sameDay(x.premiereDate!, a.airingAt)).singleOrNull ?? eps.where((x) => x.premiereDate != null && sameAirDay(x.premiereDate!, a.airingAt)).singleOrNull ??
eps.where((x) => x.indexNumber == a.episode).singleOrNull; eps.where((x) => x.indexNumber == a.episode).singleOrNull;
if (item == null) { if (item == null) {
throw ScheduleActionException('Episode ${a.episode} of ${a.title} is not on Jellyfin yet'); throw ScheduleActionException('Episode ${a.episode} of ${a.title} is not on Jellyfin yet');
@@ -4,9 +4,9 @@ import 'package:flutter/material.dart';
import '../schedule_model.dart'; import '../schedule_model.dart';
// One day of the Schedule board and its airing cards: cover poster on the // One day of the Schedule board and its airing cards: cover poster on the
// left (air time on it, download progress under it), title, episode and // left (air time on it, download progress or this user's watch progress
// status on the right, one full-width action below. Compact mode is a // under it, a check once watched), title, episode and status on the
// slim row. // right, one full-width action below. Compact mode is a slim row.
/// What a card's action does, decided by the page. /// What a card's action does, decided by the page.
enum CardAction { enum CardAction {
@@ -35,8 +35,17 @@ String countdownText(DateTime at, DateTime now) {
return 'in ${d.inMinutes}m'; return 'in ${d.inMinutes}m';
} }
/// Aired, tracked, no release yet: AnimeFlow downloads it by itself.
bool _waiting(ScheduleEntry e, DateTime now) =>
e.status == ScheduleStatus.missing ||
(e.status == ScheduleStatus.upcoming && e.tracked && !e.airing.airingAt.isAfter(now));
const waitingHint = 'Aired in Japan; downloads automatically when a release appears';
/// Status line: text, colour, icon. /// Status line: text, colour, icon.
(String, Color, IconData) statusBadge(ScheduleEntry e, DateTime now) => switch (e.status) { (String, Color, IconData) statusBadge(ScheduleEntry e, DateTime now) => _waiting(e, now)
? ('waiting for release', Colors.amber.shade200, Icons.hourglass_top)
: switch (e.status) {
ScheduleStatus.available => ('on Jellyfin', Colors.greenAccent, Icons.check_circle), ScheduleStatus.available => ('on Jellyfin', Colors.greenAccent, Icons.check_circle),
ScheduleStatus.downloading => ( ScheduleStatus.downloading => (
e.progress == null ? 'downloading' : 'downloading ${(e.progress! * 100).round()}%', e.progress == null ? 'downloading' : 'downloading ${(e.progress! * 100).round()}%',
@@ -44,14 +53,21 @@ String countdownText(DateTime at, DateTime now) {
Icons.downloading, Icons.downloading,
), ),
ScheduleStatus.upcoming => (countdownText(e.airing.airingAt, now), Colors.amber, Icons.schedule), ScheduleStatus.upcoming => (countdownText(e.airing.airingAt, now), Colors.amber, Icons.schedule),
ScheduleStatus.missing => ('missing', Colors.orangeAccent, Icons.report_gmailerrorred), ScheduleStatus.missing => ('waiting for release', Colors.amber.shade200, Icons.hourglass_top),
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), ScheduleStatus.inLibrary => ('on Jellyfin', Colors.greenAccent, Icons.video_library_outlined),
ScheduleStatus.notOnJellyfin => ('aired · not on Jellyfin yet', Colors.grey, Icons.hourglass_empty), ScheduleStatus.notOnJellyfin => ('aired · not on Jellyfin yet', Colors.grey, Icons.hourglass_empty),
ScheduleStatus.aired => ('aired', Colors.grey, Icons.done), ScheduleStatus.aired => ('aired', Colors.grey, Icons.done),
}; };
/// What the status line means, when its text alone does not say.
String? statusHint(ScheduleEntry e, DateTime now) => _waiting(e, now) ? waitingHint : null;
class DayColumn extends StatelessWidget { class DayColumn extends StatelessWidget {
final DateTime day; final DateTime day;
@@ -157,6 +173,56 @@ class AiringCard extends StatelessWidget {
Widget _image(String? url, Widget fallback) => Widget _image(String? url, Widget fallback) =>
url == null ? fallback : Image.network(url, fit: BoxFit.cover, errorBuilder: (_, _, _) => fallback); url == null ? fallback : Image.network(url, fit: BoxFit.cover, errorBuilder: (_, _, _) => fallback);
static Widget _hinted(String? hint, Widget child) => hint == null ? child : Tooltip(message: hint, child: child);
bool get _played => entry.watch?.played ?? false;
/// A watched episode's cover is a little dimmer.
Widget _dimmed(Widget cover) => _played ? Opacity(opacity: 0.6, child: cover) : cover;
/// This user's state of the episode on Jellyfin: the library cards'
/// green check once watched, else a thin bar to the resume point.
List<Widget> _watchMarks({bool small = false}) {
final w = entry.watch;
if (w == null) return const [];
if (w.played) {
final size = small ? 16.0 : 22.0;
return [
Positioned(
top: small ? 2 : 4,
right: small ? 2 : 4,
child: Tooltip(
message: 'Watched',
child: Container(
key: ValueKey('watched-$_id'),
constraints: BoxConstraints(minWidth: size, minHeight: size),
alignment: Alignment.center,
decoration: BoxDecoration(color: const Color(0xCC1B5E20), borderRadius: BorderRadius.circular(size / 2)),
child: Icon(Icons.check, size: small ? 11 : 14, color: Colors.white),
),
),
),
];
}
if (w.progress <= 0) return const [];
return [
Positioned(
left: 0,
right: 0,
bottom: 0,
child: Tooltip(
message: '${(w.progress * 100).round()}% watched',
child: LinearProgressIndicator(
key: ValueKey('watch-progress-$_id'),
value: w.progress,
minHeight: small ? 2 : 3,
backgroundColor: Colors.black54,
),
),
),
];
}
Widget? _button(BuildContext context) { Widget? _button(BuildContext context) {
final enabled = disabledHint == null && !busy; final enabled = disabledHint == null && !busy;
final spinner = const SizedBox(width: 16, height: 16, child: CircularProgressIndicator(strokeWidth: 2)); final spinner = const SizedBox(width: 16, height: 16, child: CircularProgressIndicator(strokeWidth: 2));
@@ -184,6 +250,7 @@ class AiringCard extends StatelessWidget {
final scheme = Theme.of(context).colorScheme; final scheme = Theme.of(context).colorScheme;
final a = entry.airing; final a = entry.airing;
final (label, color, icon) = statusBadge(entry, now); final (label, color, icon) = statusBadge(entry, now);
final hint = statusHint(entry, now);
final time = clockText(a.airingAt); final time = clockText(a.airingAt);
final tintColor = a.color ?? Colors.blueGrey; final tintColor = a.color ?? Colors.blueGrey;
final tint = DecoratedBox( final tint = DecoratedBox(
@@ -195,7 +262,9 @@ class AiringCard extends StatelessWidget {
), ),
), ),
); );
final tap = (action == CardAction.putUp || action == CardAction.play) && disabledHint == null && !busy ? onAction : null; final tap = (action == CardAction.putUp || action == CardAction.play) && disabledHint == null && !busy
? onAction
: null;
final onMenu = this.onMenu; final onMenu = this.onMenu;
final menu = onMenu == null || busy ? null : () => onMenu(context); final menu = onMenu == null || busy ? null : () => onMenu(context);
if (compact) { if (compact) {
@@ -214,7 +283,14 @@ class AiringCard extends StatelessWidget {
children: [ children: [
ClipRRect( ClipRRect(
borderRadius: BorderRadius.circular(6), borderRadius: BorderRadius.circular(6),
child: SizedBox(width: 34, height: 48, child: _image(a.cover, tint)), child: SizedBox(
width: 34,
height: 48,
child: Stack(
fit: StackFit.expand,
children: [_dimmed(_image(a.cover, tint)), ..._watchMarks(small: true)],
),
),
), ),
const SizedBox(width: 8), const SizedBox(width: 8),
Expanded( Expanded(
@@ -244,7 +320,7 @@ class AiringCard extends StatelessWidget {
) )
else else
Tooltip( Tooltip(
message: label, message: hint == null ? label : '$label: $hint',
child: Icon(icon, size: 16, color: color), child: Icon(icon, size: 16, color: color),
), ),
], ],
@@ -279,7 +355,7 @@ class AiringCard extends StatelessWidget {
child: Stack( child: Stack(
fit: StackFit.expand, fit: StackFit.expand,
children: [ children: [
_image(a.cover, tint), _dimmed(_image(a.cover, tint)),
Positioned( Positioned(
left: 4, left: 4,
top: 4, top: 4,
@@ -295,6 +371,7 @@ class AiringCard extends StatelessWidget {
), ),
), ),
), ),
..._watchMarks(),
if (entry.status == ScheduleStatus.downloading) if (entry.status == ScheduleStatus.downloading)
Positioned( Positioned(
left: 0, left: 0,
@@ -331,6 +408,8 @@ class AiringCard extends StatelessWidget {
style: TextStyle(color: scheme.onSurfaceVariant, fontSize: 12), style: TextStyle(color: scheme.onSurfaceVariant, fontSize: 12),
), ),
const Spacer(), const Spacer(),
_hinted(
hint,
Row( Row(
children: [ children: [
Icon(icon, size: 14, color: color), Icon(icon, size: 14, color: color),
@@ -345,6 +424,7 @@ class AiringCard extends StatelessWidget {
), ),
], ],
), ),
),
], ],
), ),
), ),
@@ -18,7 +18,8 @@ import 'schedule_card.dart';
// weeks out (AniList), each airing with what AnimeFlow has of it, and the // weeks out (AniList), each airing with what AnimeFlow has of it, and the
// one thing to do about it (put it up, request it; right-click an // one thing to do about it (put it up, request it; right-click an
// available one to queue it). Refreshes every minute while visible; ETags // available one to queue it). Refreshes every minute while visible; ETags
// keep that cheap. // keep that cheap. Episodes on Jellyfin show this user's watched state,
// reloaded after a playback stops (JellyfinAccounts.watched).
enum ScheduleFilter { jellyfin, all } enum ScheduleFilter { jellyfin, all }
@@ -70,6 +71,7 @@ class _SchedulePageState extends State<SchedulePage> {
bool _refreshing = false; bool _refreshing = false;
int _gen = 0; int _gen = 0;
Timer? _timer; Timer? _timer;
StreamSubscription<String>? _watchedSub;
final _scroll = ScrollController(); final _scroll = ScrollController();
bool _jumped = false; bool _jumped = false;
@@ -81,7 +83,6 @@ class _SchedulePageState extends State<SchedulePage> {
/// The sheet stays built while it slides out. /// The sheet stays built while it slides out.
bool _sheetBuilt = false; bool _sheetBuilt = false;
/// Requested in this run: "anilistId#episode" or "series:anilistId". /// Requested in this run: "anilistId#episode" or "series:anilistId".
final _requested = <String>{}; final _requested = <String>{};
@@ -106,6 +107,11 @@ class _SchedulePageState extends State<SchedulePage> {
_timer = Timer.periodic(SchedulePage.refreshEvery, (_) { _timer = Timer.periodic(SchedulePage.refreshEvery, (_) {
if (mounted && !_refreshing && TickerMode.valuesOf(context).enabled) _load(); if (mounted && !_refreshing && TickerMode.valuesOf(context).enabled) _load();
}); });
// Back from watching: the cards' watched marks follow (the stop report
// already dropped the cached episode lists).
_watchedSub = _accounts.watched.listen((_) {
if (mounted) _load();
});
} }
ScheduleRepository _ownRepo() { ScheduleRepository _ownRepo() {
@@ -113,13 +119,14 @@ class _SchedulePageState extends State<SchedulePage> {
return ScheduleRepository( return ScheduleRepository(
aniList: AniListClient(), aniList: AniListClient(),
animeFlow: flow, animeFlow: flow,
jellyfinEpisodes: (id) => jellyfinEpisodeNumbers(_accounts, flow, id), jellyfinEpisodes: (id) => jellyfinScheduleEpisodes(_accounts, flow, id),
); );
} }
@override @override
void dispose() { void dispose() {
_timer?.cancel(); _timer?.cancel();
_watchedSub?.cancel();
_scroll.dispose(); _scroll.dispose();
if (_ownsRepo) { if (_ownsRepo) {
_repo.aniList.close(); _repo.aniList.close();
@@ -228,9 +235,10 @@ class _SchedulePageState extends State<SchedulePage> {
final me = _me; final me = _me;
final CardAction kind; final CardAction kind;
if (e.tracked) { if (e.tracked) {
kind = e.status == ScheduleStatus.missing || e.status == ScheduleStatus.failed // Only an unmonitored episode is worth asking for: AnimeFlow grabs
? CardAction.request // a missing one (no release yet) by itself once a release appears,
: CardAction.none; // and retries a failed one.
kind = e.status == ScheduleStatus.unmonitored ? CardAction.request : CardAction.none;
} else { } else {
// On Jellyfin already (owner added it by hand): nothing to request. // On Jellyfin already (owner added it by hand): nothing to request.
kind = me != null && !e.onJellyfin ? CardAction.requestSeries : CardAction.none; kind = me != null && !e.onJellyfin ? CardAction.requestSeries : CardAction.none;
+48 -7
View File
@@ -102,11 +102,43 @@ void main() {
); );
expect(matched.map((e) => e.jellyfinEpisode), [13, 14, 3]); expect(matched.map((e) => e.jellyfinEpisode), [13, 14, 3]);
final out = confirmOnJellyfin(matched, { 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), expect(out.map((e) => e.status),
[ScheduleStatus.inLibrary, ScheduleStatus.notOnJellyfin, ScheduleStatus.aired]); [ScheduleStatus.inLibrary, ScheduleStatus.notOnJellyfin, ScheduleStatus.aired]);
expect(out[1].jellyfinSeriesId, 'jfJ'); 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)", () { test("AnimeFlow's anilist_episode wins over TVDB numbering (split cour)", () {
@@ -170,15 +202,22 @@ void main() {
expect(out.single.tracked, isFalse); 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( final out = matchSchedule(
[ [
airing(300, 1100, DateTime(2026, 10, 5, 9)), // AniList counts absolute 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: [ schedule: [
item(300, 5, EpisodeStatus.available, season: 22, absolute: 1100), 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, now: now,
); );
@@ -186,12 +225,14 @@ void main() {
expect(out[0].season, 22); expect(out[0].season, 22);
expect(out[1].status, ScheduleStatus.failed); expect(out[1].status, ScheduleStatus.failed);
expect(out[1].reason, 'No release'); 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( final out = matchSchedule(
[airing(400, 13, DateTime(2026, 10, 7, 0, 30))], // Oct 7 20:00 JST; the air date is Oct 6 (midnight JST).
schedule: [item(400, 1, EpisodeStatus.available, air: DateTime(2026, 10, 6, 23, 50))], [airing(400, 13, DateTime.utc(2026, 10, 7, 11))],
schedule: [item(400, 1, EpisodeStatus.available, air: DateTime.utc(2026, 10, 5, 15))],
now: now, now: now,
); );
// Tracked (the id is in the schedule) but unmatched and aired. // 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', 'SeriesName': 'Avail Show',
'IndexNumber': 2, 'IndexNumber': 2,
'ParentIndexNumber': 1, 'ParentIndexNumber': 1,
'UserData': {'Played': true},
}, },
{ {
'Id': 'ep3', 'Id': 'ep3',
@@ -58,6 +59,8 @@ class FakeJf implements JellyfinHttp {
'SeriesName': 'Avail Show', 'SeriesName': 'Avail Show',
'IndexNumber': 3, 'IndexNumber': 3,
'ParentIndexNumber': 1, 'ParentIndexNumber': 1,
'RunTimeTicks': 1000,
'UserData': {'PlaybackPositionTicks': 250},
}, },
], ],
'TotalRecordCount': 2, 'TotalRecordCount': 2,
@@ -91,6 +94,7 @@ List<ScheduleEntry> entries() => [
seriesId: 7, seriesId: 7,
season: 1, season: 1,
jellyfinSeriesId: 'jf7', jellyfinSeriesId: 'jf7',
watch: const EpisodeWatch(played: true),
), ),
ScheduleEntry( ScheduleEntry(
airing: airing(222, 'Missing Show', 5, day(0, 10)), airing: airing(222, 'Missing Show', 5, day(0, 10)),
@@ -99,9 +103,8 @@ List<ScheduleEntry> entries() => [
seriesId: 8, seriesId: 8,
), ),
ScheduleEntry( ScheduleEntry(
airing: airing(333, 'Failed Show', 2, day(0, 11)), airing: airing(333, 'Unmonitored Show', 2, day(0, 11)),
status: ScheduleStatus.failed, status: ScheduleStatus.unmonitored,
reason: 'no seeders',
tracked: true, tracked: true,
seriesId: 9, seriesId: 9,
season: 1, season: 1,
@@ -283,8 +286,13 @@ void main() {
expect(find.byKey(ValueKey('day-${w.year}-${w.month}-${w.day}')), findsNothing); expect(find.byKey(ValueKey('day-${w.year}-${w.month}-${w.day}')), findsNothing);
expect(card(111, 3), findsOneWidget); expect(card(111, 3), findsOneWidget);
expect(find.text('on Jellyfin'), findsOneWidget); expect(find.text('on Jellyfin'), findsOneWidget);
expect(find.text('missing'), findsOneWidget); // AnimeFlow's "missing": waiting for a release, nothing to request;
expect(find.text('failed · no seeders'), findsOneWidget); // 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('downloading 42%'), findsOneWidget);
expect(find.text('Put it up'), findsOneWidget); expect(find.text('Put it up'), findsOneWidget);
// On Jellyfin: untracked entries are hidden. // On Jellyfin: untracked entries are hidden.
@@ -316,7 +324,14 @@ void main() {
}); });
testWidgets('request on a tracked episode sends AnimeFlow numbering', (tester) async { 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 tester.tap(action(222, 5));
await io(tester); await io(tester);
final post = server.seen.where((r) => r.method == 'POST' && r.uri.path.endsWith('/requests')).single; 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 { testWidgets('without the request scope the button is disabled with a hint', (tester) async {
await pumpPage(tester, snap: () => snapshot(me: viewer)); 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(b.onPressed, isNull);
expect(find.byWidgetPredicate((w) => w is Tooltip && (w.message ?? '').contains('owner')), findsWidgets); expect(find.byWidgetPredicate((w) => w is Tooltip && (w.message ?? '').contains('owner')), findsWidgets);
expect(find.text('My requests'), findsOneWidget); expect(find.text('My requests'), findsOneWidget);
@@ -437,12 +452,13 @@ void main() {
httpFactory: () => jf, httpFactory: () => jf,
deviceName: 'Test', deviceName: 'Test',
); );
final eps = await tester.runAsync(() => jellyfinEpisodeNumbers(accounts, flow, 'jf7')); final eps = await tester.runAsync(() => jellyfinScheduleEpisodes(accounts, flow, 'jf7'));
expect(eps, {(1, 2), (1, 3)}); expect(eps!.map((e) => (e.season, e.episode)), [(1, 2), (1, 3)]);
await tester.runAsync(() => jellyfinEpisodeNumbers(accounts, flow, 'jf7')); 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'); expect(jf.seen.where((u) => u.path.endsWith('/Episodes')), hasLength(1), reason: 'memoised');
final none = JellyfinAccounts(store: MemoryJellyfinStore(), deviceName: 'Test'); 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( await pumpPage(
tester, tester,
+25 -11
View File
@@ -1185,17 +1185,31 @@ panel) and chat messages (Copy message, and a link in it).
against AnimeFlow (`animeflow.dart`, app API with the Jellyfin token; against AnimeFlow (`animeflow.dart`, app API with the Jellyfin token;
`/library` marks shows on Jellyfin it doesn't track, through every AniList `/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 entry of a season (`anilist_map`: a season AniList splits has several)) into a status per
episode (`schedule_model.dart`). An aired episode of such a show reads episode (`schedule_model.dart`). AnimeFlow's `missing` (aired,
"on Jellyfin" only when Jellyfin has it: the season plus AniList monitored, no release grabbed yet; AnimeFlow grabs it by itself once
episode + the run's offset, checked against the series' episodes seen one appears), and its `upcoming` past the AniList air time, read
by the linked account (`jellyfinEpisodeNumbers`, one request per series "waiting for release"; Request is offered only for an `unmonitored`
memoised 3 min; `confirmOnJellyfin` is the pure step); absent reads episode (and "Request series" for a show AnimeFlow does not track).
"aired · not on Jellyfin yet", unknown (no linked account, lookup Air dates (AnimeFlow's `air_date`, midnight Japan time; Jellyfin's
failed) "aired", neither with Put it up. Put it up resolves the Jellyfin premiere date)
episode (`resolveJellyfinEpisode`: AnimeFlow's numbering, else the are date-only and matched as Japan calendar days (`sameAirDay`; a slot
season and offset, else the air day) and goes through the shell's before 05:00 JST also matches the day before), never as local days. An
put-up; right-click queues it the same way. Requests and the owner's inbox are in aired episode of an untracked show on Jellyfin reads "on Jellyfin" only
`requests_sheet.dart`. It refreshes every minute; mouse drag and when Jellyfin has it: the season plus AniList episode + the run's
offset, checked against the series' episodes seen by the linked
account (`jellyfinScheduleEpisodes`, 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. The same lookup covers AnimeFlow's available
episodes (AnimeFlow's season and episode, else the one premiered that
day) and gives every episode found this user's `UserData`: a watched
one has the library cards' green check on a slightly dimmed cover, a
started one a thin bar to its resume point. A stop report
(`JellyfinAccounts.watched`) drops the memo and reloads the page. Put
it up resolves the Jellyfin episode (`resolveJellyfinEpisode`:
AnimeFlow's numbering, else the season and offset, else the air day)
and goes through the shell's put-up; right-click queues it the same
way. 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. Shift+wheel scroll the board sideways, the plain wheel scrolls a column.
Settings (`settings_tab.dart`) has a left nav with icons Settings (`settings_tab.dart`) has a left nav with icons
(icon chips under 760 px) over pages of rounded Material cards (icon chips under 760 px) over pages of rounded Material cards