forked from Foxyz/sync_replay_project
Restructure step 4: local library and media identity
- 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:
1 parent
4d00b7455e
commit
bf9fc193b3
44 files changed
+4350
-891
No files matched your search
@@ -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;
|
||||
|
||||
@@ -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'});
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in new issue
Block a user