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
+353 -91

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 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
For an always-on box (no display needed): the release tarball also contains
@@ -198,6 +198,8 @@ class AnimeFlowEpisode {
final int episode;
final int? absolute;
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;
/// The AniList entry of this episode's season (one AnimeFlow series may
/// span several AniList entries).
+111 -29
View File
@@ -12,8 +12,12 @@ enum ScheduleStatus {
available,
downloading,
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,
failed,
/// AnimeFlow does not monitor it: the only status worth a Request.
unmonitored,
/// AnimeFlow does not track this series (or is unreachable).
notTracked,
@@ -53,11 +57,15 @@ class ScheduleEntry {
/// 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).
/// The TVDB episode number in [season]: tracked, AnimeFlow's own;
/// untracked on Jellyfin, AniList episode + the `anilist_map` run's
/// offset.
final int? jellyfinEpisode;
/// Play target once available.
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).
bool get onJellyfin => jellyfinSeriesId != null;
@@ -71,11 +79,12 @@ class ScheduleEntry {
this.season,
this.jellyfinEpisode,
this.jellyfinSeriesId,
this.watch,
});
ScheduleEntry withStatus(ScheduleStatus s) => ScheduleEntry(
ScheduleEntry copyWith({ScheduleStatus? status, EpisodeWatch? watch}) => ScheduleEntry(
airing: airing,
status: s,
status: status ?? this.status,
progress: progress,
reason: reason,
tracked: tracked,
@@ -83,6 +92,7 @@ class ScheduleEntry {
season: season,
jellyfinEpisode: jellyfinEpisode,
jellyfinSeriesId: jellyfinSeriesId,
watch: watch ?? this.watch,
);
@override
@@ -95,11 +105,59 @@ ScheduleEntry untrackedEntry(Airing a, DateTime now) => ScheduleEntry(
status: a.airingAt.isAfter(now) ? ScheduleStatus.upcoming : ScheduleStatus.notTracked,
);
String _day(DateTime d) {
final l = d.toLocal();
return '${l.year}-${l.month}-${l.day}';
/// This user's state of a Jellyfin episode (its `UserData`).
class EpisodeWatch {
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 {
final int seriesId;
final String? jellyfinSeriesId;
@@ -155,7 +213,7 @@ List<ScheduleEntry> matchSchedule(
}
byEp.putIfAbsent('$id#${e.episode}', () => 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 [
for (final a in airings)
@@ -164,7 +222,9 @@ List<ScheduleEntry> matchSchedule(
final ref = refs[id];
final it = numbered.contains(id)
? 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) {
final e = it.episode;
return ScheduleEntry(
@@ -175,6 +235,7 @@ List<ScheduleEntry> matchSchedule(
tracked: true,
seriesId: it.seriesId,
season: e.season,
jellyfinEpisode: e.episode,
jellyfinSeriesId: it.jellyfinSeriesId ?? ref?.jellyfinSeriesId ?? lib[id]?.$1,
);
}
@@ -200,26 +261,45 @@ 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) => [
/// The episodes [confirmOnJellyfin] looks up: on Jellyfin, untracked
/// ([ScheduleStatus.inLibrary]) or available through AnimeFlow.
bool needsJellyfinLookup(ScheduleEntry e) =>
e.jellyfinSeriesId != null && (e.status == ScheduleStatus.inLibrary || e.status == ScheduleStatus.available);
/// 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)
if (e.status != ScheduleStatus.inLibrary)
if (!needsJellyfinLookup(e))
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),
switch ((episodes[e.jellyfinSeriesId], e.status)) {
(null, ScheduleStatus.inLibrary) => e.copyWith(status: ScheduleStatus.aired),
(null, _) => e,
(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
/// cannot tell (no linked account for that server).
typedef JellyfinEpisodeLookup = Future<Set<(int, int)>?> Function(String jellyfinSeriesId);
JellyfinEpisodeRef? _episodeOf(List<JellyfinEpisodeRef> have, ScheduleEntry e) {
final byNumber = have.where((x) => x.season == e.season && x.episode == e.jellyfinEpisode).firstOrNull;
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
/// when AniList failed with nothing cached.
@@ -262,8 +342,9 @@ class ScheduleRepository {
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]).
/// Confirms "on Jellyfin" and this user's watched state per episode
/// ([confirmOnJellyfin]); null = never confirmed (aired entries read
/// [ScheduleStatus.aired], nothing shows as watched).
final JellyfinEpisodeLookup? jellyfinEpisodes;
final DateTime Function() _now;
@@ -303,15 +384,16 @@ 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.
/// Looks up the episodes of every series an entry on Jellyfin is on
/// ([needsJellyfinLookup]), in parallel, one request per series; 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!,
if (needsJellyfinLookup(e)) e.jellyfinSeriesId!,
};
final found = <String, Set<(int, int)>>{};
final found = <String, List<JellyfinEpisodeRef>>{};
if (lookup != null) {
await Future.wait([
for (final id in ids)
@@ -1,7 +1,7 @@
import 'package:sync_relay_core/core.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 '../anilist.dart';
import '../animeflow.dart';
@@ -47,14 +47,9 @@ String friendlyError(Object e) => switch (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
/// 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 {
final id = e.seriesId;
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();
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.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
@@ -112,11 +107,13 @@ 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(
/// The episodes Jellyfin has of [jellyfinSeriesId] (numbers, premiere
/// date, this user's watched state), seen by the account AnimeFlow's ids
/// belong to ([jellyfinAccountFor]): the user's own; null without a
/// linked account. One request per series, memoised a few minutes
/// ([JellyfinAccounts.cached]; a stop report drops it,
/// [JellyfinAccounts.watched]).
Future<List<JellyfinEpisodeRef>?> jellyfinScheduleEpisodes(
JellyfinAccounts accounts,
AnimeFlowClient? flow,
String jellyfinSeriesId,
@@ -125,10 +122,20 @@ Future<Set<(int, int)>?> jellyfinEpisodeNumbers(
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 => {
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!),
});
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
@@ -153,7 +160,7 @@ Future<JellyfinMedia> resolveJellyfinEpisode(JellyfinAccounts accounts, AnimeFlo
final a = e.airing;
final item =
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;
if (item == null) {
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';
// 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
// status on the right, one full-width action below. Compact mode is a
// slim row.
// left (air time on it, download progress or this user's watch progress
// under it, a check once watched), title, episode and status on the
// right, one full-width action below. Compact mode is a slim row.
/// What a card's action does, decided by the page.
enum CardAction {
@@ -35,8 +35,17 @@ String countdownText(DateTime at, DateTime now) {
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.
(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.downloading => (
e.progress == null ? 'downloading' : 'downloading ${(e.progress! * 100).round()}%',
@@ -44,8 +53,12 @@ String countdownText(DateTime at, DateTime now) {
Icons.downloading,
),
ScheduleStatus.upcoming => (countdownText(e.airing.airingAt, now), Colors.amber, Icons.schedule),
ScheduleStatus.missing => ('missing', Colors.orangeAccent, Icons.report_gmailerrorred),
ScheduleStatus.failed => ('failed${e.reason == null ? '' : ' · ${e.reason}'}', Colors.redAccent, Icons.error_outline),
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.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),
@@ -53,6 +66,9 @@ String countdownText(DateTime at, DateTime now) {
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 {
final DateTime day;
final bool today, past;
@@ -157,6 +173,56 @@ class AiringCard extends StatelessWidget {
Widget _image(String? url, Widget 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) {
final enabled = disabledHint == null && !busy;
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 a = entry.airing;
final (label, color, icon) = statusBadge(entry, now);
final hint = statusHint(entry, now);
final time = clockText(a.airingAt);
final tintColor = a.color ?? Colors.blueGrey;
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 menu = onMenu == null || busy ? null : () => onMenu(context);
if (compact) {
@@ -214,7 +283,14 @@ class AiringCard extends StatelessWidget {
children: [
ClipRRect(
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),
Expanded(
@@ -244,7 +320,7 @@ class AiringCard extends StatelessWidget {
)
else
Tooltip(
message: label,
message: hint == null ? label : '$label: $hint',
child: Icon(icon, size: 16, color: color),
),
],
@@ -279,7 +355,7 @@ class AiringCard extends StatelessWidget {
child: Stack(
fit: StackFit.expand,
children: [
_image(a.cover, tint),
_dimmed(_image(a.cover, tint)),
Positioned(
left: 4,
top: 4,
@@ -295,6 +371,7 @@ class AiringCard extends StatelessWidget {
),
),
),
..._watchMarks(),
if (entry.status == ScheduleStatus.downloading)
Positioned(
left: 0,
@@ -331,6 +408,8 @@ class AiringCard extends StatelessWidget {
style: TextStyle(color: scheme.onSurfaceVariant, fontSize: 12),
),
const Spacer(),
_hinted(
hint,
Row(
children: [
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
// 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
// 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 }
@@ -70,6 +71,7 @@ class _SchedulePageState extends State<SchedulePage> {
bool _refreshing = false;
int _gen = 0;
Timer? _timer;
StreamSubscription<String>? _watchedSub;
final _scroll = ScrollController();
bool _jumped = false;
@@ -81,7 +83,6 @@ class _SchedulePageState extends State<SchedulePage> {
/// The sheet stays built while it slides out.
bool _sheetBuilt = false;
/// Requested in this run: "anilistId#episode" or "series:anilistId".
final _requested = <String>{};
@@ -106,6 +107,11 @@ class _SchedulePageState extends State<SchedulePage> {
_timer = Timer.periodic(SchedulePage.refreshEvery, (_) {
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() {
@@ -113,13 +119,14 @@ class _SchedulePageState extends State<SchedulePage> {
return ScheduleRepository(
aniList: AniListClient(),
animeFlow: flow,
jellyfinEpisodes: (id) => jellyfinEpisodeNumbers(_accounts, flow, id),
jellyfinEpisodes: (id) => jellyfinScheduleEpisodes(_accounts, flow, id),
);
}
@override
void dispose() {
_timer?.cancel();
_watchedSub?.cancel();
_scroll.dispose();
if (_ownsRepo) {
_repo.aniList.close();
@@ -228,9 +235,10 @@ class _SchedulePageState extends State<SchedulePage> {
final me = _me;
final CardAction kind;
if (e.tracked) {
kind = e.status == ScheduleStatus.missing || e.status == ScheduleStatus.failed
? CardAction.request
: CardAction.none;
// Only an unmonitored episode is worth asking for: AnimeFlow grabs
// a missing one (no release yet) by itself once a release appears,
// and retries a failed one.
kind = e.status == ScheduleStatus.unmonitored ? CardAction.request : CardAction.none;
} else {
// On Jellyfin already (owner added it by hand): nothing to request.
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]);
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,
+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;
`/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`). 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. 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
episode (`schedule_model.dart`). AnimeFlow's `missing` (aired,
monitored, no release grabbed yet; AnimeFlow grabs it by itself once
one appears), and its `upcoming` past the AniList air time, read
"waiting for release"; Request is offered only for an `unmonitored`
episode (and "Request series" for a show AnimeFlow does not track).
Air dates (AnimeFlow's `air_date`, midnight Japan time; Jellyfin's
premiere date)
are date-only and matched as Japan calendar days (`sameAirDay`; a slot
before 05:00 JST also matches the day before), never as local days. An
aired episode of an untracked show on Jellyfin 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 (`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.
Settings (`settings_tab.dart`) has a left nav with icons
(icon chips under 760 px) over pages of rounded Material cards