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:
foxyzandClaude Opus 5.5 committed 2026-10-07 01:57:04 +03:00
1 parent fb2927c094
commit 45f4c1bc84
18 files changed
+1363 -66

No files matched your search

+17
View File
@@ -16,6 +16,9 @@ Future<void> main(List<String> args) async {
..addOption('name', defaultsTo: 'server', help: 'Server name, reported by /health.')
..addOption('media', defaultsTo: '', help: 'Local file to serve as the starting media.')
..addOption('advertise-host', defaultsTo: '', help: 'Host put in file-media URLs instead of the one each client dialled.')
..addOption('jellyfin',
help: 'YAML or JSON file with a Jellyfin login (baseUrl, token, client, device, deviceId, version[, serverId]) '
'to relay Jellyfin media to viewers without an account. The device fields must be the ones the token was issued with.')
..addOption('config', help: 'YAML or JSON config file (keys as in AppConfig); overrides the other flags.')
..addFlag('help', abbr: 'h', negatable: false, help: 'Show this help.');
final ArgResults parsed;
@@ -51,6 +54,20 @@ Future<void> main(List<String> args) async {
advertiseHost: '${parsed['advertise-host']}',
);
}
final jellyfinPath = parsed['jellyfin'] as String?;
if (jellyfinPath != null) {
final c = config;
config = AppConfig(
port: c.port,
secret: c.secret,
name: c.name,
mediaPath: c.mediaPath,
logLevel: c.logLevel,
advertiseHost: c.advertiseHost,
hostKey: c.hostKey,
jellyfin: await JellyfinCredential.loadFile(jellyfinPath),
);
}
} on ConfigException catch (e) {
stderr.writeln(e.message);
exitCode = 64;
+1
View File
@@ -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';
+160
View File
@@ -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,
);
});
}
+14 -2
View File
@@ -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;
}
}
+67 -6
View File
@@ -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()));
+3
View File
@@ -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,
});
}
+36 -7
View File
@@ -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;
@@ -0,0 +1,91 @@
// Live relay test against a real Jellyfin server. Skipped unless the same
// variables as packages/core/test/jellyfin_live_test.dart are set:
//
// JELLYFIN_TEST_URL, JELLYFIN_TEST_TOKEN, JELLYFIN_TEST_CLIENT,
// JELLYFIN_TEST_DEVICE, JELLYFIN_TEST_DEVICE_ID, JELLYFIN_TEST_VERSION
//
// The device fields must be the token's own (Jellyfin overwrites a token's
// stored device fields with the header's). Read-only: it relays 1 KiB of one
// episode and compares it with the same range fetched directly.
library;
import 'dart:convert';
import 'dart:io';
import 'package:sync_relay_core/core.dart';
import 'package:sync_relay_server/server.dart';
import 'package:test/test.dart';
void main() {
final env = Platform.environment;
const keys = [
'JELLYFIN_TEST_URL',
'JELLYFIN_TEST_TOKEN',
'JELLYFIN_TEST_CLIENT',
'JELLYFIN_TEST_DEVICE',
'JELLYFIN_TEST_DEVICE_ID',
'JELLYFIN_TEST_VERSION',
];
final skip = keys.every(env.containsKey) ? null : 'JELLYFIN_TEST_* not set';
test('relays a 1 KiB range byte-exact', () async {
final cred = JellyfinCredential(
baseUrl: env['JELLYFIN_TEST_URL']!,
token: env['JELLYFIN_TEST_TOKEN']!,
client: env['JELLYFIN_TEST_CLIENT']!,
device: env['JELLYFIN_TEST_DEVICE']!,
deviceId: env['JELLYFIN_TEST_DEVICE_ID']!,
version: env['JELLYFIN_TEST_VERSION']!,
);
final jf = JellyfinClient(baseUrl: cred.baseUrl, clientInfo: cred.clientInfo, accessToken: cred.token);
await jf.validateToken();
final episode = (await jf.items(const JellyfinItemQuery(
includeItemTypes: [JellyfinItemType.episode], recursive: true, limit: 1)))
.items
.single;
final server = SyncServer(AppConfig(port: 0, secret: '', name: 'live', jellyfin: cred));
await server.start();
final http = HttpClient();
final ws = await WebSocket.connect('ws://127.0.0.1:${server.boundPort}/sync');
try {
expect(server.hub.jellyfin!.serverId, isNotEmpty, reason: 'server id looked up');
final frames = ws.map((m) => jsonDecode('$m') as Map).asBroadcastStream();
ws.add(jsonEncode(HelloMessage(
proto: RelayConstants.protoVersion,
secret: '',
user: const HelloUser(name: 'live'),
media: const HelloMedia(name: '', size: 0, duration: 0))
.toJson()));
final welcome = await frames.firstWhere((f) => f['t'] == 'welcome').timeout(const Duration(seconds: 5));
ws.add(jsonEncode(SwapIntent(
media: HelloMedia(
name: episode.name,
size: 0,
duration: 0,
jellyfin: JellyfinRef(
serverId: server.hub.jellyfin!.serverId, itemId: episode.id, title: episode.name))).toJson()));
await frames.firstWhere((f) => f['t'] == 'state' && f['relay'] == true).timeout(const Duration(seconds: 5));
final req = await http.get('127.0.0.1', server.boundPort, '/j/default/${welcome['token']}/${episode.id}');
req.headers.set('range', 'bytes=0-1023');
final res = await req.close();
expect(res.statusCode, 206);
final relayed = [for (final c in await res.toList()) ...c];
expect(relayed, hasLength(1024));
final direct = await http.getUrl(jf.shareableStreamUrl(episode.id));
jf.authHeaders.forEach(direct.headers.set);
direct.headers.set('range', 'bytes=0-1023');
final directRes = await direct.close();
expect(res.headers.value('content-range'), directRes.headers.value('content-range'));
expect(relayed, [for (final c in await directRes.toList()) ...c]);
expect(server.session.totalBytesServed, 1024);
} finally {
await ws.close();
http.close(force: true);
jf.close();
await server.stop();
}
}, skip: skip, timeout: const Timeout(Duration(seconds: 60)));
}
@@ -0,0 +1,408 @@
import 'dart:async';
import 'dart:convert';
import 'dart:io';
import 'dart:typed_data';
import 'package:sync_relay_core/core.dart';
import 'package:sync_relay_server/server.dart';
import 'package:test/test.dart';
const _serverId = 'fakeserver01';
const _item = '0123456789abcdef0123456789abcdef';
const _source = 'fedcba9876543210fedcba9876543210';
const _slowItem = 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa';
const _token = 'JF-SECRET-TOKEN';
const _cred = JellyfinCredential(
baseUrl: 'http://127.0.0.1:1', // port filled in per test
serverId: _serverId,
token: _token,
client: 'Watch Together',
device: 'host pc "1"',
deviceId: 'dev-42',
version: '0.9.0',
);
/// Jellyfin stand-in: `/Videos/{id}/stream` serves [bytes] with ranges (the
/// same table as `/m`), `_slowItem` streams forever until the reader goes
/// away, `/System/Info/Public` gives the server id.
class _FakeJellyfin {
final HttpServer _server;
final Uint8List bytes;
final requests = <HttpRequest>[];
final auth = <String?>[];
final uris = <Uri>[];
final ranges = <String?>[];
final slowAborted = Completer<void>();
int status401 = 0;
_FakeJellyfin._(this._server, this.bytes);
int get port => _server.port;
static Future<_FakeJellyfin> start(Uint8List bytes) async {
final s = await HttpServer.bind(InternetAddress.loopbackIPv4, 0);
final f = _FakeJellyfin._(s, bytes);
s.listen(f._handle);
return f;
}
Future<void> _handle(HttpRequest req) async {
final res = req.response;
if (req.uri.path == '/System/Info/Public') {
res.headers.contentType = ContentType.json;
res.write(jsonEncode({'Id': _serverId, 'ServerName': 'fake', 'Version': '10.11.0'}));
await res.close();
return;
}
auth.add(req.headers.value(HttpHeaders.authorizationHeader));
uris.add(req.uri);
ranges.add(req.headers.value(HttpHeaders.rangeHeader));
if (status401 > 0) {
status401--;
res.statusCode = 401;
await res.close();
return;
}
final m = RegExp(r'^/Videos/([^/]+)/stream$').firstMatch(req.uri.path);
if (m == null) {
res.statusCode = 404;
await res.close();
return;
}
if (m.group(1) == _slowItem) {
// Raw socket: dart:io's HttpResponse.flush does not notice a peer that
// went away, the socket's input closing does.
final socket = await res.detachSocket(writeHeaders: false);
socket.listen((_) {}, onError: (_) {}, onDone: () {
if (!slowAborted.isCompleted) slowAborted.complete();
});
socket.write('HTTP/1.1 206 Partial Content\r\nContent-Type: video/x-matroska\r\n'
'Content-Range: bytes 0-999999999/1000000000\r\nContent-Length: 1000000000\r\n\r\n');
final chunk = Uint8List(16 * 1024);
try {
while (!slowAborted.isCompleted) {
socket.add(chunk);
await socket.flush();
await Future<void>.delayed(const Duration(milliseconds: 5));
}
} catch (_) {}
socket.destroy();
return;
}
final r = resolveMediaRequest(
rangeHeader: req.headers.value(HttpHeaders.rangeHeader), size: bytes.length, filename: 'x.mkv');
res.statusCode = r.status;
res.headers.clear();
r.headers.forEach(res.headers.set);
if (r.status == 200 || r.status == 206) {
final end = r.end ?? bytes.length - 1;
res.contentLength = end - r.start + 1;
res.add(bytes.sublist(r.start, end + 1));
}
await res.close();
}
Future<void> close() => _server.close(force: true);
}
class _Client {
final WebSocket ws;
final raw = <String>[];
final _welcome = Completer<String>();
_Client(this.ws) {
ws.listen((m) {
raw.add('$m');
if ('$m'.contains('"t":"welcome"') && !_welcome.isCompleted) _welcome.complete('$m');
}, onError: (_) {}, onDone: () {});
}
void send(Map<String, Object?> j) => ws.add(jsonEncode(j));
Map<String, Object?>? get lastState {
for (final f in raw.reversed) {
final d = jsonDecode(f) as Map;
if (d['t'] == 'state') return Map<String, Object?>.from(d);
}
return null;
}
static Future<(_Client, String)> join(int port, String name) async {
final ws = await WebSocket.connect('ws://127.0.0.1:$port/sync');
final c = _Client(ws);
c.send(HelloMessage(
proto: RelayConstants.protoVersion,
secret: '',
user: HelloUser(name: name),
media: const HelloMedia(name: '', size: 0, duration: 0),
).toJson());
return (c, await c._welcome.future.timeout(const Duration(seconds: 5)));
}
}
Future<void> _until(bool Function() cond, String what) async {
final deadline = DateTime.now().add(const Duration(seconds: 5));
while (!cond()) {
if (DateTime.now().isAfter(deadline)) fail('timed out waiting for $what');
await Future<void>.delayed(const Duration(milliseconds: 20));
}
}
void main() {
late Uint8List bytes;
late _FakeJellyfin jf;
late SyncServer server;
late HttpClient http;
final clients = <_Client>[];
JellyfinCredential cred({String serverId = _serverId}) => JellyfinCredential(
baseUrl: 'http://127.0.0.1:${jf.port}/',
serverId: serverId,
token: _cred.token,
client: _cred.client,
device: _cred.device,
deviceId: _cred.deviceId,
version: _cred.version);
Future<void> startServer(JellyfinCredential? c) async {
server = SyncServer(AppConfig(port: 0, secret: '', name: 't', jellyfin: c));
await server.start();
}
setUp(() async {
bytes = Uint8List.fromList(List<int>.generate(100000, (i) => (i * 7) % 256));
jf = await _FakeJellyfin.start(bytes);
http = HttpClient();
});
tearDown(() async {
for (final c in clients) {
await c.ws.close();
}
clients.clear();
http.close(force: true);
await server.stop();
await jf.close();
});
Future<(_Client, WelcomeMessage)> join(String name) async {
final (c, raw) = await _Client.join(server.boundPort, name);
clients.add(c);
return (c, WelcomeMessage.fromJson(Map<String, Object?>.from(jsonDecode(raw) as Map)));
}
Map<String, Object?> refSwap(String itemId, {String? url, String serverId = _serverId}) => {
't': 'swap',
'media': {
'name': 'Pilot',
'size': 0,
'duration': 0,
if (url != null) 'url': url,
'jellyfin': {
'serverId': serverId,
'itemId': itemId,
'mediaSourceId': _source,
'title': 'Pilot',
'subtitle': 'Show - S01E01',
'runtimeSecs': 1440.0,
},
},
};
Future<String> swapToRef(_Client c, String itemId, {String? url}) async {
final before = c.raw.length;
c.send(refSwap(itemId, url: url));
late String state;
await _until(() {
for (final f in c.raw.skip(before)) {
if (f.contains('"t":"state"') && f.contains(itemId)) {
state = f;
return true;
}
}
return false;
}, 'state with the ref');
return state;
}
Future<HttpClientResponse> relayGet(String token, String itemId, {String? range}) async {
final req = await http.get('127.0.0.1', server.boundPort, '/j/default/$token/$itemId');
if (range != null) req.headers.set('range', range);
return req.close();
}
Future<List<int>> readAll(HttpClientResponse r) async => [for (final c in await r.toList()) ...c];
group('wire', () {
test('ref swap: state carries the ref and relay, never a URL or token', () async {
await startServer(cred());
final (a, _) = await join('alice');
final state = await swapToRef(a, _item, url: 'http://jf/Videos/x/stream?api_key=LEAK');
final decoded = jsonDecode(state) as Map;
expect(decoded['relay'], true);
final media = decoded['media'] as Map;
expect(media.containsKey('url'), isFalse);
expect(media['jellyfin'], {
'serverId': _serverId,
'itemId': _item,
'mediaSourceId': _source,
'title': 'Pilot',
'subtitle': 'Show - S01E01',
'runtimeSecs': 1440.0,
});
expect(state, isNot(contains('LEAK')));
expect(state, isNot(contains(_token)));
expect(state, isNot(contains('http')));
expect(server.session.media!.url, isNull);
// A later joiner gets its own relay URL in welcome, no mediaUrl.
final (_, w) = await join('bob');
expect(w.mediaUrl, isNull);
expect(w.relayUrl, 'http://127.0.0.1:${server.boundPort}/j/default/${w.token}/$_item');
expect(w.state!['relay'], true);
expect(jsonEncode(w.state), isNot(contains(_token)));
});
test('no relay without a credential, or for another Jellyfin server', () async {
await startServer(cred(serverId: 'otherserver'));
final (a, _) = await join('alice');
final state = jsonDecode(await swapToRef(a, _item)) as Map;
expect(state.containsKey('relay'), isFalse);
final (_, w) = await join('bob');
expect(w.relayUrl, isNull);
expect((await relayGet(w.token, _item)).statusCode, 404);
});
test('bad ref ids are rejected', () async {
await startServer(cred());
final (a, _) = await join('alice');
a.send(refSwap('../../Users'));
await _until(() => a.raw.any((f) => f.contains('bad swap media')), 'err');
expect(server.session.media, isNull);
});
test('report jf shows on the member', () async {
await startServer(cred());
final (a, w) = await join('alice');
a.send(const ReportMessage(position: 0, buffered: 0, eventId: 0, jf: {_serverId: false}).toJson());
await _until(() {
final members = a.lastState?['members'] as List?;
return members != null && members.any((m) => (m as Map)['jf'] != null);
}, 'member jf');
final m = (a.lastState!['members'] as List).cast<Map>().firstWhere((m) => m['id'] == w.memberId);
expect(m['jf'], {_serverId: false});
});
});
group('relay endpoint', () {
late String token;
Future<void> setUpRef(String itemId) async {
await startServer(cred());
final (a, w) = await join('alice');
token = w.token;
await swapToRef(a, itemId);
}
test('sends exactly the configured Authorization fields, static original stream', () async {
await setUpRef(_item);
final r = await relayGet(token, _item, range: 'bytes=0-9');
await r.drain<void>();
expect(jf.auth.single,
'MediaBrowser Client="Watch%20Together", Device="host%20pc%20%221%22", DeviceId="dev-42", Version="0.9.0", Token="JF-SECRET-TOKEN"');
expect(jf.uris.single.path, '/Videos/$_item/stream');
expect(jf.uris.single.queryParameters, {'static': 'true', 'mediaSourceId': _source});
});
test('no Range: 200 full body, headers passed through', () async {
await setUpRef(_item);
final r = await relayGet(token, _item);
expect(r.statusCode, 200);
expect(r.headers.value('content-length'), '${bytes.length}');
expect(r.headers.value('accept-ranges'), 'bytes');
expect(r.headers.contentType?.mimeType, 'video/x-matroska');
expect(await readAll(r), bytes);
expect(jf.ranges.single, isNull);
});
test('closed, open-ended and suffix ranges forward and come back byte-exact', () async {
await setUpRef(_item);
var r = await relayGet(token, _item, range: 'bytes=100-199');
expect(r.statusCode, 206);
expect(r.headers.value('content-range'), 'bytes 100-199/${bytes.length}');
expect(r.headers.value('content-length'), '100');
expect(await readAll(r), bytes.sublist(100, 200));
r = await relayGet(token, _item, range: 'bytes=99000-');
expect(r.statusCode, 206);
expect(r.headers.value('content-range'), 'bytes 99000-99999/${bytes.length}');
expect(await readAll(r), bytes.sublist(99000));
r = await relayGet(token, _item, range: 'bytes=-256');
expect(r.statusCode, 206);
expect(r.headers.value('content-range'), 'bytes 99744-99999/${bytes.length}');
expect(await readAll(r), bytes.sublist(bytes.length - 256));
expect(jf.ranges, ['bytes=100-199', 'bytes=99000-', 'bytes=-256']);
});
test('unsatisfiable range: 416 with Content-Range passed through', () async {
await setUpRef(_item);
final r = await relayGet(token, _item, range: 'bytes=500000-');
expect(r.statusCode, 416);
expect(r.headers.value('content-range'), 'bytes */${bytes.length}');
await r.drain<void>();
});
test('bytes are counted as sent', () async {
await setUpRef(_item);
await readAll(await relayGet(token, _item, range: 'bytes=0-999'));
await readAll(await relayGet(token, _item, range: 'bytes=-24'));
expect(server.session.tokens[token]!.bytesServed, 1024);
});
test('gates: bad token 401, other item 403, unknown session 410', () async {
await setUpRef(_item);
expect((await relayGet('nope', _item)).statusCode, 401);
expect((await relayGet(token, _slowItem)).statusCode, 403);
final req = await http.get('127.0.0.1', server.boundPort, '/j/other/$token/$_item');
expect((await req.close()).statusCode, 410);
expect(jf.uris, isEmpty, reason: 'nothing reached Jellyfin');
});
test('non-Jellyfin media: 404', () async {
await startServer(cred());
final (a, w) = await join('alice');
a.send(const SwapIntent(media: HelloMedia(name: 'x', size: 0, duration: 0, url: 'http://x/a.mkv')).toJson());
await _until(() => server.session.media?.url != null, 'url swap');
expect((await relayGet(w.token, _item)).statusCode, 404);
});
test('Jellyfin rejecting the credential is a 502, not the viewer\'s 401', () async {
await setUpRef(_item);
jf.status401 = 1;
expect((await relayGet(token, _item)).statusCode, 502);
});
test('viewer disconnect aborts the upstream transfer', () async {
await setUpRef(_slowItem);
final socket = await Socket.connect(InternetAddress.loopbackIPv4, server.boundPort);
socket.write('GET /j/default/$token/$_slowItem HTTP/1.1\r\nHost: 127.0.0.1\r\nRange: bytes=0-\r\n\r\n');
var got = 0;
final enough = Completer<void>();
socket.listen((d) {
got += d.length;
if (got > 64 * 1024 && !enough.isCompleted) enough.complete();
}, onError: (_) {}, onDone: () {});
await enough.future.timeout(const Duration(seconds: 5));
socket.destroy();
await jf.slowAborted.future.timeout(const Duration(seconds: 5));
final served = server.session.tokens[token]!.bytesServed;
expect(served, greaterThan(0));
expect(served, lessThan(1000000000));
});
});
test('a credential without serverId looks it up at start', () async {
await startServer(cred(serverId: ''));
expect(server.hub.jellyfin!.serverId, _serverId);
});
}
+3 -1
View File
@@ -18,7 +18,7 @@ void main() {
setUp(() async {
bytes = Uint8List.fromList(List<int>.generate(8192, (i) => i % 251));
file = File(
'${Directory.systemTemp.path}/server_test_${DateTime.now().microsecondsSinceEpoch}.bin');
'${Directory.systemTemp.path}/server_test_${DateTime.now().microsecondsSinceEpoch}.mkv');
await file.writeAsBytes(bytes);
server = SyncServer(AppConfig(
port: 0, secret: 'shh', name: 'test', mediaPath: file.path));
@@ -117,6 +117,8 @@ void main() {
resp = await mediaGet('/m/default/$token/$name', range: 'bytes=0-99');
expect(resp.statusCode, 206);
expect(resp.headers.value('content-range'), 'bytes 0-99/8192');
expect(resp.headers.contentType?.mimeType, contentTypeForFile(name));
expect(contentTypeForFile(name), isNot('application/octet-stream'));
final body = <int>[];
await for (final c in resp) {
body.addAll(c);
+2 -2
View File
@@ -80,8 +80,8 @@ void main() {
});
group('core welcome + proto', () {
test('proto 6: state broadcast + hello hostKey', () {
expect(RelayConstants.protoVersion, 6);
test('proto 7: Jellyfin refs and relay', () {
expect(RelayConstants.protoVersion, 7);
});
test('welcome round-trips with token + member id', () {