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>
159 lines
5.8 KiB
Dart
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,
|
|
);
|
|
});
|
|
}
|