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:
1 parent
1ec4fc8b13
commit
a407d3cbb0
9 files changed
+379
-117
No files matched your search
@@ -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).
|
||||||
|
|||||||
@@ -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(
|
||||||
for (final x in await client.episodes(jellyfinSeriesId))
|
account.serverId,
|
||||||
if (x.parentIndexNumber != null && x.indexNumber != null) (x.parentIndexNumber!, x.indexNumber!),
|
'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
|
/// 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,23 +35,39 @@ 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)
|
||||||
ScheduleStatus.available => ('on Jellyfin', Colors.greenAccent, Icons.check_circle),
|
? ('waiting for release', Colors.amber.shade200, Icons.hourglass_top)
|
||||||
ScheduleStatus.downloading => (
|
: switch (e.status) {
|
||||||
e.progress == null ? 'downloading' : 'downloading ${(e.progress! * 100).round()}%',
|
ScheduleStatus.available => ('on Jellyfin', Colors.greenAccent, Icons.check_circle),
|
||||||
Colors.lightBlueAccent,
|
ScheduleStatus.downloading => (
|
||||||
Icons.downloading,
|
e.progress == null ? 'downloading' : 'downloading ${(e.progress! * 100).round()}%',
|
||||||
),
|
Colors.lightBlueAccent,
|
||||||
ScheduleStatus.upcoming => (countdownText(e.airing.airingAt, now), Colors.amber, Icons.schedule),
|
Icons.downloading,
|
||||||
ScheduleStatus.missing => ('missing', Colors.orangeAccent, Icons.report_gmailerrorred),
|
),
|
||||||
ScheduleStatus.failed => ('failed${e.reason == null ? '' : ' · ${e.reason}'}', Colors.redAccent, Icons.error_outline),
|
ScheduleStatus.upcoming => (countdownText(e.airing.airingAt, now), Colors.amber, Icons.schedule),
|
||||||
ScheduleStatus.unmonitored => ('not monitored', Colors.grey, Icons.visibility_off_outlined),
|
ScheduleStatus.missing => ('waiting for release', Colors.amber.shade200, Icons.hourglass_top),
|
||||||
ScheduleStatus.notTracked => ('not in AnimeFlow', Colors.grey, Icons.public),
|
ScheduleStatus.failed => (
|
||||||
ScheduleStatus.inLibrary => ('on Jellyfin', Colors.greenAccent, Icons.video_library_outlined),
|
'failed${e.reason == null ? '' : ' · ${e.reason}'}',
|
||||||
ScheduleStatus.notOnJellyfin => ('aired · not on Jellyfin yet', Colors.grey, Icons.hourglass_empty),
|
Colors.redAccent,
|
||||||
ScheduleStatus.aired => ('aired', Colors.grey, Icons.done),
|
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 {
|
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,19 +408,22 @@ class AiringCard extends StatelessWidget {
|
|||||||
style: TextStyle(color: scheme.onSurfaceVariant, fontSize: 12),
|
style: TextStyle(color: scheme.onSurfaceVariant, fontSize: 12),
|
||||||
),
|
),
|
||||||
const Spacer(),
|
const Spacer(),
|
||||||
Row(
|
_hinted(
|
||||||
children: [
|
hint,
|
||||||
Icon(icon, size: 14, color: color),
|
Row(
|
||||||
const SizedBox(width: 4),
|
children: [
|
||||||
Expanded(
|
Icon(icon, size: 14, color: color),
|
||||||
child: Text(
|
const SizedBox(width: 4),
|
||||||
label,
|
Expanded(
|
||||||
maxLines: 2,
|
child: Text(
|
||||||
overflow: TextOverflow.ellipsis,
|
label,
|
||||||
style: TextStyle(color: color, fontSize: 12),
|
maxLines: 2,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
style: TextStyle(color: color, fontSize: 12),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
],
|
||||||
],
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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
@@ -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
|
||||||
|
|||||||
Reference in new issue
Block a user