Jellyfin refs on the wire + guest relay (proto 7)
- Media can be a Jellyfin ref {serverId, itemId, mediaSourceId, title,
subtitle, runtimeSecs}; the server never stores or broadcasts a URL
or token for it.
- Optional server-side Jellyfin credential (serve map / --jellyfin
file) enables GET /j/<sid>/<token>/<itemId>: range-faithful proxy of
the active item only, exact client header fields, upstream aborted on
disconnect, bytes counted. welcome.relayUrl / state.relay advertise it.
- Members report jf access (own account vs relay).
- /m now sends the real Content-Type (Relic overrode it).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
fb2927c094
commit
45f4c1bc84
18 files changed
+1363
-66
No files matched your search
@@ -3,3 +3,4 @@ export 'src/serve.dart';
|
||||
export 'src/server_hub.dart';
|
||||
export 'src/session.dart';
|
||||
export 'src/sync_server.dart';
|
||||
export 'src/jellyfin_relay.dart';
|
||||
@@ -0,0 +1,160 @@
|
||||
import 'dart:async';
|
||||
import 'dart:io';
|
||||
import 'dart:typed_data';
|
||||
|
||||
import 'package:relic/relic.dart';
|
||||
import 'package:sync_relay_core/core.dart';
|
||||
|
||||
import 'media_endpoint.dart';
|
||||
import 'session.dart';
|
||||
|
||||
final _relayLog = createLogger('jellyfin_relay');
|
||||
|
||||
/// Upstream request for one relayed range: the original file
|
||||
/// (`/Videos/{id}/stream?static=true`) with the server's credential in the
|
||||
/// `Authorization` header (never in the URL, so nothing to leak in logs).
|
||||
Uri jellyfinStaticStreamUri(JellyfinCredential cred, JellyfinRef ref) {
|
||||
final base = normalizeServerUrl(cred.baseUrl) ?? cred.baseUrl;
|
||||
return Uri.parse('$base/Videos/${Uri.encodeComponent(ref.itemId)}/stream').replace(
|
||||
queryParameters: {'static': 'true', 'mediaSourceId': ref.mediaSourceId ?? ref.itemId});
|
||||
}
|
||||
|
||||
bool _sameItemId(String a, String b) => (normalizeItemId(a) ?? a) == (normalizeItemId(b) ?? b);
|
||||
|
||||
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
|
||||
/// their own access. Only the active ref's item on the credential's server,
|
||||
/// 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
|
||||
/// credential failing, so the viewer gets 502). Bytes stream through without
|
||||
/// buffering, are counted as sent, and a viewer disconnect aborts upstream.
|
||||
///
|
||||
/// [client] gives the HTTP client for upstream requests (the caller owns
|
||||
/// and closes it).
|
||||
void mountJellyfinRelayRoute(
|
||||
RelicApp app,
|
||||
SessionMap sessions,
|
||||
JellyfinCredential? Function() credential,
|
||||
HttpClient Function() client, {
|
||||
String pattern = '/j/:sid/:token/:itemId',
|
||||
void Function(String token, MediaLog log)? onLog,
|
||||
Duration timeout = const Duration(seconds: 20),
|
||||
}) {
|
||||
app.get(pattern, (final req) async {
|
||||
final stopwatch = Stopwatch()..start();
|
||||
final sid = req.pathParameters.raw[#sid] ?? '';
|
||||
final token = req.pathParameters.raw[#token] ?? '';
|
||||
final itemId = req.pathParameters.raw[#itemId] ?? '';
|
||||
final session = sessions.get(sid);
|
||||
if (session == null) return _plain(410);
|
||||
switch (session.gate(token)) {
|
||||
case MediaGate.ok:
|
||||
break;
|
||||
case MediaGate.forbidden:
|
||||
return _plain(403);
|
||||
default:
|
||||
return _plain(401);
|
||||
}
|
||||
final ref = session.media?.jellyfin;
|
||||
final cred = credential();
|
||||
if (ref == null || cred == null || cred.serverId.isEmpty || cred.serverId != ref.serverId) {
|
||||
return _plain(404);
|
||||
}
|
||||
if (!_sameItemId(itemId, ref.itemId)) return _plain(403);
|
||||
|
||||
final range = req.headers['range']?.join(',');
|
||||
final HttpClientRequest upReq;
|
||||
final HttpClientResponse upRes;
|
||||
try {
|
||||
upReq = await client().getUrl(jellyfinStaticStreamUri(cred, ref)).timeout(timeout);
|
||||
upReq.followRedirects = false;
|
||||
upReq.headers
|
||||
..set(HttpHeaders.authorizationHeader, cred.clientInfo.authorizationHeader(cred.token))
|
||||
..set(HttpHeaders.acceptEncodingHeader, 'identity');
|
||||
if (range != null && range.isNotEmpty) upReq.headers.set(HttpHeaders.rangeHeader, range);
|
||||
upRes = await upReq.close().timeout(timeout);
|
||||
} catch (e) {
|
||||
_relayLog.warning('upstream ${ref.itemId}: $e');
|
||||
return _plain(502);
|
||||
}
|
||||
|
||||
final shortToken = token.length > 4 ? '…${token.substring(token.length - 4)}' : token;
|
||||
void log(int status, int bytes) => onLog?.call(
|
||||
token,
|
||||
MediaLog(
|
||||
method: req.method.name,
|
||||
path: '/j/$sid/$shortToken/$itemId',
|
||||
rangeHeader: range,
|
||||
status: status,
|
||||
bytesWritten: bytes,
|
||||
durationMs: stopwatch.elapsedMilliseconds,
|
||||
));
|
||||
|
||||
if (upRes.statusCode == 401 || upRes.statusCode == 403) {
|
||||
_relayLog.warning('upstream ${ref.itemId}: ${upRes.statusCode} (server credential rejected)');
|
||||
await upRes.listen(null).cancel();
|
||||
log(502, 0);
|
||||
return _plain(502);
|
||||
}
|
||||
|
||||
final headers = Headers.build((h) {
|
||||
for (final name in const ['content-range', 'accept-ranges']) {
|
||||
final v = upRes.headers.value(name);
|
||||
if (v != null) h[name] = [v];
|
||||
}
|
||||
});
|
||||
MimeType mime;
|
||||
try {
|
||||
mime = MimeType.parse(upRes.headers.value(HttpHeaders.contentTypeHeader) ?? '');
|
||||
} catch (_) {
|
||||
mime = MimeType.octetStream;
|
||||
}
|
||||
var sent = 0;
|
||||
late final StreamController<Uint8List> body;
|
||||
StreamSubscription<List<int>>? sub;
|
||||
body = StreamController<Uint8List>(
|
||||
onListen: () {
|
||||
sub = upRes.listen(
|
||||
(chunk) {
|
||||
session.addBytes(token, chunk.length);
|
||||
sent += chunk.length;
|
||||
body.add(chunk is Uint8List ? chunk : Uint8List.fromList(chunk));
|
||||
},
|
||||
onError: (Object e) {
|
||||
_relayLog.warning('upstream ${ref.itemId} broke: $e');
|
||||
sub = null;
|
||||
log(upRes.statusCode, sent);
|
||||
body.addError(e);
|
||||
unawaited(body.close());
|
||||
},
|
||||
onDone: () {
|
||||
sub = null;
|
||||
log(upRes.statusCode, sent);
|
||||
unawaited(body.close());
|
||||
},
|
||||
cancelOnError: true,
|
||||
);
|
||||
},
|
||||
onPause: () => sub?.pause(),
|
||||
onResume: () => sub?.resume(),
|
||||
onCancel: () async {
|
||||
// Viewer went away (mpv drops `bytes=N-` on every seek): cancelling
|
||||
// an unfinished HttpClientResponse closes its connection, so the
|
||||
// upstream transfer stops instead of draining the rest of the file.
|
||||
final s = sub;
|
||||
sub = null;
|
||||
await s?.cancel();
|
||||
if (s != null) log(upRes.statusCode, sent);
|
||||
},
|
||||
);
|
||||
return Response(
|
||||
upRes.statusCode,
|
||||
body: Body.fromDataStream(body.stream,
|
||||
mimeType: mime, contentLength: upRes.contentLength >= 0 ? upRes.contentLength : null),
|
||||
headers: headers,
|
||||
);
|
||||
});
|
||||
}
|
||||
@@ -136,7 +136,9 @@ void mountMediaRoute(RelicApp app, File file,
|
||||
log(byteCount);
|
||||
return Response(
|
||||
res.status,
|
||||
body: Body.fromDataStream(stream, contentLength: byteCount),
|
||||
// Relic replaces Content-Type with the body's mime type, so set it here.
|
||||
body: Body.fromDataStream(stream,
|
||||
mimeType: _mimeOf(res.headers['content-type']), contentLength: byteCount),
|
||||
headers: Headers.build((h) {
|
||||
res.headers.forEach((k, v) => h[k] = [v]);
|
||||
}),
|
||||
@@ -208,10 +210,20 @@ void mountSessionMediaRoute(
|
||||
});
|
||||
return Response(
|
||||
res.status,
|
||||
body: Body.fromDataStream(stream, contentLength: byteCount),
|
||||
// Relic replaces Content-Type with the body's mime type, so set it here.
|
||||
body: Body.fromDataStream(stream,
|
||||
mimeType: _mimeOf(res.headers['content-type']), contentLength: byteCount),
|
||||
headers: Headers.build((h) {
|
||||
res.headers.forEach((k, v) => h[k] = [v]);
|
||||
}),
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
MimeType _mimeOf(String? contentType) {
|
||||
try {
|
||||
return MimeType.parse(contentType ?? '');
|
||||
} catch (_) {
|
||||
return MimeType.octetStream;
|
||||
}
|
||||
}
|
||||
@@ -16,11 +16,22 @@ class ServerHub {
|
||||
final String hostKey;
|
||||
final SyncEngine engine;
|
||||
final Map<String, RelicWebSocket> sockets = {};
|
||||
/// The server's Jellyfin login for the `/j/...` relay; null = no relay.
|
||||
JellyfinCredential? jellyfin;
|
||||
|
||||
ServerHub(this.sessions, this.expectedSecret,
|
||||
{SyncEngine? engine, this.hostKey = ''})
|
||||
{SyncEngine? engine, this.hostKey = '', this.jellyfin})
|
||||
: engine = engine ?? SyncEngine();
|
||||
|
||||
/// The current media is a Jellyfin ref on the server [jellyfin] logs in to.
|
||||
bool get relayAvailable {
|
||||
final ref = session.media?.jellyfin;
|
||||
final cred = jellyfin;
|
||||
return ref != null && cred != null && cred.serverId.isNotEmpty && cred.serverId == ref.serverId;
|
||||
}
|
||||
|
||||
StateBroadcast stateBroadcast() => engine.broadcast(relay: relayAvailable);
|
||||
|
||||
bool isHostKey(String key) => hostKey.isNotEmpty && key == hostKey;
|
||||
|
||||
/// The member a reconnecting client may take over with its old token:
|
||||
@@ -35,7 +46,7 @@ class ServerHub {
|
||||
Session get session => sessions.getOrCreate('default', expectedSecret);
|
||||
|
||||
void broadcastState() {
|
||||
final raw = jsonEncode(engine.broadcast().toJson());
|
||||
final raw = jsonEncode(stateBroadcast().toJson());
|
||||
for (final ws in sockets.values.toList()) {
|
||||
ws.trySendText(raw);
|
||||
}
|
||||
@@ -139,12 +150,34 @@ void wsErr(RelicWebSocket ws, String message) {
|
||||
ws.trySendText(jsonEncode({'t': 'err', 'message': message}));
|
||||
}
|
||||
|
||||
/// Jellyfin ids are path segments of upstream requests: keep them plain.
|
||||
final _jellyfinId = RegExp(r'^[A-Za-z0-9-]{1,64}$');
|
||||
|
||||
/// The swap's Jellyfin ref cleaned for broadcast (names like
|
||||
/// [sanitizeMediaName]), or null when its ids are unusable.
|
||||
JellyfinRef? sanitizeJellyfinRef(JellyfinRef ref) {
|
||||
if (!_jellyfinId.hasMatch(ref.serverId) || !_jellyfinId.hasMatch(ref.itemId)) return null;
|
||||
final source = ref.mediaSourceId;
|
||||
if (source != null && !_jellyfinId.hasMatch(source)) return null;
|
||||
final runtime = ref.runtimeSecs;
|
||||
return JellyfinRef(
|
||||
serverId: ref.serverId,
|
||||
itemId: ref.itemId,
|
||||
mediaSourceId: source,
|
||||
title: sanitizeMediaName(ref.title),
|
||||
subtitle: ref.subtitle == null ? null : sanitizeMediaName(ref.subtitle!),
|
||||
runtimeSecs: runtime == null || runtime < 0 ? null : runtime,
|
||||
);
|
||||
}
|
||||
|
||||
/// [relayUrlBuilder] builds a member's `/j/...` URL for `welcome.relayUrl`.
|
||||
void mountSyncRoute(
|
||||
RelicApp app,
|
||||
ServerHub hub,
|
||||
String Function(String sid, String token, SessionMedia? media, String host)
|
||||
urlBuilder,
|
||||
) {
|
||||
urlBuilder, {
|
||||
String Function(String sid, String token, String itemId, String host)? relayUrlBuilder,
|
||||
}) {
|
||||
app.get('/sync', (final req) {
|
||||
// Host the client dialled (Host header), so file URLs work for remote joiners.
|
||||
final dialledHost = req.url.hasPort
|
||||
@@ -196,6 +229,30 @@ void mountSyncRoute(
|
||||
case 'swap':
|
||||
if (token == null) return;
|
||||
final parsed = SwapIntent.fromJson(decoded).media;
|
||||
if (parsed.jellyfin != null) {
|
||||
// By reference only: no URL (it could carry a token) is
|
||||
// stored or broadcast for Jellyfin media.
|
||||
final ref = sanitizeJellyfinRef(parsed.jellyfin!);
|
||||
final media = ref == null
|
||||
? null
|
||||
: HelloMedia(
|
||||
name: sanitizeMediaName(parsed.name.isEmpty ? ref.title : parsed.name),
|
||||
size: parsed.size,
|
||||
duration: parsed.duration,
|
||||
jellyfin: ref);
|
||||
if (media == null || !hub.engine.applySwap(token, media)) {
|
||||
wsErr(ws, 'bad swap media');
|
||||
return;
|
||||
}
|
||||
session.media = SessionMedia(
|
||||
name: media.name,
|
||||
size: media.size,
|
||||
duration: media.duration,
|
||||
jellyfin: ref,
|
||||
mediaId: hub.engine.mediaId);
|
||||
hub.changed();
|
||||
return;
|
||||
}
|
||||
final intent = SwapIntent(
|
||||
media: HelloMedia(
|
||||
name: sanitizeMediaName(parsed.name),
|
||||
@@ -236,7 +293,7 @@ void mountSyncRoute(
|
||||
final report = ReportMessage.fromJson(decoded);
|
||||
hub.engine.applyReport(
|
||||
token, report.position, report.buffered, report.eventId,
|
||||
duration: report.duration, mediaId: report.mediaId);
|
||||
duration: report.duration, mediaId: report.mediaId, jf: report.jf);
|
||||
hub.changed();
|
||||
case 'ping':
|
||||
final ping = PingMessage.fromJson(decoded);
|
||||
@@ -355,11 +412,15 @@ void mountSyncRoute(
|
||||
hub.forgetSocketOf(activeToken, ws);
|
||||
};
|
||||
final media = session.media;
|
||||
final relay = hub.relayAvailable;
|
||||
final welcome = WelcomeMessage(
|
||||
token: activeToken,
|
||||
memberId: memberId,
|
||||
mediaUrl: media?.file != null ? urlBuilder('default', activeToken, media, dialledHost) : media?.url,
|
||||
state: hub.engine.broadcast().toJson(),
|
||||
relayUrl: relay && relayUrlBuilder != null
|
||||
? relayUrlBuilder('default', activeToken, media!.jellyfin!.itemId, dialledHost)
|
||||
: null,
|
||||
state: hub.stateBroadcast().toJson(),
|
||||
);
|
||||
try {
|
||||
ws.sendText(jsonEncode(welcome.toJson()));
|
||||
|
||||
@@ -30,6 +30,8 @@ class SessionMedia {
|
||||
final double duration;
|
||||
final String? url;
|
||||
final File? file;
|
||||
/// Jellyfin media by reference; never together with [url] or [file].
|
||||
final JellyfinRef? jellyfin;
|
||||
final int mediaId;
|
||||
const SessionMedia({
|
||||
required this.name,
|
||||
@@ -37,6 +39,7 @@ class SessionMedia {
|
||||
required this.duration,
|
||||
this.url,
|
||||
this.file,
|
||||
this.jellyfin,
|
||||
this.mediaId = 1,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ import 'package:logging/logging.dart' as pkg_logging;
|
||||
import 'package:relic/relic.dart';
|
||||
import 'package:sync_relay_core/core.dart';
|
||||
|
||||
import 'jellyfin_relay.dart';
|
||||
import 'media_endpoint.dart';
|
||||
import 'server_hub.dart';
|
||||
import 'session.dart';
|
||||
@@ -20,9 +21,11 @@ class SyncServer {
|
||||
DateTime? _startedAt;
|
||||
Timer? _heartbeat;
|
||||
bool _routesMounted = false;
|
||||
/// Upstream connections of the Jellyfin relay; created on first use.
|
||||
HttpClient? _relayClient;
|
||||
|
||||
SyncServer(this.config) {
|
||||
hub = ServerHub(sessions, config.secret, hostKey: config.hostKey);
|
||||
hub = ServerHub(sessions, config.secret, hostKey: config.hostKey, jellyfin: config.jellyfin);
|
||||
}
|
||||
|
||||
bool get isRunning => _server != null;
|
||||
@@ -59,14 +62,17 @@ class SyncServer {
|
||||
);
|
||||
});
|
||||
mountSessionMediaRoute(_app, sessions);
|
||||
mountJellyfinRelayRoute(
|
||||
_app, sessions, () => hub.jellyfin, () => _relayClient ??= HttpClient());
|
||||
// advertiseHost is only an explicit override; otherwise echo what the client dialled.
|
||||
String authority(String dialledHost) => config.advertiseHost.isNotEmpty
|
||||
? '${config.advertiseHost}:$boundPort'
|
||||
: dialledHost;
|
||||
mountSyncRoute(_app, hub, (sid, token, media, dialledHost) {
|
||||
final name = Uri.encodeComponent(media?.name ?? 'file');
|
||||
// advertiseHost is only an explicit override; otherwise echo what the client dialled.
|
||||
final authority = config.advertiseHost.isNotEmpty
|
||||
? '${config.advertiseHost}:$boundPort'
|
||||
: dialledHost;
|
||||
return 'http://$authority/m/$sid/$token/$name';
|
||||
});
|
||||
return 'http://${authority(dialledHost)}/m/$sid/$token/$name';
|
||||
}, relayUrlBuilder: (sid, token, itemId, dialledHost) =>
|
||||
'http://${authority(dialledHost)}/j/$sid/$token/${Uri.encodeComponent(itemId)}');
|
||||
_app.fallback = (final req) => Response.notFound(
|
||||
body: Body.fromString('not found'),
|
||||
);
|
||||
@@ -89,6 +95,7 @@ class SyncServer {
|
||||
if (config.secret.isEmpty) {
|
||||
_log.warning('no password set: open server, anyone who can reach it can join');
|
||||
}
|
||||
await _resolveJellyfinServerId();
|
||||
_mountRoutes();
|
||||
_server = await _app.serve(
|
||||
address: InternetAddress.anyIPv4,
|
||||
@@ -110,10 +117,32 @@ class SyncServer {
|
||||
_log.info('server "${config.name}" listening on $boundPort');
|
||||
}
|
||||
|
||||
/// A configured credential without a ServerId gets it from
|
||||
/// `/System/Info/Public` (unauthenticated). On failure the relay stays off.
|
||||
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();
|
||||
}
|
||||
}
|
||||
_log.info('jellyfin relay on for server ${hub.jellyfin!.serverId}');
|
||||
}
|
||||
|
||||
Future<void> stop() async {
|
||||
if (_server == null) return;
|
||||
_heartbeat?.cancel();
|
||||
_heartbeat = null;
|
||||
_relayClient?.close(force: true);
|
||||
_relayClient = null;
|
||||
await hub.closeAllSockets();
|
||||
await _app.close();
|
||||
_server = null;
|
||||
|
||||
Reference in new issue
Block a user