Files
sync_replay_project/packages/server/lib/src/jellyfin_relay.dart
T
foxyzandClaude Opus 5.5 8b81da4069 Protocol 11: sit out replaces kicking; nudge
Anyone can sit anyone out (host included); only you bring yourself back.
Out members stay in chat and the party, never block or count, and load no
media; a card with the live position and Join back (Space) replaces the
video. The mini player's x sits you out; optionally idle does too. Nudge:
the server forwards it to the target only (3/s, 20/min per sender); the
target hears the nudge sound, gets a toast and a taskbar attention
request. Kick, its messages, errors and dialog are gone.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 21:05:35 +03:00

159 lines
5.8 KiB
Dart

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;
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,
);
});
}