Restructure step 4: local library and media identity
ci / core (push) Successful in 29s
ci / server (push) Successful in 1m8s
ci / updater-windows (push) Failing after 6m2s

- L1 (core, offline): release names with kinds (episode, movie, extra:
  OP/ED/Opening/Ending/NC/PV/OVA…), folder hints (season and name from
  parentheticals, ranges), one show across folders, gaps and next with
  skipped numbers, junk ignored, stable keys.
- L2 (app, device-owned cache): AniList match with synonyms and
  sequel walking, episode mapping across cours (AnimeFlow runs when
  signed in), art and episode titles, "Wrong show?" pins per show or
  folder, episode offsets; lazy, spaced requests, works offline.
- Files tab as a library: poster cards, show pages with banner, gap
  prompt, extras, movies, Wrong show? and Episode numbers.
- media.ref produced for local files, Jellyfin (provider ids) and links,
  carried in swaps and Up next; receivers name media from it.
- Hosting relays each Jellyfin item through its own server's login.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
foxyzandClaude Opus 5.5 committed 2026-10-15 23:34:39 +03:00
1 parent 4d00b7455e
commit bf9fc193b3
44 files changed
+4350 -891

No files matched your search

+6 -6
View File
@@ -24,9 +24,10 @@ bool _sameItemId(String a, String b) => (normalizeItemId(a) ?? a) == (normalizeI
Response _plain(int status) => Response(status, body: Body.fromString('status $status'));
/// `GET /j/<sid>/<token>/<itemId>`: relays byte ranges of the current
/// Jellyfin media from [credential]'s server to a viewer, for guests without
/// Jellyfin media from its Jellyfin server to a viewer, with the server's
/// login there ([credentialFor] the ref's `serverId`), for guests without
/// their own access. Only the active ref's item, or Up next's first one
/// ([next], so viewers can get it ready), on the credential's server, only
/// ([next], so viewers can get it ready), on a server with a login, only
/// for a valid viewer token: no open proxy. Range goes up as sent;
/// status, Content-Range, Content-Length, Content-Type and Accept-Ranges
/// come back as Jellyfin sent them (a 401/403 from Jellyfin is the server's
@@ -38,7 +39,7 @@ Response _plain(int status) => Response(status, body: Body.fromString('status $s
void mountJellyfinRelayRoute(
RelicApp app,
SessionMap sessions,
JellyfinCredential? Function() credential,
JellyfinCredential? Function(String serverId) credentialFor,
HttpClient Function() client, {
JellyfinRef? Function()? next,
String pattern = '/j/:sid/:token/:itemId',
@@ -58,14 +59,13 @@ void mountJellyfinRelayRoute(
default:
return _plain(401);
}
final cred = credential();
if (cred == null || cred.serverId.isEmpty) return _plain(404);
JellyfinRef? onLogin(JellyfinRef? r) => r != null && cred.serverId == r.serverId ? r : null;
JellyfinRef? onLogin(JellyfinRef? r) => r != null && credentialFor(r.serverId) != null ? r : null;
final current = onLogin(session.media?.jellyfin);
final upNext = onLogin(next?.call());
if (current == null && upNext == null) return _plain(404);
final ref = [current, upNext].firstWhere((r) => r != null && _sameItemId(itemId, r.itemId), orElse: () => null);
if (ref == null) return _plain(403);
final cred = credentialFor(ref.serverId)!;
final range = req.headers['range']?.join(',');
final HttpClientRequest upReq;
+6 -1
View File
@@ -61,7 +61,12 @@ Future<void> serve(Map<String, Object?> config, {SendPort? events}) async {
final port = await server.share(
port: (msg['port'] as num?)?.toInt() ?? 0,
secret: (msg['secret'] as String?) ?? '',
jellyfin: JellyfinCredential.fromMap(msg['jellyfin']));
jellyfin: msg['jellyfin'] is List
? [
for (final c in msg['jellyfin'] as List)
if (JellyfinCredential.fromMap(c) case final cred?) cred,
]
: null);
reply.send({'ok': true, 'port': port});
} catch (e) {
reply.send({'ok': false, 'message': '$e'});
+24 -11
View File
@@ -23,16 +23,31 @@ class ServerHub {
/// The `/sync` socket of each member that has one, by token; a `gone`
/// member has none.
final Map<String, RelicWebSocket> sockets = {};
/// The server's Jellyfin login for the `/j/...` relay; null = no relay.
JellyfinCredential? jellyfin;
/// The server's Jellyfin logins for the `/j/...` relay, at most one per
/// Jellyfin server (`serverId`); empty = no relay. The dedicated
/// server has its configured one; the app's own server one for each
/// linked account, so an item is relayed with its own server's login.
List<JellyfinCredential> jellyfinLogins;
/// The first login (the dedicated server's only one); null = none.
JellyfinCredential? get jellyfin => jellyfinLogins.isEmpty ? null : jellyfinLogins.first;
/// The login on Jellyfin server [serverId]; null when there is none.
JellyfinCredential? loginFor(String serverId) {
for (final c in jellyfinLogins) {
if (c.serverId.isNotEmpty && c.serverId == serverId) return c;
}
return null;
}
/// Reads web pages for "the server fetches it"; null = files only.
final YtDlp? ytDlp;
/// Random per server boot (`welcome.instance`).
final String instance = _randomId();
ServerHub(this.sessions, this.expectedSecret,
{SyncEngine? engine, this.hostKey = '', this.jellyfin, this.ytDlp})
: engine = engine ?? SyncEngine() {
{SyncEngine? engine, this.hostKey = '', JellyfinCredential? jellyfin, this.ytDlp})
: jellyfinLogins = [if (jellyfin != null) jellyfin],
engine = engine ?? SyncEngine() {
this.engine
..onGone = _memberGone
..onLeft = _memberLeft;
@@ -106,20 +121,18 @@ class ServerHub {
bool get _sourceGone => engine.media?.relay?.sourceGone ?? false;
/// The current media is a Jellyfin ref on the server [jellyfin] logs in to.
/// The current media is a Jellyfin ref on a server one of
/// [jellyfinLogins] logs in to.
bool get relayAvailable => _onLogin(session.media?.jellyfin);
/// Up next's first item is a Jellyfin ref on that server: `/j` serves it
/// too, so viewers without access get it ready.
/// Up next's first item is a Jellyfin ref on such a server: `/j` serves
/// it too, so viewers without access get it ready.
bool get relayNextAvailable => _onLogin(nextJellyfinRef);
/// Up next's first item's Jellyfin ref (null: none, or not a ref).
JellyfinRef? get nextJellyfinRef => engine.queue.items.firstOrNull?.media.jellyfin;
bool _onLogin(JellyfinRef? ref) {
final cred = jellyfin;
return ref != null && cred != null && cred.serverId.isNotEmpty && cred.serverId == ref.serverId;
}
bool _onLogin(JellyfinRef? ref) => ref != null && loginFor(ref.serverId) != null;
/// What the swapper reads when the server cannot fetch a link ([why]
/// from the relay). A yt-dlp failure on a server without cookies says
+26 -19
View File
@@ -130,7 +130,7 @@ class SyncServer {
mountArtRoute(_app, hub);
mountSessionLogRoutes(_app, hub, () => _sessionLog);
mountJellyfinRelayRoute(
_app, sessions, () => hub.jellyfin, () => _relayClient ??= HttpClient(),
_app, sessions, hub.loginFor, () => _relayClient ??= HttpClient(),
next: () => hub.nextJellyfinRef, onLog: logRequest);
// advertiseHost is only an explicit override; otherwise echo what the client dialled.
String authority(String dialledHost) => config.advertiseHost.isNotEmpty
@@ -193,32 +193,39 @@ class SyncServer {
}
/// A configured credential without a ServerId gets it from
/// `/System/Info/Public` (unauthenticated). On failure the relay stays off.
/// `/System/Info/Public` (unauthenticated). On failure that login stays
/// unused.
Future<void> _resolveJellyfinServerId() async {
final cred = hub.jellyfin;
if (cred == null) return;
if (cred.serverId.isEmpty) {
final client = JellyfinClient(baseUrl: cred.baseUrl, clientInfo: cred.clientInfo);
try {
final info = await client.publicInfo().timeout(const Duration(seconds: 10));
hub.jellyfin = cred.withServerId(info.id);
} catch (e) {
_log.warning('jellyfin relay off: server id lookup failed: $e');
return;
} finally {
client.close();
final logins = <JellyfinCredential>[];
for (final cred in hub.jellyfinLogins) {
var c = cred;
if (c.serverId.isEmpty) {
final client = JellyfinClient(baseUrl: c.baseUrl, clientInfo: c.clientInfo);
try {
final info = await client.publicInfo().timeout(const Duration(seconds: 10));
c = c.withServerId(info.id);
} catch (e) {
_log.warning('jellyfin relay off for ${c.baseUrl}: server id lookup failed: $e');
} finally {
client.close();
}
}
if (c.serverId.isNotEmpty && !logins.any((l) => l.serverId == c.serverId)) {
logins.add(c);
_log.info('jellyfin relay on for server ${c.serverId}');
}
}
_log.info('jellyfin relay on for server ${hub.jellyfin!.serverId}');
hub.jellyfinLogins = logins;
}
/// Opens the running server to the network: a second listener on
/// every IPv4 interface at [port] (0: a free one), the password
/// [secret] ('' : open) and the Jellyfin relay login [jellyfin]. Members
/// [secret] ('' : open) and the Jellyfin relay logins [jellyfin] (one
/// per Jellyfin server; null keeps the ones it has). Members
/// already in (on the first listener) stay connected; nothing restarts.
/// Shared already: the listener moves to [port] when it differs. Returns
/// the bound port; throws when it cannot bind.
Future<int> share({required int port, required String secret, JellyfinCredential? jellyfin}) async {
Future<int> share({required int port, required String secret, List<JellyfinCredential>? jellyfin}) async {
if (_server == null) throw StateError('the server is not running');
final was = _shared;
if (was != null && (port == 0 || port == was.port)) {
@@ -247,9 +254,9 @@ class SyncServer {
return listener.port;
}
void _applyShareSettings(String secret, JellyfinCredential? jellyfin) {
void _applyShareSettings(String secret, List<JellyfinCredential>? jellyfin) {
session.secret = secret;
if (jellyfin != null) hub.jellyfin = jellyfin;
if (jellyfin != null) hub.jellyfinLogins = [...jellyfin];
}
/// Closes the network listener [share] opened: everyone but the host