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 <noreply@anthropic.com>
This commit is contained in:
foxyzandClaude Opus 5.5 committed 2026-10-08 23:47:33 +03:00
1 parent c76fee70dc
commit caa0765890
7 files changed
+2072

No files matched your search

+328
View File
@@ -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<Airing> 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<Object?> raw;
const _Cached(this.fetched, this.from, this.to, this.raw);
}
class AniListClient {
AniListClient({
String url = aniListDefaultUrl,
HttpClient? http,
Future<Directory?> Function()? cacheDir,
this.memoryTtl = const Duration(minutes: 10),
this.diskTtl = const Duration(hours: 1),
Future<void> Function(Duration)? delay,
DateTime Function()? now,
String? userAgent,
}) : url = Uri.parse(url),
_http = http ?? HttpClient(),
_ownsHttp = http == null,
_cacheDir = cacheDir ?? _defaultCacheDir,
_delay = delay ?? Future<void>.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<Directory?> Function() _cacheDir;
final Future<void> 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 = <int, _Cached>{};
final _inflight = <String, Future<AniListSchedule>>{};
static Future<Directory?> _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<AniListSchedule> 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<AniListSchedule> _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<List<Object?>> _fetch(int from, int to) async {
final out = <Object?>[];
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<Map> _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?> _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<void> _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);
}
}
@@ -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<Map> _items(Object? j) =>
j is Map && j['items'] is List ? (j['items'] as List).whereType<Map>().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<AnimeFlowScope> 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<AnimeFlowScope> 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<AnimeFlowScheduleItem> 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<AnimeFlowEpisode> 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<Map>()) 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<AnimeFlowScope> 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<void> 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<void>.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<String?> Function()? _tokenProvider;
final HttpClient _http;
final bool _ownsHttp;
final Future<void> 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 = <String, _Etagged>{};
({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<JellyfinAccount> 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<String> _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<String, String>? 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<Object?> _call(String method, String path,
{Map<String, String>? query, Object? body, bool etag = false}) async {
final token = await _authToken();
final uri = _uri(path, query);
final key = uri.toString();
List<int>? 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<int>? 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<AnimeFlowMe> 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<AnimeFlowScheduleWindow> 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<List<AnimeFlowSeries>> series() async =>
[for (final s in _items(await _call('GET', '/series', etag: true))) AnimeFlowSeries.fromJson(s)];
/// One series with all its episodes [view].
Future<AnimeFlowSeries> 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<void> setFollowed(int seriesId, bool followed) =>
_call(followed ? 'PUT' : 'DELETE', '/series/$seriesId/follow');
/// Titles that can be requested [request].
Future<List<AnimeFlowSearchHit>> 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<AnimeFlowRequest> requestSeries(int anidbId) => _request({'anidb_id': anidbId});
/// Asks for one season of a tracked series [request].
Future<AnimeFlowRequest> requestSeason(int seriesId, int season) =>
_request({'series_id': seriesId, 'season': season});
/// Asks for one episode of a tracked series [request].
Future<AnimeFlowRequest> requestEpisode(int seriesId, int season, int episode) =>
_request({'series_id': seriesId, 'season': season, 'episode': episode});
Future<AnimeFlowRequest> _request(Map<String, Object> 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<List<AnimeFlowRequest>> 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<AnimeFlowRequest?> approve(int requestId) async =>
_maybeRequest(await _call('POST', '/requests/$requestId/approve'));
/// [manage].
Future<AnimeFlowRequest?> 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<List<AnimeFlowUser>> 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<AnimeFlowUser?> setScopes(String jellyfinUserId, Set<AnimeFlowScope> 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);
}
}
@@ -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<ScheduleEntry> matchSchedule(
List<Airing> airings, {
required List<AnimeFlowScheduleItem> schedule,
List<AnimeFlowSeries> series = const [],
DateTime? now,
}) {
final at = now ?? DateTime.now();
final refs = <int, _SeriesRef>{};
for (final s in series) {
final id = s.anilistId;
if (id != null) refs[id] ??= _SeriesRef(s.seriesId, s.jellyfinSeriesId, s.followed);
}
final byEp = <String, AnimeFlowScheduleItem>{};
final byAbs = <String, AnimeFlowScheduleItem>{};
final byDay = <String, AnimeFlowScheduleItem>{};
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<ScheduleEntry> 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<ScheduleSnapshot> 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<AnimeFlowScheduleItem> schedule, List<AnimeFlowSeries> series, Object? error})>
_animeFlow(DateTime from, DateTime to, bool force) async {
final c = animeFlow;
if (c == null) {
return (me: null, schedule: const <AnimeFlowScheduleItem>[], series: const <AnimeFlowSeries>[],
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 <AnimeFlowScheduleItem>[],
series: const <AnimeFlowSeries>[],
error: (errs.$1 ?? errs.$2)!.error,
);
} catch (e) {
return (me: null, schedule: const <AnimeFlowScheduleItem>[], series: const <AnimeFlowSeries>[], 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',
};
}