diff --git a/README.md b/README.md index 2bbab29..ba7a1fe 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/apps/desktop/lib/src/schedule/animeflow.dart b/apps/desktop/lib/src/schedule/animeflow.dart index 2dfa009..13dee92 100644 --- a/apps/desktop/lib/src/schedule/animeflow.dart +++ b/apps/desktop/lib/src/schedule/animeflow.dart @@ -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). diff --git a/apps/desktop/lib/src/schedule/schedule_model.dart b/apps/desktop/lib/src/schedule/schedule_model.dart index 248bb79..31f4d88 100644 --- a/apps/desktop/lib/src/schedule/schedule_model.dart +++ b/apps/desktop/lib/src/schedule/schedule_model.dart @@ -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 _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 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 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 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 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 confirmOnJellyfin(List entries, Map> 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 confirmOnJellyfin(List entries, Map> 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?> Function(String jellyfinSeriesId); +JellyfinEpisodeRef? _episodeOf(List 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?> 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> _confirm(List 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 = >{}; + final found = >{}; if (lookup != null) { await Future.wait([ for (final id in ids) diff --git a/apps/desktop/lib/src/schedule/ui/schedule_actions.dart b/apps/desktop/lib/src/schedule/ui/schedule_actions.dart index 29b0fe1..0e063ea 100644 --- a/apps/desktop/lib/src/schedule/ui/schedule_actions.dart +++ b/apps/desktop/lib/src/schedule/ui/schedule_actions.dart @@ -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 flowEpisodeFor(AnimeFlowClient client, ScheduleEntry e) async { final id = e.seriesId; if (id == null) return null; @@ -63,7 +58,7 @@ Future 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?> 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?> jellyfinScheduleEpisodes( JellyfinAccounts accounts, AnimeFlowClient? flow, String jellyfinSeriesId, @@ -125,10 +122,20 @@ Future?> 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 => { - for (final x in await client.episodes(jellyfinSeriesId)) - if (x.parentIndexNumber != null && x.indexNumber != null) (x.parentIndexNumber!, x.indexNumber!), - }); + return accounts.cached( + account.serverId, + 'schedule-episodes:$jellyfinSeriesId', + () async => [ + for (final x in await client.episodes(jellyfinSeriesId)) + 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 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'); diff --git a/apps/desktop/lib/src/schedule/ui/schedule_card.dart b/apps/desktop/lib/src/schedule/ui/schedule_card.dart index 0f5302e..ba7f31b 100644 --- a/apps/desktop/lib/src/schedule/ui/schedule_card.dart +++ b/apps/desktop/lib/src/schedule/ui/schedule_card.dart @@ -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,23 +35,39 @@ 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) { - ScheduleStatus.available => ('on Jellyfin', Colors.greenAccent, Icons.check_circle), - ScheduleStatus.downloading => ( - e.progress == null ? 'downloading' : 'downloading ${(e.progress! * 100).round()}%', - Colors.lightBlueAccent, - 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.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), -}; +(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()}%', + Colors.lightBlueAccent, + Icons.downloading, + ), + ScheduleStatus.upcoming => (countdownText(e.airing.airingAt, now), Colors.amber, Icons.schedule), + 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), + ScheduleStatus.notOnJellyfin => ('aired · not on Jellyfin yet', Colors.grey, Icons.hourglass_empty), + 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; @@ -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 _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,19 +408,22 @@ class AiringCard extends StatelessWidget { style: TextStyle(color: scheme.onSurfaceVariant, fontSize: 12), ), const Spacer(), - Row( - children: [ - Icon(icon, size: 14, color: color), - const SizedBox(width: 4), - Expanded( - child: Text( - label, - maxLines: 2, - overflow: TextOverflow.ellipsis, - style: TextStyle(color: color, fontSize: 12), + _hinted( + hint, + Row( + children: [ + Icon(icon, size: 14, color: color), + const SizedBox(width: 4), + Expanded( + child: Text( + label, + maxLines: 2, + overflow: TextOverflow.ellipsis, + style: TextStyle(color: color, fontSize: 12), + ), ), - ), - ], + ], + ), ), ], ), diff --git a/apps/desktop/lib/src/schedule/ui/schedule_page.dart b/apps/desktop/lib/src/schedule/ui/schedule_page.dart index d3e13b4..38eeffe 100644 --- a/apps/desktop/lib/src/schedule/ui/schedule_page.dart +++ b/apps/desktop/lib/src/schedule/ui/schedule_page.dart @@ -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 { bool _refreshing = false; int _gen = 0; Timer? _timer; + StreamSubscription? _watchedSub; final _scroll = ScrollController(); bool _jumped = false; @@ -81,7 +83,6 @@ class _SchedulePageState extends State { /// The sheet stays built while it slides out. bool _sheetBuilt = false; - /// Requested in this run: "anilistId#episode" or "series:anilistId". final _requested = {}; @@ -106,6 +107,11 @@ class _SchedulePageState extends State { _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 { 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 { 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; diff --git a/apps/desktop/test/schedule_model_test.dart b/apps/desktop/test/schedule_model_test.dart index 1ad1f8d..ceac49a 100644 --- a/apps/desktop/test/schedule_model_test.dart +++ b/apps/desktop/test/schedule_model_test.dart @@ -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. diff --git a/apps/desktop/test/schedule_page_test.dart b/apps/desktop/test/schedule_page_test.dart index edc2dc4..33eec97 100644 --- a/apps/desktop/test/schedule_page_test.dart +++ b/apps/desktop/test/schedule_page_test.dart @@ -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 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 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(tester, action(222, 5)); + final b = widgetOf(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, diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 1617cda..b6abf45 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -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