From caa076589054c92ea766d8b8753ea719393e923a Mon Sep 17 00:00:00 2001 From: foxyz Date: Thu, 8 Oct 2026 23:47:33 +0300 Subject: [PATCH] Schedule backend: AniList client, AnimeFlow client, schedule matching lib/src/schedule/ for the 1.0 Schedule tab (UI and docs come with the schedule round): - AniListClient: airingSchedules in a window, 50 per page, adult/music dropped; memory 10 min + disk 1 h cache, one Retry-After wait on 429, stale cache on failure; real User-Agent (AniList 403s some defaults). - AnimeFlowClient: the full app API (me, schedule, series, follow, search, requests incl. approve/reject, users/scopes) with the user's Jellyfin token, only for a Jellyfin on AnimeFlow's parent domain; ETag/304, typed error codes, backoff on nginx's HTML 429, /me cached at most 60 s so scope changes show. - matchSchedule + ScheduleRepository: AniList airings with AnimeFlow status by (episode anilist_id, episode), then absolute number, then air day; degrades to AniList-only when AnimeFlow fails. Co-Authored-By: Claude Opus 5.5 --- apps/desktop/lib/src/schedule/anilist.dart | 328 +++++++++ apps/desktop/lib/src/schedule/animeflow.dart | 686 ++++++++++++++++++ .../lib/src/schedule/schedule_model.dart | 259 +++++++ apps/desktop/test/schedule_anilist_test.dart | 171 +++++ .../desktop/test/schedule_animeflow_test.dart | 335 +++++++++ apps/desktop/test/schedule_fake_server.dart | 51 ++ apps/desktop/test/schedule_model_test.dart | 242 ++++++ 7 files changed, 2072 insertions(+) create mode 100644 apps/desktop/lib/src/schedule/anilist.dart create mode 100644 apps/desktop/lib/src/schedule/animeflow.dart create mode 100644 apps/desktop/lib/src/schedule/schedule_model.dart create mode 100644 apps/desktop/test/schedule_anilist_test.dart create mode 100644 apps/desktop/test/schedule_animeflow_test.dart create mode 100644 apps/desktop/test/schedule_fake_server.dart create mode 100644 apps/desktop/test/schedule_model_test.dart diff --git a/apps/desktop/lib/src/schedule/anilist.dart b/apps/desktop/lib/src/schedule/anilist.dart new file mode 100644 index 0000000..bf02dfd --- /dev/null +++ b/apps/desktop/lib/src/schedule/anilist.dart @@ -0,0 +1,328 @@ +import 'dart:async'; +import 'dart:convert'; +import 'dart:io'; +import 'dart:ui' show Color; + +import 'package:path_provider/path_provider.dart'; +import 'package:sync_relay_core/core.dart'; + +import '../update/updater.dart' show appVersion; + +// AniList airing schedule (public GraphQL, no login): every episode airing +// in a time range, with covers, banners and popularity. +// +// AniList allows ~90 requests/min (sometimes degraded to 30), so the raw +// pages are cached: in memory for 10 min (tab switches) and on disk for an +// hour (restarts), keyed by range start. A 429 waits out Retry-After once; +// if AniList still refuses (or is unreachable), stale cached data is +// returned with its fetch time. + +final _log = createLogger('anilist'); + +const aniListDefaultUrl = 'https://graphql.anilist.co'; + +/// One episode airing, as AniList announces it. +class Airing { + final int anilistId; + /// English title, else romaji. + final String title; + final String titleRomaji; + final int episode; + /// UTC. + final DateTime airingAt; + final String? cover; + final String? banner; + /// The cover's dominant color, if AniList has one. + final Color? color; + final int popularity; + + const Airing({ + required this.anilistId, + required this.title, + required this.titleRomaji, + required this.episode, + required this.airingAt, + this.cover, + this.banner, + this.color, + this.popularity = 0, + }); + + /// Null for adult entries, music videos and malformed rows. + static Airing? fromJson(Object? j) { + if (j is! Map) return null; + final m = j['media'], at = j['airingAt'], ep = j['episode']; + if (m is! Map || at is! int || ep is! int) return null; + final id = m['id']; + if (id is! int || m['isAdult'] == true || m['format'] == 'MUSIC') return null; + final t = m['title'] is Map ? m['title'] as Map : const {}; + final romaji = t['romaji'] is String ? t['romaji'] as String : ''; + final english = t['english'] is String ? t['english'] as String : ''; + final cov = m['coverImage'] is Map ? m['coverImage'] as Map : const {}; + return Airing( + anilistId: id, + title: english.isNotEmpty ? english : (romaji.isNotEmpty ? romaji : '?'), + titleRomaji: romaji, + episode: ep, + airingAt: DateTime.fromMillisecondsSinceEpoch(at * 1000, isUtc: true), + cover: cov['large'] is String ? cov['large'] as String : null, + banner: m['bannerImage'] is String ? m['bannerImage'] as String : null, + color: parseHexColor(cov['color']), + popularity: m['popularity'] is int ? m['popularity'] as int : 0, + ); + } + + @override + String toString() => 'Airing($anilistId "$title" ep $episode @ $airingAt)'; +} + +/// "#e4a15d" -> opaque [Color]; anything else -> null. +Color? parseHexColor(Object? s) { + if (s is! String || s.length != 7 || !s.startsWith('#')) return null; + final v = int.tryParse(s.substring(1), radix: 16); + return v == null ? null : Color(0xff000000 | v); +} + +class AniListException implements Exception { + final int? statusCode; + final String message; + const AniListException(this.message, {this.statusCode}); + + bool get isRateLimited => statusCode == 429; + + @override + String toString() => 'AniListException: $message'; +} + +/// The airings of one range. [staleSince] is set when AniList refused and +/// older cached data (fetched then) is returned instead. +class AniListSchedule { + final List airings; + final DateTime fetchedAt; + final DateTime? staleSince; + const AniListSchedule(this.airings, this.fetchedAt, {this.staleSince}); + + bool get isStale => staleSince != null; +} + +class _Cached { + final DateTime fetched; + final int from; + final int to; + final List raw; + const _Cached(this.fetched, this.from, this.to, this.raw); +} + +class AniListClient { + AniListClient({ + String url = aniListDefaultUrl, + HttpClient? http, + Future Function()? cacheDir, + this.memoryTtl = const Duration(minutes: 10), + this.diskTtl = const Duration(hours: 1), + Future Function(Duration)? delay, + DateTime Function()? now, + String? userAgent, + }) : url = Uri.parse(url), + _http = http ?? HttpClient(), + _ownsHttp = http == null, + _cacheDir = cacheDir ?? _defaultCacheDir, + _delay = delay ?? Future.delayed, + _now = now ?? DateTime.now, + userAgent = userAgent ?? 'watch-together/$appVersion'; + + final Uri url; + final Duration memoryTtl; + final Duration diskTtl; + /// AniList 403s some default agents. + final String userAgent; + final HttpClient _http; + final bool _ownsHttp; + final Future Function() _cacheDir; + final Future Function(Duration) _delay; + final DateTime Function() _now; + + /// Pages per range at most (50 airings each). + static const maxPages = 40; + static const perPage = 50; + + static const query = r''' +query($from:Int,$to:Int,$page:Int){Page(page:$page,perPage:50){pageInfo{hasNextPage} +airingSchedules(airingAt_greater:$from,airingAt_lesser:$to,sort:TIME){airingAt episode +media{id title{romaji english} coverImage{large color} bannerImage isAdult popularity format}}}}'''; + + final _mem = {}; + final _inflight = >{}; + + static Future _defaultCacheDir() async { + try { + return await getApplicationCacheDirectory(); + } catch (_) { + return null; + } + } + + /// Every airing in [from, to), cached; see the file comment. [force] + /// skips the fresh caches (stale data is still the fallback). + Future schedule(DateTime from, DateTime to, {bool force = false}) { + final f = from.millisecondsSinceEpoch ~/ 1000, t = to.millisecondsSinceEpoch ~/ 1000; + final k = '$f-$t-$force'; + return _inflight[k] ??= _schedule(f, t, force).whenComplete(() { + _inflight.remove(k); + }); + } + + Future _schedule(int from, int to, bool force) async { + final now = _now(); + final mem = _mem[from]; + final memHit = mem != null && mem.to == to ? mem : null; + if (!force && memHit != null && now.difference(memHit.fetched) < memoryTtl) { + return _result(memHit); + } + final disk = await _readDisk(from, to); + if (!force && disk != null && now.difference(disk.fetched) < diskTtl) { + _mem[from] = disk; + return _result(disk); + } + try { + final raw = await _fetch(from, to); + final c = _mem[from] = _Cached(_now(), from, to, raw); + await _writeDisk(c); + return _result(c); + } catch (e) { + final stale = [memHit, disk].whereType<_Cached>().fold<_Cached?>( + null, (a, b) => a == null || b.fetched.isAfter(a.fetched) ? b : a); + if (stale == null) rethrow; + _log.warning('AniList failed, using cache from ${stale.fetched}: $e'); + return _result(stale, stale: true); + } + } + + AniListSchedule _result(_Cached c, {bool stale = false}) => AniListSchedule( + [for (final r in c.raw) ?Airing.fromJson(r)], + c.fetched, + staleSince: stale ? c.fetched : null, + ); + + Future> _fetch(int from, int to) async { + final out = []; + for (var page = 1; page <= maxPages; page++) { + final data = await _page(from, to, page); + final rows = data['airingSchedules']; + if (rows is List) out.addAll(rows); + final info = data['pageInfo']; + if (info is! Map || info['hasNextPage'] != true) break; + } + return out; + } + + Future _page(int from, int to, int page) async { + for (var attempt = 0;; attempt++) { + final (status, body, retryAfter) = await _post(jsonEncode({ + 'query': query, + 'variables': {'from': from - 1, 'to': to, 'page': page}, + })); + if (status == 429 && attempt == 0) { + final wait = retryAfter ?? 60; + _log.info('AniList rate limited, retrying in ${wait}s'); + await _delay(Duration(seconds: wait.clamp(1, 65))); + continue; + } + Object? j; + try { + j = jsonDecode(body); + } catch (_) {} + if (status != 200) { + throw AniListException('AniList HTTP $status${_errorText(j)}', statusCode: status); + } + final data = j is Map && j['data'] is Map ? (j['data'] as Map)['Page'] : null; + if (data is! Map) { + throw AniListException('AniList: bad response${_errorText(j)}', statusCode: status); + } + return data; + } + } + + static String _errorText(Object? j) { + final errs = j is Map ? j['errors'] : null; + if (errs is! List || errs.isEmpty) return ''; + final m = errs.first is Map ? (errs.first as Map)['message'] : null; + return m is String ? ': $m' : ''; + } + + Future<(int, String, int?)> _post(String body) async { + try { + final req = await _http.postUrl(url).timeout(const Duration(seconds: 15)); + req.headers + ..contentType = ContentType.json + ..set(HttpHeaders.acceptHeader, 'application/json') + ..set(HttpHeaders.userAgentHeader, userAgent); + req.write(body); + final res = await req.close().timeout(const Duration(seconds: 20)); + final text = await res.transform(const Utf8Decoder(allowMalformed: true)).join(); + return ( + res.statusCode, + text, + int.tryParse(res.headers.value(HttpHeaders.retryAfterHeader) ?? ''), + ); + } on TimeoutException { + throw const AniListException('AniList timed out'); + } on IOException catch (e) { + throw AniListException('AniList unreachable: $e'); + } + } + + // ---------------------------------------------------------------- disk + + Future _file(int from) async { + final dir = await _cacheDir(); + return dir == null ? null : File('${dir.path}/anilist-schedule-$from.json'); + } + + Future<_Cached?> _readDisk(int from, int to) async { + try { + final f = await _file(from); + if (f == null || !await f.exists()) return null; + final j = jsonDecode(await f.readAsString()); + if (j is! Map || j['from'] != from || j['to'] != to) return null; + final fetched = j['fetched'], raw = j['raw']; + if (fetched is! int || raw is! List) return null; + return _Cached(DateTime.fromMillisecondsSinceEpoch(fetched), from, to, raw); + } catch (_) { + return null; + } + } + + Future _writeDisk(_Cached c) async { + try { + final f = await _file(c.from); + if (f == null) return; + await f.parent.create(recursive: true); + await f.writeAsString(jsonEncode({ + 'fetched': c.fetched.millisecondsSinceEpoch, + 'from': c.from, + 'to': c.to, + 'raw': c.raw, + })); + // Older ranges are useless once a day has passed. + await for (final e in f.parent.list()) { + final name = e.uri.pathSegments.last; + if (e is File && + e.path != f.path && + name.startsWith('anilist-schedule-') && + _now().difference(await e.lastModified()) > const Duration(days: 1)) { + await e.delete(); + } + } + } catch (e) { + _log.warning('AniList cache write failed: $e'); + } + } + + /// Drops the memory cache (the disk cache stays as the stale fallback). + void clearMemory() => _mem.clear(); + + void close() { + if (_ownsHttp) _http.close(force: true); + } +} diff --git a/apps/desktop/lib/src/schedule/animeflow.dart b/apps/desktop/lib/src/schedule/animeflow.dart new file mode 100644 index 0000000..073a54a --- /dev/null +++ b/apps/desktop/lib/src/schedule/animeflow.dart @@ -0,0 +1,686 @@ +import 'dart:async'; +import 'dart:convert'; +import 'dart:io'; + +import 'package:sync_relay_core/core.dart'; + +import '../jellyfin/accounts.dart'; +import '../update/updater.dart' show appVersion; + +// AnimeFlow app API: what the media server tracks, has, downloads and was +// asked for. Authenticated with the user's own Jellyfin access token +// (X-Emby-Token) on the Jellyfin server next to it. +// +// Errors are JSON {"detail","code"} ([AnimeFlowException]); nginx in front +// may answer 429 with HTML (5 req/s, burst 20), retried with backoff. +// /schedule, /series, /series/{id} and /requests send weak ETags: the last +// body is kept and revalidated with If-None-Match. Times are UTC ISO +// ("2026-10-08T19:36:32Z"). Scopes can change at runtime, so /me is cached +// for a minute at most. + +final _log = createLogger('animeflow'); + +const animeFlowDefaultBase = 'https://anime.senkochan.moe/api/app/v1'; + +// ---------------------------------------------------------------- errors + +enum AnimeFlowErrorCode { + missingToken('missing_token'), + badToken('bad_token'), + revoked('revoked'), + missingScope('missing_scope'), + ownAccount('own_account'), + jellyfinUnreachable('jellyfin_unreachable'), + searchUnavailable('search_unavailable'), + invalidRequest('invalid_request'), + invalidTime('invalid_time'), + invalidRange('invalid_range'), + notFound('not_found'), + seriesNotFound('series_not_found'), + episodeNotFound('episode_not_found'), + requestNotFound('request_not_found'), + userNotFound('user_not_found'), + alreadyTracked('already_tracked'), + alreadyAvailable('already_available'), + alreadyRequested('already_requested'), + invalidState('invalid_state'), + /// nginx 429 (client side, no JSON body). + rateLimited('rate_limited'), + /// No answer: DNS, connect, timeout (client side). + network('network'), + /// Anything not in this list. + unknown('unknown'); + + const AnimeFlowErrorCode(this.wire); + final String wire; + + static AnimeFlowErrorCode parse(Object? s) => + values.where((c) => c.wire == s).firstOrNull ?? unknown; + + /// Best guess from the status alone (body was not JSON). + static AnimeFlowErrorCode forStatus(int status) => switch (status) { + 401 => badToken, + 403 => missingScope, + 404 => notFound, + 409 => invalidState, + 400 => invalidRequest, + 429 => rateLimited, + 503 => jellyfinUnreachable, + _ => unknown, + }; +} + +class AnimeFlowException implements Exception { + /// HTTP status; 0 when no answer came. + final int statusCode; + final AnimeFlowErrorCode code; + final String detail; + const AnimeFlowException(this.statusCode, this.code, this.detail); + + /// Sign in again (token missing, wrong or expired). + bool get isUnauthorized => statusCode == 401; + /// Revoked, missing scope or own account. + bool get isForbidden => statusCode == 403; + bool get isNotFound => statusCode == 404; + bool get isConflict => statusCode == 409; + bool get isUnavailable => statusCode == 503 || code == AnimeFlowErrorCode.network; + bool get isRateLimited => code == AnimeFlowErrorCode.rateLimited; + + @override + String toString() => 'AnimeFlowException($statusCode ${code.wire}): $detail'; +} + +// ---------------------------------------------------------------- models + +DateTime? _time(Object? v) => v is String ? DateTime.tryParse(v)?.toUtc() : null; +int? _int(Object? v) => v is int ? v : (v is num ? v.toInt() : null); +String? _str(Object? v) => v is String ? v : null; +List _items(Object? j) => + j is Map && j['items'] is List ? (j['items'] as List).whereType().toList() : const []; + +/// Formats [d] as AnimeFlow wants it: "2026-10-08T19:36:32Z". +String animeFlowTime(DateTime d) => '${d.toUtc().toIso8601String().split('.').first.replaceAll('Z', '')}Z'; + +enum AnimeFlowScope { + view, + request, + manage; + + static Set parse(Object? j) => { + if (j is List) + for (final s in j) ...values.where((v) => v.name == s), + }; +} + +class AnimeFlowMe { + final String userId; + final String name; + final Set scopes; + const AnimeFlowMe({required this.userId, required this.name, required this.scopes}); + + bool can(AnimeFlowScope s) => scopes.contains(s); + + static AnimeFlowMe fromJson(Object? j) { + final m = j is Map ? j : const {}; + return AnimeFlowMe( + userId: _str(m['user_id']) ?? '', + name: _str(m['name']) ?? '', + scopes: AnimeFlowScope.parse(m['scopes']), + ); + } +} + +enum EpisodeStatus { + upcoming, + missing, + downloading, + available, + unmonitored, + failed; + + /// Unknown values read as upcoming. + static EpisodeStatus parse(Object? s) => values.where((v) => v.name == s).firstOrNull ?? upcoming; +} + +class AnimeFlowEpisode { + /// 0 = specials. + final int season; + final int episode; + final int? absolute; + final String? title; + final DateTime? airDate; + /// The AniList entry of this episode's season (one AnimeFlow series may + /// span several AniList entries). + final int? anilistId; + final EpisodeStatus status; + /// 0..1, only while downloading. + final double? progress; + /// Only when failed. + final String? reason; + + const AnimeFlowEpisode({ + required this.season, + required this.episode, + this.absolute, + this.title, + this.airDate, + this.anilistId, + required this.status, + this.progress, + this.reason, + }); + + static AnimeFlowEpisode fromJson(Map j) => AnimeFlowEpisode( + season: _int(j['season']) ?? 0, + episode: _int(j['episode']) ?? 0, + absolute: _int(j['absolute']), + title: _str(j['episode_title']), + airDate: _time(j['air_date']), + anilistId: _int(j['anilist_id']), + status: EpisodeStatus.parse(j['status']), + progress: (j['progress'] as num?)?.toDouble(), + reason: _str(j['reason']), + ); +} + +/// A /schedule row: one episode plus its series. +class AnimeFlowScheduleItem { + final AnimeFlowEpisode episode; + final int seriesId; + final String title; + final int? anidbId; + final int? tvdbId; + final String? jellyfinSeriesId; + final bool followed; + + const AnimeFlowScheduleItem({ + required this.episode, + required this.seriesId, + required this.title, + this.anidbId, + this.tvdbId, + this.jellyfinSeriesId, + this.followed = false, + }); + + static AnimeFlowScheduleItem fromJson(Map j) => AnimeFlowScheduleItem( + episode: AnimeFlowEpisode.fromJson(j), + seriesId: _int(j['series_id']) ?? 0, + title: _str(j['title']) ?? '', + anidbId: _int(j['anidb_id']), + tvdbId: _int(j['tvdb_id']), + jellyfinSeriesId: _str(j['jellyfin_series_id']), + followed: j['followed'] == true, + ); +} + +class AnimeFlowScheduleWindow { + final DateTime? from; + final DateTime? to; + final List items; + const AnimeFlowScheduleWindow({this.from, this.to, required this.items}); +} + +enum SeriesState { + releasing, + released; + + static SeriesState parse(Object? s) => s == 'released' ? released : releasing; +} + +class AnimeFlowSeries { + final int seriesId; + final String title; + final int? anidbId; + final int? tvdbId; + /// The series' (first) AniList entry; later seasons may have others, see + /// [AnimeFlowEpisode.anilistId]. + final int? anilistId; + final String? jellyfinSeriesId; + final SeriesState state; + final bool monitored; + final bool followed; + final int episodesTotal; + final int episodesAvailable; + final DateTime? nextAirDate; + /// Only from [AnimeFlowClient.seriesDetail]. + final List episodes; + + const AnimeFlowSeries({ + required this.seriesId, + required this.title, + this.anidbId, + this.tvdbId, + this.anilistId, + this.jellyfinSeriesId, + this.state = SeriesState.releasing, + this.monitored = false, + this.followed = false, + this.episodesTotal = 0, + this.episodesAvailable = 0, + this.nextAirDate, + this.episodes = const [], + }); + + static AnimeFlowSeries fromJson(Map j) => AnimeFlowSeries( + seriesId: _int(j['series_id']) ?? 0, + title: _str(j['title']) ?? '', + anidbId: _int(j['anidb_id']), + tvdbId: _int(j['tvdb_id']), + anilistId: _int(j['anilist_id']), + jellyfinSeriesId: _str(j['jellyfin_series_id']), + state: SeriesState.parse(j['state']), + monitored: j['monitored'] == true, + followed: j['followed'] == true, + episodesTotal: _int(j['episodes_total']) ?? 0, + episodesAvailable: _int(j['episodes_available']) ?? 0, + nextAirDate: _time(j['next_air_date']), + episodes: [ + if (j['episodes'] is List) + for (final e in (j['episodes'] as List).whereType()) AnimeFlowEpisode.fromJson(e), + ], + ); +} + +class AnimeFlowSearchHit { + final int anidbId; + final String title; + final int? anilistId; + /// Set when AnimeFlow already tracks it. + final int? seriesId; + const AnimeFlowSearchHit({required this.anidbId, required this.title, this.anilistId, this.seriesId}); + + static AnimeFlowSearchHit fromJson(Map j) => AnimeFlowSearchHit( + anidbId: _int(j['anidb_id']) ?? 0, + title: _str(j['title']) ?? '', + anilistId: _int(j['anilist_id']), + seriesId: _int(j['series_id']), + ); +} + +enum RequestKind { + series, + season, + episode; + + static RequestKind parse(Object? s) => values.where((v) => v.name == s).firstOrNull ?? series; +} + +enum RequestStatus { + /// Briefly, before auto-approval or a manager decides. + pending, + approved, + searching, + done, + rejected, + failed; + + static RequestStatus parse(Object? s) => values.where((v) => v.name == s).firstOrNull ?? pending; +} + +class AnimeFlowRequest { + final int id; + final RequestKind kind; + final RequestStatus status; + final String title; + final int? anidbId; + final int? seriesId; + final int? season; + final int? episode; + final String? reason; + final DateTime? createdAt; + final DateTime? updatedAt; + /// Only with scope=all. + final String? userId; + final String? userName; + + const AnimeFlowRequest({ + required this.id, + required this.kind, + required this.status, + required this.title, + this.anidbId, + this.seriesId, + this.season, + this.episode, + this.reason, + this.createdAt, + this.updatedAt, + this.userId, + this.userName, + }); + + static AnimeFlowRequest fromJson(Map j) => AnimeFlowRequest( + id: _int(j['id']) ?? 0, + kind: RequestKind.parse(j['kind']), + status: RequestStatus.parse(j['status']), + title: _str(j['title']) ?? '', + anidbId: _int(j['anidb_id']), + seriesId: _int(j['series_id']), + season: _int(j['season']), + episode: _int(j['episode']), + reason: _str(j['reason']), + createdAt: _time(j['created_at']), + updatedAt: _time(j['updated_at']), + userId: _str(j['user_id']), + userName: _str(j['user_name']), + ); +} + +class AnimeFlowUser { + /// The Jellyfin user id. + final String userId; + final String name; + final Set scopes; + final bool revoked; + final DateTime? lastSeenAt; + const AnimeFlowUser({ + required this.userId, + required this.name, + required this.scopes, + this.revoked = false, + this.lastSeenAt, + }); + + static AnimeFlowUser fromJson(Map j) => AnimeFlowUser( + userId: _str(j['user_id']) ?? '', + name: _str(j['name']) ?? '', + scopes: AnimeFlowScope.parse(j['scopes']), + revoked: j['revoked'] == true, + lastSeenAt: _time(j['last_seen_at']), + ); +} + +// ---------------------------------------------------------------- client + +class _Etagged { + final String etag; + final Object? body; + const _Etagged(this.etag, this.body); +} + +class AnimeFlowClient { + /// [token] wins over [tokenProvider]; with neither, every call fails with + /// [AnimeFlowErrorCode.missingToken]. + AnimeFlowClient({ + String baseUrl = animeFlowDefaultBase, + this._token, + this._tokenProvider, + HttpClient? http, + Future Function(Duration)? delay, + DateTime Function()? now, + this.meTtl = const Duration(seconds: 60), + String? userAgent, + }) : baseUrl = baseUrl.endsWith('/') ? baseUrl.substring(0, baseUrl.length - 1) : baseUrl, + _http = http ?? HttpClient(), + _ownsHttp = http == null, + _delay = delay ?? Future.delayed, + _now = now ?? DateTime.now, + userAgent = userAgent ?? 'watch-together/$appVersion'; + + /// Authenticated with the linked Jellyfin account on the same domain + /// (see [tokenFor]), looked up on every call so a re-link applies. + factory AnimeFlowClient.fromAccounts( + JellyfinAccounts accounts, { + String baseUrl = animeFlowDefaultBase, + HttpClient? http, + }) => + AnimeFlowClient( + baseUrl: baseUrl, + http: http, + tokenProvider: () async { + await accounts.ensureLoaded(validate: false); + return tokenFor(accounts.accounts, baseUrl); + }, + ); + + final String baseUrl; + final Duration meTtl; + final String userAgent; + final String? _token; + final Future Function()? _tokenProvider; + final HttpClient _http; + final bool _ownsHttp; + final Future Function(Duration) _delay; + final DateTime Function() _now; + + /// Request bodies above this are refused by the server. + static const maxBodyBytes = 16 * 1024; + /// Retries after an nginx 429 (backoff 1 s, 2 s unless Retry-After). + static const rateLimitRetries = 2; + /// The longest /schedule window. + static const maxScheduleRange = Duration(days: 45); + + String? _lastToken; + final _etags = {}; + ({DateTime at, AnimeFlowMe me})? _me; + + /// The token of the linked Jellyfin account whose host shares + /// [animeFlowBase]'s parent domain (anime.example.org -> example.org, + /// jellyfin.example.org, ...). + static String? tokenFor(Iterable accounts, String animeFlowBase) { + final host = Uri.tryParse(animeFlowBase)?.host.toLowerCase() ?? ''; + if (host.isEmpty) return null; + final labels = host.split('.'); + final parent = labels.length > 2 ? labels.skip(1).join('.') : host; + bool matches(String url) { + final h = Uri.tryParse(url)?.host.toLowerCase() ?? ''; + return h.isNotEmpty && (h == parent || h.endsWith('.$parent')); + } + + for (final a in accounts) { + if (a.urls.any(matches)) return a.token; + } + return null; + } + + Future _authToken() async { + final t = _token ?? await _tokenProvider?.call(); + if (t == null || t.isEmpty) { + throw const AnimeFlowException( + 401, AnimeFlowErrorCode.missingToken, 'No linked Jellyfin account for AnimeFlow'); + } + if (t != _lastToken) { + // Another user: nothing cached belongs to them. + _lastToken = t; + _etags.clear(); + _me = null; + } + return t; + } + + Uri _uri(String path, [Map? query]) => + Uri.parse('$baseUrl$path').replace(queryParameters: query); + + /// Sends one call; JSON body or null (204). [etag]: revalidate the last + /// body of this URL. + Future _call(String method, String path, + {Map? query, Object? body, bool etag = false}) async { + final token = await _authToken(); + final uri = _uri(path, query); + final key = uri.toString(); + List? bytes; + if (body != null) { + bytes = utf8.encode(jsonEncode(body)); + if (bytes.length > maxBodyBytes) { + throw const AnimeFlowException(400, AnimeFlowErrorCode.invalidRequest, 'Request body too large'); + } + } + for (var attempt = 0;; attempt++) { + final cached = etag ? _etags[key] : null; + final (status, text, retryAfter, tag) = await _send(method, uri, token, bytes, cached?.etag); + if (status == 304 && cached != null) return cached.body; + if (status >= 200 && status < 300) { + final j = text.trim().isEmpty ? null : _decode(text, status); + if (etag && tag != null && tag.isNotEmpty) _etags[key] = _Etagged(tag, j); + return j; + } + final err = _error(status, text); + if (err.isRateLimited && attempt < rateLimitRetries) { + final wait = retryAfter != null ? Duration(seconds: retryAfter.clamp(1, 30)) : Duration(seconds: 1 << attempt); + _log.info('AnimeFlow rate limited on $path, retrying in ${wait.inSeconds}s'); + await _delay(wait); + continue; + } + throw err; + } + } + + static Object? _decode(String text, int status) { + try { + return jsonDecode(text); + } catch (_) { + throw AnimeFlowException(status, AnimeFlowErrorCode.unknown, 'Response is not JSON'); + } + } + + static AnimeFlowException _error(int status, String text) { + Object? j; + try { + j = jsonDecode(text); + } catch (_) {} + if (j is Map && j['code'] is String) { + final code = AnimeFlowErrorCode.parse(j['code']); + return AnimeFlowException(status, code == AnimeFlowErrorCode.unknown ? AnimeFlowErrorCode.forStatus(status) : code, + _str(j['detail']) ?? 'HTTP $status'); + } + // Not ours: nginx (HTML 429/502), a proxy, ... + return AnimeFlowException(status, AnimeFlowErrorCode.forStatus(status), + status == 429 ? 'Too many requests, try again shortly' : 'HTTP $status'); + } + + Future<(int, String, int?, String?)> _send( + String method, Uri uri, String token, List? body, String? ifNoneMatch) async { + try { + final req = await _http.openUrl(method, uri).timeout(const Duration(seconds: 15)); + req.headers + ..set(HttpHeaders.acceptHeader, 'application/json') + ..set(HttpHeaders.userAgentHeader, userAgent) + ..set('X-Emby-Token', token); + if (ifNoneMatch != null) req.headers.set(HttpHeaders.ifNoneMatchHeader, ifNoneMatch); + if (body != null) { + req.headers.contentType = ContentType.json; + req.contentLength = body.length; + req.add(body); + } + final res = await req.close().timeout(const Duration(seconds: 20)); + final text = await res.transform(const Utf8Decoder(allowMalformed: true)).join(); + return ( + res.statusCode, + text, + int.tryParse(res.headers.value(HttpHeaders.retryAfterHeader) ?? ''), + res.headers.value(HttpHeaders.etagHeader), + ); + } on TimeoutException { + throw const AnimeFlowException(0, AnimeFlowErrorCode.network, 'AnimeFlow timed out'); + } on IOException catch (e) { + throw AnimeFlowException(0, AnimeFlowErrorCode.network, 'AnimeFlow unreachable: $e'); + } + } + + // ------------------------------------------------------------ endpoints + + /// Who the token belongs to and what they may do. Cached for [meTtl] + /// (scopes change at runtime). + Future me({bool force = false}) async { + await _authToken(); + final c = _me; + if (!force && c != null && _now().difference(c.at) < meTtl) return c.me; + final me = AnimeFlowMe.fromJson(await _call('GET', '/me')); + _me = (at: _now(), me: me); + return me; + } + + /// Episodes airing in [from, to) (at most 45 days) [view]. + Future schedule(DateTime from, DateTime to) async { + if (!from.isBefore(to) || to.difference(from) > maxScheduleRange) { + throw const AnimeFlowException(400, AnimeFlowErrorCode.invalidRange, 'from must be before to, at most 45 days'); + } + final j = await _call('GET', '/schedule', + query: {'from': animeFlowTime(from), 'to': animeFlowTime(to)}, etag: true); + final m = j is Map ? j : const {}; + return AnimeFlowScheduleWindow( + from: _time(m['from']), + to: _time(m['to']), + items: [for (final e in _items(j)) AnimeFlowScheduleItem.fromJson(e)], + ); + } + + /// Every tracked series [view]. + Future> series() async => + [for (final s in _items(await _call('GET', '/series', etag: true))) AnimeFlowSeries.fromJson(s)]; + + /// One series with all its episodes [view]. + Future seriesDetail(int seriesId) async { + final j = await _call('GET', '/series/$seriesId', etag: true); + return AnimeFlowSeries.fromJson(j is Map ? j : const {}); + } + + /// Follow (or unfollow) a series [view]. + Future setFollowed(int seriesId, bool followed) => + _call(followed ? 'PUT' : 'DELETE', '/series/$seriesId/follow'); + + /// Titles that can be requested [request]. + Future> search(String q) async => [ + for (final h in _items(await _call('GET', '/search', query: {'q': q}))) AnimeFlowSearchHit.fromJson(h), + ]; + + /// Asks for a whole new series by its AniDB id [request]. + Future requestSeries(int anidbId) => _request({'anidb_id': anidbId}); + + /// Asks for one season of a tracked series [request]. + Future requestSeason(int seriesId, int season) => + _request({'series_id': seriesId, 'season': season}); + + /// Asks for one episode of a tracked series [request]. + Future requestEpisode(int seriesId, int season, int episode) => + _request({'series_id': seriesId, 'season': season, 'episode': episode}); + + Future _request(Map body) async { + final j = await _call('POST', '/requests', body: body); + return AnimeFlowRequest.fromJson(j is Map ? j : const {}); + } + + /// Own requests, newest first [view]; [all] = everyone's, with the user + /// [manage]. + Future> requests({bool all = false}) async => [ + for (final r in _items(await _call('GET', '/requests', query: all ? {'scope': 'all'} : null, etag: true))) + AnimeFlowRequest.fromJson(r), + ]; + + /// [manage]. Returns the updated request when the server sends one. + Future approve(int requestId) async => + _maybeRequest(await _call('POST', '/requests/$requestId/approve')); + + /// [manage]. + Future reject(int requestId, {String? reason}) async => _maybeRequest( + await _call('POST', '/requests/$requestId/reject', body: {'reason': ?reason})); + + static AnimeFlowRequest? _maybeRequest(Object? j) => + j is Map && j['id'] != null ? AnimeFlowRequest.fromJson(j) : null; + + /// [manage]. + Future> users() async => + [for (final u in _items(await _call('GET', '/users'))) AnimeFlowUser.fromJson(u)]; + + /// Sets a user's scopes (and revocation) [manage]. Returns the updated + /// user when the server sends one. + Future setScopes(String jellyfinUserId, Set scopes, {bool? revoked}) async { + final j = await _call('PUT', '/users/${Uri.encodeComponent(jellyfinUserId)}/scopes', body: { + 'scopes': [for (final s in AnimeFlowScope.values) if (scopes.contains(s)) s.name], + 'revoked': ?revoked, + }); + _me = null; // could be our own + return j is Map && j['user_id'] != null ? AnimeFlowUser.fromJson(j) : null; + } + + /// Forgets cached bodies and /me (e.g. on a manual refresh). + void clearCache() { + _etags.clear(); + _me = null; + } + + void close() { + if (_ownsHttp) _http.close(force: true); + } +} diff --git a/apps/desktop/lib/src/schedule/schedule_model.dart b/apps/desktop/lib/src/schedule/schedule_model.dart new file mode 100644 index 0000000..e0dfa7a --- /dev/null +++ b/apps/desktop/lib/src/schedule/schedule_model.dart @@ -0,0 +1,259 @@ +import 'dart:async'; + +import 'anilist.dart'; +import 'animeflow.dart'; + +// The Schedule tab's data: AniList says what airs when, AnimeFlow says +// what the media server has of it. [matchSchedule] merges the two and +// [ScheduleRepository] loads a window of both, degrading to AniList alone +// when AnimeFlow is unreachable or not linked. + +enum ScheduleStatus { + available, + downloading, + upcoming, + missing, + failed, + unmonitored, + /// AnimeFlow does not track this series (or is unreachable). + notTracked; + + static ScheduleStatus of(EpisodeStatus s) => switch (s) { + EpisodeStatus.available => available, + EpisodeStatus.downloading => downloading, + EpisodeStatus.upcoming => upcoming, + EpisodeStatus.missing => missing, + EpisodeStatus.failed => failed, + EpisodeStatus.unmonitored => unmonitored, + }; +} + +/// One airing with what AnimeFlow knows about it. +class ScheduleEntry { + final Airing airing; + final ScheduleStatus status; + /// 0..1 while downloading. + final double? progress; + /// Why it failed. + final String? reason; + /// AnimeFlow tracks the series. + final bool tracked; + final bool followed; + /// AnimeFlow's series id (follow, requests). + final int? seriesId; + /// The AnimeFlow season of the matched episode (requests). + final int? season; + /// Play target once available. + final String? jellyfinSeriesId; + + const ScheduleEntry({ + required this.airing, + required this.status, + this.progress, + this.reason, + this.tracked = false, + this.followed = false, + this.seriesId, + this.season, + this.jellyfinSeriesId, + }); + + @override + String toString() => 'ScheduleEntry(${airing.anilistId}#${airing.episode} $status)'; +} + +/// Without AnimeFlow: future airings are upcoming, past ones unknown. +ScheduleEntry untrackedEntry(Airing a, DateTime now) => ScheduleEntry( + airing: a, + status: a.airingAt.isAfter(now) ? ScheduleStatus.upcoming : ScheduleStatus.notTracked, + ); + +String _day(DateTime d) { + final l = d.toLocal(); + return '${l.year}-${l.month}-${l.day}'; +} + +class _SeriesRef { + final int seriesId; + final String? jellyfinSeriesId; + bool followed; + _SeriesRef(this.seriesId, this.jellyfinSeriesId, this.followed); +} + +/// Merges AniList [airings] with AnimeFlow's [schedule] rows and tracked +/// [series]. Per airing: the episode with the same AniList id (that +/// episode's season's entry) and episode number, else absolute number, +/// else the same local air day; unmatched airings of a tracked AniList id +/// are missing once aired, upcoming before; the rest are not tracked. +List matchSchedule( + List airings, { + required List schedule, + List series = const [], + DateTime? now, +}) { + final at = now ?? DateTime.now(); + final refs = {}; + for (final s in series) { + final id = s.anilistId; + if (id != null) refs[id] ??= _SeriesRef(s.seriesId, s.jellyfinSeriesId, s.followed); + } + final byEp = {}; + final byAbs = {}; + final byDay = {}; + for (final it in schedule) { + final e = it.episode, id = e.anilistId; + if (id == null) continue; + final ref = refs[id] ??= _SeriesRef(it.seriesId, it.jellyfinSeriesId, it.followed); + if (it.followed) ref.followed = true; + 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); + } + return [ + for (final a in airings) + () { + final id = a.anilistId; + final ref = refs[id]; + final it = byEp['$id#${a.episode}'] ?? byAbs['$id#${a.episode}'] ?? byDay['$id@${_day(a.airingAt)}']; + if (it != null) { + final e = it.episode; + return ScheduleEntry( + airing: a, + status: ScheduleStatus.of(e.status), + progress: e.status == EpisodeStatus.downloading ? e.progress : null, + reason: e.status == EpisodeStatus.failed ? e.reason : null, + tracked: true, + followed: it.followed || (ref?.followed ?? false), + seriesId: it.seriesId, + season: e.season, + jellyfinSeriesId: it.jellyfinSeriesId ?? ref?.jellyfinSeriesId, + ); + } + if (ref == null) return ScheduleEntry(airing: a, status: ScheduleStatus.notTracked); + return ScheduleEntry( + airing: a, + status: a.airingAt.isAfter(at) ? ScheduleStatus.upcoming : ScheduleStatus.missing, + tracked: true, + followed: ref.followed, + seriesId: ref.seriesId, + jellyfinSeriesId: ref.jellyfinSeriesId, + ); + }(), + ]; +} + +/// One loaded window. Errors are display text; [entries] is empty only +/// when AniList failed with nothing cached. +class ScheduleSnapshot { + final DateTime from; + final DateTime to; + /// Sorted by air time. + final List entries; + /// AniList failed and nothing was cached. + final String? aniListError; + /// AniList failed; [entries] come from the cache fetched then. + final DateTime? aniListStaleSince; + /// AnimeFlow is unreachable, refused or not linked: statuses are + /// AniList-only (upcoming / not tracked). + final String? animeFlowError; + /// The structured AnimeFlow failure behind [animeFlowError]. + final AnimeFlowException? animeFlowException; + /// Who we are on AnimeFlow (null when it failed). + final AnimeFlowMe? me; + + const ScheduleSnapshot({ + required this.from, + required this.to, + required this.entries, + this.aniListError, + this.aniListStaleSince, + this.animeFlowError, + this.animeFlowException, + this.me, + }); + + bool get hasAnimeFlow => me != null; +} + +/// Loads AniList and AnimeFlow for a window and merges them. +class ScheduleRepository { + ScheduleRepository({required this.aniList, this.animeFlow, DateTime Function()? now}) + : _now = now ?? DateTime.now; + + final AniListClient aniList; + /// Null = not configured (AniList only). + final AnimeFlowClient? animeFlow; + final DateTime Function() _now; + + /// Last week's Monday 00:00 local .. 4 weeks later. + static ({DateTime from, DateTime to}) defaultWindow(DateTime now) { + final l = now.toLocal(); + final from = DateTime(l.year, l.month, l.day - (l.weekday - 1) - 7); + return (from: from, to: DateTime(from.year, from.month, from.day + 28)); + } + + Future load({DateTime? from, DateTime? to, bool force = false}) async { + final w = defaultWindow(_now()); + final start = from ?? w.from, end = to ?? w.to; + final flowF = _animeFlow(start, end, force); + final AniListSchedule ani; + try { + ani = await aniList.schedule(start, end, force: force); + } catch (e) { + unawaited(flowF.then((_) {}, onError: (_) {})); + return ScheduleSnapshot(from: start, to: end, entries: const [], aniListError: _text(e)); + } + final now = _now(); + final flow = await flowF; + final airings = [...ani.airings]..sort((a, b) => a.airingAt.compareTo(b.airingAt)); + final entries = flow.error == null + ? matchSchedule(airings, schedule: flow.schedule, series: flow.series, now: now) + : [for (final a in airings) untrackedEntry(a, now)]; + return ScheduleSnapshot( + from: start, + to: end, + entries: entries, + aniListStaleSince: ani.staleSince, + animeFlowError: flow.error == null ? null : _text(flow.error!), + animeFlowException: flow.error is AnimeFlowException ? flow.error as AnimeFlowException : null, + me: flow.me, + ); + } + + Future<({AnimeFlowMe? me, List schedule, List series, Object? error})> + _animeFlow(DateTime from, DateTime to, bool force) async { + final c = animeFlow; + if (c == null) { + return (me: null, schedule: const [], series: const [], + error: 'AnimeFlow is not configured'); + } + try { + if (force) c.clearCache(); + final me = await c.me(); + final (sched, series) = await (c.schedule(from, to), c.series()).wait; + return (me: me, schedule: sched.items, series: series, error: null); + } on ParallelWaitError catch (e) { + final errs = e.errors as (AsyncError?, AsyncError?); + return ( + me: null, + schedule: const [], + series: const [], + error: (errs.$1 ?? errs.$2)!.error, + ); + } catch (e) { + return (me: null, schedule: const [], series: const [], error: e); + } + } + + static String _text(Object e) => switch (e) { + AnimeFlowException(:final code, :final detail) => switch (code) { + AnimeFlowErrorCode.missingToken => 'Link the Jellyfin server to see AnimeFlow statuses', + AnimeFlowErrorCode.badToken => 'AnimeFlow: sign in to Jellyfin again', + AnimeFlowErrorCode.revoked => 'AnimeFlow: access revoked', + AnimeFlowErrorCode.rateLimited => 'AnimeFlow: too many requests, try again shortly', + _ => 'AnimeFlow: $detail', + }, + AniListException(:final message) => message, + _ => '$e', + }; +} diff --git a/apps/desktop/test/schedule_anilist_test.dart b/apps/desktop/test/schedule_anilist_test.dart new file mode 100644 index 0000000..c9de308 --- /dev/null +++ b/apps/desktop/test/schedule_anilist_test.dart @@ -0,0 +1,171 @@ +import 'dart:convert'; +import 'dart:io'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:watch_together/src/schedule/anilist.dart'; + +import 'schedule_fake_server.dart'; + +Map airing(int id, int ep, int at, + {bool adult = false, String format = 'TV', String? english = 'Show', String? color = '#e4a15d'}) => + { + 'airingAt': at, + 'episode': ep, + 'media': { + 'id': id, + 'title': {'romaji': 'Romaji $id', 'english': english}, + 'coverImage': {'large': 'https://img/$id.jpg', 'color': color}, + 'bannerImage': null, + 'isAdult': adult, + 'popularity': id * 10, + 'format': format, + }, + }; + +Map page(List rows, {bool next = false}) => { + 'data': { + 'Page': { + 'pageInfo': {'hasNextPage': next}, + 'airingSchedules': rows, + }, + }, + }; + +void main() { + late FakeServer server; + late Directory cache; + var now = DateTime.utc(2026, 10, 8, 12); + final delays = []; + final from = DateTime.utc(2026, 9, 28), to = DateTime.utc(2026, 10, 26); + + AniListClient client() => AniListClient( + url: server.url, + cacheDir: () async => cache, + delay: (d) async => delays.add(d), + now: () => now, + userAgent: 'watch-together/test', + ); + + setUp(() async { + now = DateTime.utc(2026, 10, 8, 12); + delays.clear(); + cache = await Directory.systemTemp.createTemp('anilist-test'); + server = await FakeServer.start((req, res) async { + final vars = (jsonDecode(req.body) as Map)['variables'] as Map; + final p = vars['page'] as int; + json(res, page([ + if (p == 1) ...[airing(1, 5, 1759900000), airing(2, 1, 1759910000, adult: true)], + if (p == 2) ...[airing(3, 2, 1759920000, format: 'MUSIC'), airing(4, 12, 1759930000, english: null)], + ], next: p == 1)); + }); + }); + + tearDown(() async { + await server.close(); + await cache.delete(recursive: true); + }); + + test('pages, filters adult and music, parses fields', () async { + final c = client(); + final s = await c.schedule(from, to); + expect(server.seen, hasLength(2)); + final first = jsonDecode(server.seen.first.body) as Map; + expect(first['variables'], {'from': from.millisecondsSinceEpoch ~/ 1000 - 1, 'to': to.millisecondsSinceEpoch ~/ 1000, 'page': 1}); + expect(server.seen.first.headers.value(HttpHeaders.userAgentHeader), 'watch-together/test'); + expect(s.airings.map((a) => a.anilistId), [1, 4]); + final a = s.airings.first; + expect(a.title, 'Show'); + expect(a.titleRomaji, 'Romaji 1'); + expect(a.episode, 5); + expect(a.airingAt, DateTime.fromMillisecondsSinceEpoch(1759900000000, isUtc: true)); + expect(a.airingAt.isUtc, isTrue); + expect(a.cover, 'https://img/1.jpg'); + expect(a.color?.toARGB32(), 0xffe4a15d); + expect(a.popularity, 10); + expect(s.airings.last.title, 'Romaji 4'); // no English title + expect(s.isStale, isFalse); + c.close(); + }); + + test('memory cache, then disk cache across clients, expiry refetches', () async { + final c = client(); + await c.schedule(from, to); + await c.schedule(from, to); + expect(server.seen, hasLength(2)); + now = now.add(const Duration(minutes: 11)); // memory stale, disk fresh + await c.schedule(from, to); + expect(server.seen, hasLength(2)); + final c2 = client(); // restart + await c2.schedule(from, to); + expect(server.seen, hasLength(2)); + now = now.add(const Duration(hours: 1)); + await c2.schedule(from, to); + expect(server.seen, hasLength(4)); + // Another range start is another key. + await c2.schedule(from.add(const Duration(days: 7)), to); + expect(server.seen, hasLength(6)); + c.close(); + c2.close(); + }); + + test('429 waits Retry-After once, then succeeds', () async { + var n = 0; + final ok = server.handler; + server.handler = (req, res) async { + if (n++ == 0) { + res.statusCode = 429; + res.headers.set(HttpHeaders.retryAfterHeader, '7'); + res.write('{"errors":[{"message":"Too Many Requests."}]}'); + return; + } + await ok(req, res); + }; + final s = await client().schedule(from, to); + expect(delays, [const Duration(seconds: 7)]); + expect(s.airings, hasLength(2)); + }); + + test('429 twice fails', () async { + server.handler = (req, res) async { + res.statusCode = 429; + res.headers.set(HttpHeaders.retryAfterHeader, '2'); + }; + await expectLater( + client().schedule(from, to), + throwsA(isA().having((e) => e.isRateLimited, 'rate limited', isTrue))); + expect(delays, hasLength(1)); + expect(server.seen, hasLength(2)); + }); + + test('failure returns stale cache with its fetch time', () async { + final c = client(); + final fetched = now; + await c.schedule(from, to); + now = now.add(const Duration(hours: 2)); + server.handler = (req, res) async => json(res, {'errors': [{'message': 'down'}]}, status: 500); + final s = await c.schedule(from, to); + expect(s.isStale, isTrue); + expect(s.staleSince, fetched); + expect(s.airings, hasLength(2)); + // A fresh client (restart) falls back to the disk copy. + final s2 = await client().schedule(from, to); + expect(s2.staleSince!.isAtSameMomentAs(fetched), isTrue); + }); + + test('failure without cache throws', () async { + server.handler = (req, res) async => json(res, {'errors': [{'message': 'Bad'}]}, status: 400); + await expectLater(client().schedule(from, to), + throwsA(isA().having((e) => e.message, 'message', contains('Bad')))); + }); + + test('unreachable is an AniListException', () async { + await server.close(); + await expectLater(client().schedule(from, to), throwsA(isA())); + }); + + test('parseHexColor', () { + expect(parseHexColor('#000000')?.toARGB32(), 0xff000000); + expect(parseHexColor('e4a15d'), isNull); + expect(parseHexColor(null), isNull); + }); +} diff --git a/apps/desktop/test/schedule_animeflow_test.dart b/apps/desktop/test/schedule_animeflow_test.dart new file mode 100644 index 0000000..932a77a --- /dev/null +++ b/apps/desktop/test/schedule_animeflow_test.dart @@ -0,0 +1,335 @@ +import 'dart:convert'; +import 'dart:io'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:watch_together/src/jellyfin/accounts.dart'; +import 'package:watch_together/src/schedule/animeflow.dart'; + +import 'schedule_fake_server.dart'; + +const token = 'test-token'; + +final episodeJson = { + 'season': 2, + 'episode': 3, + 'absolute': 15, + 'episode_title': 'Three', + 'air_date': '2026-10-08T19:36:32Z', + 'anilist_id': 222, + 'status': 'downloading', + 'progress': 0.5, +}; + +final seriesJson = { + 'series_id': 7, + 'title': 'Show', + 'anidb_id': 70, + 'tvdb_id': 700, + 'anilist_id': 111, + 'jellyfin_series_id': 'jf7', + 'state': 'released', + 'monitored': true, + 'followed': true, + 'episodes_total': 24, + 'episodes_available': 20, + 'next_air_date': null, +}; + +final requestJson = { + 'id': 9, + 'kind': 'season', + 'title': 'Show S2', + 'series_id': 7, + 'season': 2, + 'status': 'searching', + 'created_at': '2026-10-01T10:00:00Z', + 'updated_at': '2026-10-02T10:00:00Z', +}; + +void main() { + late FakeServer server; + final delays = []; + var now = DateTime.utc(2026, 10, 8); + + AnimeFlowClient client({String? tok = token}) => AnimeFlowClient( + baseUrl: '${server.url}/api/app/v1', + token: tok, + delay: (d) async => delays.add(d), + now: () => now, + ); + + /// Routes "METHOD /path" (below the base) to a handler. + Future serve(Map routes) async { + server.handler = (req, res) async { + final path = req.uri.path.replaceFirst('/api/app/v1', ''); + final h = routes['${req.method} $path']; + if (h == null) return json(res, {'detail': 'Not found', 'code': 'not_found'}, status: 404); + await h(req, res); + }; + } + + setUp(() async { + delays.clear(); + now = DateTime.utc(2026, 10, 8); + server = await FakeServer.start((req, res) async {}); + }); + + tearDown(() => server.close()); + + test('/me: token header, scopes, cached for a minute', () async { + await serve({ + 'GET /me': (req, res) async => + json(res, {'user_id': 'u1', 'name': 'Foxyz', 'scopes': ['view', 'request', 'bogus']}), + }); + final c = client(); + final me = await c.me(); + expect(server.seen.single.headers.value('x-emby-token'), token); + expect(server.seen.single.headers.value(HttpHeaders.userAgentHeader), startsWith('watch-together/')); + expect(me.userId, 'u1'); + expect(me.name, 'Foxyz'); + expect(me.scopes, {AnimeFlowScope.view, AnimeFlowScope.request}); + expect(me.can(AnimeFlowScope.manage), isFalse); + await c.me(); + expect(server.seen, hasLength(1)); + now = now.add(const Duration(seconds: 61)); + await c.me(); + expect(server.seen, hasLength(2)); + }); + + test('/schedule: query in UTC ISO, items parsed, ETag/304 returns cached body', () async { + var etagHits = 0; + await serve({ + 'GET /schedule': (req, res) async { + if (req.headers.value(HttpHeaders.ifNoneMatchHeader) == 'W/"s1"') { + etagHits++; + res.statusCode = 304; + return; + } + json(res, { + 'from': req.uri.queryParameters['from'], + 'to': req.uri.queryParameters['to'], + 'items': [ + {...episodeJson, 'series_id': 7, 'title': 'Show', 'anidb_id': 70, 'tvdb_id': 700, + 'jellyfin_series_id': 'jf7', 'followed': true}, + ], + }, etag: 'W/"s1"'); + }, + }); + final c = client(); + final from = DateTime.utc(2026, 9, 28), to = DateTime.utc(2026, 10, 26); + final w = await c.schedule(from, to); + expect(server.seen.first.uri.queryParameters, {'from': '2026-09-28T00:00:00Z', 'to': '2026-10-26T00:00:00Z'}); + expect(w.from, from); + final it = w.items.single; + expect(it.seriesId, 7); + expect(it.title, 'Show'); + expect(it.anidbId, 70); + expect(it.tvdbId, 700); + expect(it.jellyfinSeriesId, 'jf7'); + expect(it.followed, isTrue); + final e = it.episode; + expect((e.season, e.episode, e.absolute, e.title, e.anilistId), (2, 3, 15, 'Three', 222)); + expect(e.airDate, DateTime.utc(2026, 10, 8, 19, 36, 32)); + expect(e.status, EpisodeStatus.downloading); + expect(e.progress, 0.5); + final again = await c.schedule(from, to); + expect(etagHits, 1); + expect(again.items.single.episode.anilistId, 222); + }); + + test('/schedule range checked locally', () async { + final c = client(); + final from = DateTime.utc(2026, 10, 1); + await expectLater(c.schedule(from, from), + throwsA(isA().having((e) => e.code, 'code', AnimeFlowErrorCode.invalidRange))); + await expectLater(c.schedule(from, from.add(const Duration(days: 46))), throwsA(isA())); + expect(server.seen, isEmpty); + }); + + test('/series, /series/{id} with episodes, follow/unfollow', () async { + await serve({ + 'GET /series': (req, res) async => json(res, {'items': [seriesJson]}, etag: 'W/"a"'), + 'GET /series/7': (req, res) async => json(res, { + ...seriesJson, + 'episodes': [ + {'season': 0, 'episode': 1, 'status': 'available'}, + {'season': 1, 'episode': 1, 'status': 'failed', 'reason': 'No release'}, + ], + }), + 'PUT /series/7/follow': (req, res) async => res.statusCode = 204, + 'DELETE /series/7/follow': (req, res) async => res.statusCode = 204, + }); + final c = client(); + final s = (await c.series()).single; + expect(s.seriesId, 7); + expect(s.anilistId, 111); + expect(s.state, SeriesState.released); + expect(s.monitored && s.followed, isTrue); + expect((s.episodesTotal, s.episodesAvailable, s.nextAirDate), (24, 20, null)); + final d = await c.seriesDetail(7); + expect(d.episodes.map((e) => e.season), [0, 1]); + expect(d.episodes.last.status, EpisodeStatus.failed); + expect(d.episodes.last.reason, 'No release'); + await c.setFollowed(7, true); + await c.setFollowed(7, false); + expect(server.seen.skip(2).map((r) => '${r.method} ${r.uri.path}'), + ['PUT /api/app/v1/series/7/follow', 'DELETE /api/app/v1/series/7/follow']); + }); + + test('/search and the three request bodies', () async { + await serve({ + 'GET /search': (req, res) async => json(res, { + 'items': [ + {'anidb_id': 70, 'title': 'Show', 'anilist_id': 111, 'series_id': 7}, + {'anidb_id': 71, 'title': 'Other'}, + ], + }), + 'POST /requests': (req, res) async => json(res, {...requestJson, ...jsonDecode(req.body) as Map}, status: 202), + }); + final c = client(); + final hits = await c.search('show'); + expect(server.seen.single.uri.queryParameters['q'], 'show'); + expect(hits.map((h) => (h.anidbId, h.title, h.anilistId, h.seriesId)), [(70, 'Show', 111, 7), (71, 'Other', null, null)]); + final r = await c.requestSeries(70); + expect(r.id, 9); + expect(r.status, RequestStatus.searching); + await c.requestSeason(7, 2); + await c.requestEpisode(7, 2, 3); + expect(server.seen.skip(1).map((r) => jsonDecode(r.body)), [ + {'anidb_id': 70}, + {'series_id': 7, 'season': 2}, + {'series_id': 7, 'season': 2, 'episode': 3}, + ]); + expect(server.seen.last.headers.contentType?.mimeType, 'application/json'); + }); + + test('/requests own and all, approve, reject', () async { + await serve({ + 'GET /requests': (req, res) async => json(res, { + 'items': [ + if (req.uri.queryParameters['scope'] == 'all') {...requestJson, 'user_id': 'u2', 'user_name': 'Bob'} + else requestJson, + ], + }), + 'POST /requests/9/approve': (req, res) async => json(res, {...requestJson, 'status': 'approved'}), + 'POST /requests/9/reject': (req, res) async => res.statusCode = 204, + }); + final c = client(); + final own = (await c.requests()).single; + expect(own.kind, RequestKind.season); + expect((own.seriesId, own.season, own.episode, own.userName), (7, 2, null, null)); + expect(own.createdAt, DateTime.utc(2026, 10, 1, 10)); + final all = (await c.requests(all: true)).single; + expect(all.userName, 'Bob'); + expect((await c.approve(9))?.status, RequestStatus.approved); + expect(await c.reject(9, reason: 'nope'), isNull); + expect(jsonDecode(server.seen.last.body), {'reason': 'nope'}); + await c.reject(9); + expect(jsonDecode(server.seen.last.body), {}); + }); + + test('/users and scopes', () async { + await serve({ + 'GET /users': (req, res) async => json(res, { + 'items': [ + {'user_id': 'u2', 'name': 'Bob', 'scopes': ['view'], 'revoked': true, 'last_seen_at': '2026-10-07T08:00:00Z'}, + ], + }), + 'PUT /users/u2/scopes': (req, res) async => res.statusCode = 204, + }); + final c = client(); + final u = (await c.users()).single; + expect((u.userId, u.name, u.revoked), ('u2', 'Bob', true)); + expect(u.scopes, {AnimeFlowScope.view}); + expect(u.lastSeenAt, DateTime.utc(2026, 10, 7, 8)); + await c.setScopes('u2', {AnimeFlowScope.request, AnimeFlowScope.view}, revoked: false); + expect(jsonDecode(server.seen.last.body), {'scopes': ['view', 'request'], 'revoked': false}); + }); + + test('error codes map to typed exceptions', () async { + const cases = { + 'missing_token': (401, AnimeFlowErrorCode.missingToken), + 'bad_token': (401, AnimeFlowErrorCode.badToken), + 'revoked': (403, AnimeFlowErrorCode.revoked), + 'missing_scope': (403, AnimeFlowErrorCode.missingScope), + 'own_account': (403, AnimeFlowErrorCode.ownAccount), + 'jellyfin_unreachable': (503, AnimeFlowErrorCode.jellyfinUnreachable), + 'search_unavailable': (503, AnimeFlowErrorCode.searchUnavailable), + 'invalid_request': (400, AnimeFlowErrorCode.invalidRequest), + 'invalid_time': (400, AnimeFlowErrorCode.invalidTime), + 'invalid_range': (400, AnimeFlowErrorCode.invalidRange), + 'not_found': (404, AnimeFlowErrorCode.notFound), + 'series_not_found': (404, AnimeFlowErrorCode.seriesNotFound), + 'episode_not_found': (404, AnimeFlowErrorCode.episodeNotFound), + 'request_not_found': (404, AnimeFlowErrorCode.requestNotFound), + 'user_not_found': (404, AnimeFlowErrorCode.userNotFound), + 'already_tracked': (409, AnimeFlowErrorCode.alreadyTracked), + 'already_available': (409, AnimeFlowErrorCode.alreadyAvailable), + 'already_requested': (409, AnimeFlowErrorCode.alreadyRequested), + 'invalid_state': (409, AnimeFlowErrorCode.invalidState), + 'something_new': (409, AnimeFlowErrorCode.invalidState), // falls back by status + }; + final c = client(); + for (final MapEntry(key: wire, value: (status, code)) in cases.entries) { + server.handler = (req, res) async => json(res, {'detail': 'because $wire', 'code': wire}, status: status); + try { + await c.series(); + fail('no throw for $wire'); + } on AnimeFlowException catch (e) { + expect((e.statusCode, e.code, e.detail), (status, code, 'because $wire'), reason: wire); + } + } + expect(delays, isEmpty); + }); + + test('nginx HTML 429 backs off, then succeeds or gives up', () async { + var n = 0; + server.handler = (req, res) async { + if (n++ < 2) { + res.statusCode = 429; + res.headers.contentType = ContentType.html; + res.write('

429 Too Many Requests

'); + return; + } + json(res, {'items': []}); + }; + final c = client(); + expect(await c.series(), isEmpty); + expect(delays, [const Duration(seconds: 1), const Duration(seconds: 2)]); + n = -10; + delays.clear(); + await expectLater( + c.series(), + throwsA(isA() + .having((e) => e.isRateLimited, 'rate limited', isTrue) + .having((e) => e.statusCode, 'status', 429))); + expect(delays, hasLength(AnimeFlowClient.rateLimitRetries)); + }); + + test('no token: missing_token without a request; oversized body refused', () async { + await expectLater(client(tok: null).series(), + throwsA(isA().having((e) => e.code, 'code', AnimeFlowErrorCode.missingToken))); + await expectLater(client().reject(1, reason: 'x' * 17000), + throwsA(isA().having((e) => e.code, 'code', AnimeFlowErrorCode.invalidRequest))); + expect(server.seen, isEmpty); + }); + + test('unreachable is a network error', () async { + await server.close(); + await expectLater(client().me(), + throwsA(isA().having((e) => e.code, 'code', AnimeFlowErrorCode.network))); + }); + + test('tokenFor picks the account on the same parent domain', () { + JellyfinAccount acc(String id, String url) => + JellyfinAccount(serverId: id, serverName: id, urls: [url], userId: 'u', userName: 'n', token: 'tok-$id'); + final accounts = [ + acc('a', 'https://jellyfin.other.org'), + acc('b', 'https://media.senkochan.moe:8920'), + ]; + expect(AnimeFlowClient.tokenFor(accounts, animeFlowDefaultBase), 'tok-b'); + expect(AnimeFlowClient.tokenFor([acc('c', 'https://senkochan.moe')], animeFlowDefaultBase), 'tok-c'); + expect(AnimeFlowClient.tokenFor([acc('d', 'https://notsenkochan.moe')], animeFlowDefaultBase), isNull); + expect(AnimeFlowClient.tokenFor(accounts, 'https://anime.example.net/api'), isNull); + }); +} diff --git a/apps/desktop/test/schedule_fake_server.dart b/apps/desktop/test/schedule_fake_server.dart new file mode 100644 index 0000000..3a3b06c --- /dev/null +++ b/apps/desktop/test/schedule_fake_server.dart @@ -0,0 +1,51 @@ +import 'dart:convert'; +import 'dart:io'; + +/// One request the fake server saw. +class SeenRequest { + final String method; + final Uri uri; + final HttpHeaders headers; + final String body; + SeenRequest(this.method, this.uri, this.headers, this.body); +} + +typedef FakeRoute = Future Function(SeenRequest req, HttpResponse res); + +/// A localhost HTTP server for the Schedule clients: [handler] answers, +/// [seen] records every request. +class FakeServer { + FakeServer._(this._server) : url = 'http://${_server.address.host}:${_server.port}'; + final HttpServer _server; + /// Kept after [close], so clients can try an address nobody answers. + final String url; + final seen = []; + late FakeRoute handler; + + static Future start(FakeRoute handler) async { + final s = FakeServer._(await HttpServer.bind(InternetAddress.loopbackIPv4, 0))..handler = handler; + s._server.listen((req) async { + final body = await utf8.decodeStream(req); + final r = SeenRequest(req.method, req.uri, req.headers, body); + s.seen.add(r); + try { + await s.handler(r, req.response); + } catch (e, st) { + // ignore: avoid_print + print('fake server handler failed: $e\n$st'); + req.response.statusCode = 599; + } + await req.response.close(); + }); + return s; + } + + Future close() => _server.close(force: true); +} + +void json(HttpResponse res, Object? body, {int status = 200, String? etag}) { + res.statusCode = status; + res.headers.contentType = ContentType.json; + if (etag != null) res.headers.set(HttpHeaders.etagHeader, etag); + res.write(jsonEncode(body)); +} diff --git a/apps/desktop/test/schedule_model_test.dart b/apps/desktop/test/schedule_model_test.dart new file mode 100644 index 0000000..d8c42d6 --- /dev/null +++ b/apps/desktop/test/schedule_model_test.dart @@ -0,0 +1,242 @@ +import 'dart:convert'; +import 'dart:io'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:watch_together/src/schedule/anilist.dart'; +import 'package:watch_together/src/schedule/animeflow.dart'; +import 'package:watch_together/src/schedule/schedule_model.dart'; + +import 'schedule_fake_server.dart'; + +// Local times, so the "same local air day" rule is timezone-proof. +final now = DateTime(2026, 10, 8, 12); + +Airing airing(int id, int ep, DateTime local) => Airing( + anilistId: id, + title: 'Show $id', + titleRomaji: 'Show $id', + episode: ep, + airingAt: local.toUtc(), + ); + +AnimeFlowScheduleItem item(int? anilistId, int ep, EpisodeStatus status, + {int season = 1, int? absolute, DateTime? air, int seriesId = 7, bool followed = false, + double? progress, String? reason}) => + AnimeFlowScheduleItem( + episode: AnimeFlowEpisode( + season: season, + episode: ep, + absolute: absolute, + airDate: air?.toUtc(), + anilistId: anilistId, + status: status, + progress: progress, + reason: reason, + ), + seriesId: seriesId, + title: 'Show', + jellyfinSeriesId: 'jf$seriesId', + followed: followed, + ); + +void main() { + group('matchSchedule', () { + test('by AniList id + episode, per season ids', () { + // One AnimeFlow series (7) spans two AniList entries: S1 = 100, S2 = 200. + final out = matchSchedule( + [airing(100, 3, DateTime(2026, 10, 1, 20)), airing(200, 3, DateTime(2026, 10, 9, 20))], + schedule: [ + item(100, 3, EpisodeStatus.available, season: 1), + item(200, 3, EpisodeStatus.downloading, season: 2, progress: 0.4, followed: true), + ], + series: [const AnimeFlowSeries(seriesId: 7, title: 'Show', anilistId: 100, jellyfinSeriesId: 'jf7')], + now: now, + ); + expect(out.map((e) => e.status), [ScheduleStatus.available, ScheduleStatus.downloading]); + expect(out.map((e) => e.season), [1, 2]); + expect(out[1].progress, 0.4); + expect(out[1].followed, isTrue); + expect(out.every((e) => e.tracked && e.seriesId == 7 && e.jellyfinSeriesId == 'jf7'), isTrue); + }); + + test('episode numbers do not cross AniList ids', () { + final out = matchSchedule( + [airing(200, 1, DateTime(2026, 10, 2, 20))], + schedule: [item(100, 1, EpisodeStatus.available)], + now: now, + ); + expect(out.single.status, ScheduleStatus.notTracked); + expect(out.single.tracked, isFalse); + }); + + test('absolute number, then same local 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 + ], + 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'), + ], + now: now, + ); + expect(out[0].status, ScheduleStatus.available); + expect(out[0].season, 22); + expect(out[1].status, ScheduleStatus.failed); + expect(out[1].reason, 'No release'); + }); + + test('a different local 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))], + now: now, + ); + // Tracked (the id is in the schedule) but unmatched and aired. + expect(out.single.status, ScheduleStatus.missing); + expect(out.single.tracked, isTrue); + }); + + test('tracked but unmatched: missing once aired, upcoming before', () { + final out = matchSchedule( + [airing(500, 2, DateTime(2026, 10, 7, 20)), airing(500, 3, DateTime(2026, 10, 14, 20))], + schedule: const [], + series: [const AnimeFlowSeries(seriesId: 9, title: 'S', anilistId: 500, followed: true)], + now: now, + ); + expect(out.map((e) => e.status), [ScheduleStatus.missing, ScheduleStatus.upcoming]); + expect(out.every((e) => e.followed && e.seriesId == 9), isTrue); + }); + + test('progress only while downloading, reason only when failed', () { + final out = matchSchedule( + [airing(600, 1, DateTime(2026, 10, 1, 20))], + schedule: [item(600, 1, EpisodeStatus.available, progress: 1, reason: 'x')], + now: now, + ); + expect(out.single.progress, isNull); + expect(out.single.reason, isNull); + }); + }); + + test('defaultWindow: last week Monday, 28 days', () { + final w = ScheduleRepository.defaultWindow(DateTime(2026, 10, 8, 15)); // Thursday + expect(w.from, DateTime(2026, 9, 28)); + expect(w.to, DateTime(2026, 10, 26)); + final mon = ScheduleRepository.defaultWindow(DateTime(2026, 10, 5, 1)); + expect(mon.from, DateTime(2026, 9, 28)); + }); + + group('ScheduleRepository', () { + late FakeServer server; + late Directory cache; + var aniDown = false; + var flowStatus = 200; + final at = DateTime(2026, 10, 1, 20).toUtc(); + + setUp(() async { + aniDown = false; + flowStatus = 200; + cache = await Directory.systemTemp.createTemp('schedule-repo-test'); + server = await FakeServer.start((req, res) async { + if (req.uri.path == '/anilist') { + if (aniDown) return json(res, {'errors': [{'message': 'down'}]}, status: 500); + return json(res, { + 'data': { + 'Page': { + 'pageInfo': {'hasNextPage': false}, + 'airingSchedules': [ + for (final (id, ep, t) in [(100, 3, at), (900, 1, at.add(const Duration(days: 20))), (901, 1, at)]) + { + 'airingAt': t.millisecondsSinceEpoch ~/ 1000, + 'episode': ep, + 'media': {'id': id, 'title': {'romaji': 'R$id'}, 'isAdult': false, 'format': 'TV'}, + }, + ], + }, + }, + }); + } + if (flowStatus != 200) { + return json(res, {'detail': 'Jellyfin is down', 'code': 'jellyfin_unreachable'}, status: flowStatus); + } + switch (req.uri.path) { + case '/flow/me': + return json(res, {'user_id': 'u', 'name': 'Me', 'scopes': ['view']}); + case '/flow/schedule': + return json(res, { + 'items': [ + {'season': 1, 'episode': 3, 'anilist_id': 100, 'status': 'available', 'series_id': 7, + 'title': 'Show', 'followed': false}, + ], + }); + case '/flow/series': + return json(res, {'items': []}); + } + }); + }); + + tearDown(() async { + await server.close(); + await cache.delete(recursive: true); + }); + + ScheduleRepository repo({bool flow = true}) => ScheduleRepository( + aniList: AniListClient(url: '${server.url}/anilist', cacheDir: () async => cache, now: () => now), + animeFlow: flow ? AnimeFlowClient(baseUrl: '${server.url}/flow', token: 'tok', now: () => now) : null, + now: () => now, + ); + + test('both ok: merged, sorted', () async { + final s = await repo().load(); + expect(s.aniListError, isNull); + expect(s.animeFlowError, isNull); + expect(s.me?.name, 'Me'); + expect(s.entries.map((e) => (e.airing.anilistId, e.status)), [ + (100, ScheduleStatus.available), + (901, ScheduleStatus.notTracked), + (900, ScheduleStatus.notTracked), + ]); + final aniReq = server.seen.firstWhere((r) => r.uri.path == '/anilist'); + final vars = (jsonDecode(aniReq.body) as Map)['variables'] as Map; + expect(vars['from'], DateTime(2026, 9, 28).millisecondsSinceEpoch ~/ 1000 - 1); + final sched = server.seen.firstWhere((r) => r.uri.path == '/flow/schedule'); + expect(sched.uri.queryParameters['from'], animeFlowTime(DateTime(2026, 9, 28))); + }); + + test('AnimeFlow failing: AniList-only statuses and an error', () async { + flowStatus = 503; + final s = await repo().load(); + expect(s.animeFlowException?.code, AnimeFlowErrorCode.jellyfinUnreachable); + expect(s.animeFlowError, contains('Jellyfin is down')); + expect(s.me, isNull); + expect(s.entries.map((e) => (e.airing.anilistId, e.status, e.tracked)), [ + (100, ScheduleStatus.notTracked, false), + (901, ScheduleStatus.notTracked, false), + (900, ScheduleStatus.upcoming, false), + ]); + }); + + test('AnimeFlow not configured', () async { + final s = await repo(flow: false).load(); + expect(s.animeFlowError, isNotNull); + expect(s.entries, hasLength(3)); + }); + + test('AniList failing: stale cache, else an error', () async { + final r = repo(); + aniDown = true; + final fail = await r.load(); + expect(fail.entries, isEmpty); + expect(fail.aniListError, contains('500')); + aniDown = false; + await r.load(); + aniDown = true; + final stale = await r.load(force: true); + expect(stale.aniListStaleSince, isNotNull); + expect(stale.entries, hasLength(3)); + expect(stale.entries.first.status, ScheduleStatus.available); + }); + }); +}