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

@@ -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<void> 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 = <SeenRequest>[];
late FakeRoute handler;
static Future<FakeServer> 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<void> 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));
}