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
+1357 -60

No files matched your search

+39 -4
View File
@@ -8,7 +8,7 @@ Plain overview first: [HOW-IT-WORKS.md](HOW-IT-WORKS.md). Wire format:
| Path | What | Rule |
|---|---|---|
| `packages/core` | protocol messages, sync engine (`SyncEngine`), constants, config, Range parsing, logging, Jellyfin client | pure Dart, no Flutter |
| `packages/server` | Relic HTTP + WebSocket server (`SyncServer`, `ServerHub`, `Session`, media endpoint), the `serve()` entrypoint, `bin/server.dart` (dedicated server) | pure Dart, no Flutter |
| `packages/server` | Relic HTTP + WebSocket server (`SyncServer`, `ServerHub`, `Session`, media endpoint, Jellyfin relay), the `serve()` entrypoint, `bin/server.dart` (dedicated server) | pure Dart, no Flutter |
| `apps/desktop` | Flutter app: UI, `SyncClient`, `SessionCoordinator`, `PlaybackController` (media_kit/mpv), `ServerIsolate`, updater | ships with `watch-together-server` |
| `tool` | harnesses: `range_logger`, `shaper`, `fake_viewer`, `e2e_check` | dev only |
| `deploy` | systemd unit for the dedicated server | |
@@ -40,7 +40,7 @@ all sockets and returns, and the isolate exits.
- **Dedicated server**: `bin/server.dart` compiled with `dart compile exe`
and shipped next to the app as `watch-together-server`. It parses flags
(`--port`, `--secret`, `--name`, `--media`, `--advertise-host`,
`--config`; default port 8080) into the same map and calls `serve`;
`--jellyfin <file>`, `--config`; default port 8080) into the same map and calls `serve`;
SIGINT and SIGTERM stop it. A Flutter binary cannot run without a
display, which is why the server is its own build.
@@ -137,7 +137,7 @@ Additive wire changes only; bump `proto` and PROTOCOL.md in the same commit.
## Media
Two sources, same sync.
Three sources, same sync.
- **Link**: the media field holds an http(s) URL. It travels as `media.url`
in `swap` and `state`, and as `mediaUrl` in `welcome`. Each app plays it
@@ -153,6 +153,12 @@ Two sources, same sync.
(`bytesServed`); nothing is stored. The host plays through loopback like
everyone else.
- **Jellyfin item**: the media is a ref (`{serverId, itemId, ...}`, wire in
[PROTOCOL.md](PROTOCOL.md) "Jellyfin media"); the server never stores or
sends a URL or token for it. Each app resolves it with its own account and
direct-plays the static stream; guests use the relay (see "Jellyfin
relay").
Range table (`resolveMediaRequest`, tested byte-exact):
| Request | Response |
@@ -278,12 +284,41 @@ Gotchas (Jellyfin 10.11):
failure).
- Jellyfin overwrites a token's stored device name and app version with the
header's. Only use tokens this app obtained itself; a relay using a
viewer's token must send that viewer's client fields.
viewer's token must send that viewer's client fields (the server relay
sends exactly its credential's fields).
- Static streams (`/Videos/{id}/stream?static=true`) serve the original file
with byte ranges. The share URL has no credentials; each side adds its own
`api_key` (`authorizeUrl`).
- Ticks: 10,000,000 per second.
## Jellyfin relay
For viewers without an account on the item's Jellyfin
(`packages/server/lib/src/jellyfin_relay.dart`). The server may hold one
`JellyfinCredential` (config key `jellyfin`: `baseUrl`, `serverId`, `token`,
`client`, `device`, `deviceId`, `version`; primitives, so it crosses the
isolate boundary in the `serve()` map). The hosting app fills it with its
own app-obtained token and that token's device fields; the dedicated server
reads it from `--jellyfin <file>` (YAML/JSON with those keys, keeps the
token off the command line) or a `jellyfin:` map in `--config`. An empty
`serverId` is looked up at start (`/System/Info/Public`); if that fails the
relay stays off. The token is never logged (`toString` and config parse
errors leave it out).
- Available (`state.relay`) only while the current media is a ref whose
`serverId` equals the credential's. Only that item is relayed, only to a
valid, unkicked viewer token: no open proxy.
- Each request opens `/Videos/{id}/stream?static=true&mediaSourceId=` with
`Authorization: MediaBrowser` built from exactly the configured fields
(Jellyfin rewrites a token's device fields from the header), forwards
`Range` untouched and `Accept-Encoding: identity`, and returns Jellyfin's
status and range headers. Bytes stream through (no buffering) and count
toward `bytesServed` as sent. A viewer disconnect cancels the upstream
response, which closes that connection (tested: `HttpClientRequest.abort`
does nothing once the response has arrived; cancelling the response
subscription does).
- No progress reporting in the server: watched state is per client.
## Desktop UI
One `HomeScreen` (`screens/home/`) with a single top bar (`top_bar.dart`):
+54 -9
View File
@@ -1,4 +1,4 @@
# Protocol (proto = 6)
# Protocol (proto = 7)
Wire format only. Behavior (gate, clock, timeouts) is in
[ARCHITECTURE.md](ARCHITECTURE.md). Source of truth:
@@ -21,19 +21,22 @@ with different `proto` refuse each other at `hello` (`proto mismatch`).
- `GET /health` returns JSON (`ok`, `name`, `proto`, `viewers`, `bytesServed`,
`uptimeSecs`; unauthenticated). `GET /m/<sid>/<token>/<name>`
serves the host's file (Range table in ARCHITECTURE.md).
`GET /j/<sid>/<token>/<itemId>` relays the current Jellyfin media (see
"Jellyfin media" below).
## Client to server
```
{"t":"hello","proto":6,"secret":"","user":{"name":"","avatar":""},
{"t":"hello","proto":7,"secret":"","user":{"name":"","avatar":""},
"media":{"name":"","size":0,"duration":0,"url":null},"hostKey":"...",
"resume":"<token>"}
{"t":"ready","ready":true,"source":"manual"} source: manual | auto
{"t":"force","target":"all"|"<memberId>","ready":true}
{"t":"seek","position":123.4,"seq":3}
{"t":"swap","media":{"name":"","size":0,"duration":0,"url":"..."}}
{"t":"swap","media":{"name":"","size":0,"duration":0,"jellyfin":{<ref>}}}
{"t":"report","position":123.1,"buffered":6.2,"eventId":41,
"duration":1440.5,"mediaId":2}
"duration":1440.5,"mediaId":2,"jf":{"<serverId>":true}}
{"t":"ping","c":1767225600000}
{"t":"chat","text":"..."}
{"t":"kick","target":"<memberId>","reason":""}
@@ -58,23 +61,30 @@ Field notes:
- `report.duration` / `report.mediaId` (optional, omitted when 0): the open
media's length in seconds and the `mediaId` it belongs to. The server takes
the first one whose `mediaId` is current while its media duration is 0.
- `report.jf` (optional): Jellyfin access per `ServerId`, `true` = this
client plays the item with its own account, `false` = through the relay.
Sticky: a report without `jf` keeps the last one. At most 8 entries, keys
up to 64 characters; others are dropped.
- `ping.c`: the client's clock in epoch ms; answered with `pong`.
- `user.avatar`: base64 image, at most 256 KiB of characters. `name` is
trimmed, empty becomes `?`, cut to 32 characters.
- `chat.text` is cut to 500 characters.
- `swap.media.url` is the link; `name`, `size`, `duration` are informational.
`swap.media.jellyfin` makes it Jellyfin media (see below); then `url` is
dropped.
- Frames before `welcome` (other than `hello`) are ignored; unparsable or
unknown frames are ignored silently.
## Server to client
```
{"t":"welcome","token":"...","memberId":"m1","mediaUrl":"...","state":{<state>}}
{"t":"welcome","token":"...","memberId":"m1","mediaUrl":"...","relayUrl":"...",
"state":{<state>}}
{"t":"state","eventId":7,"transport":"playing"|"held"|"finished","position":12.3,
"serverTime":"<ISO-8601 UTC>","mediaId":1,
"media":{name,size,duration,url?}|absent,
"members":[{"id","name","avatar","ready","inSet","buffered","stale"}],
"seekBy":"m1","seekSeq":3}
"media":{name,size,duration,url?,jellyfin?}|absent,
"members":[{"id","name","avatar","ready","inSet","buffered","stale","jf"?}],
"seekBy":"m1","seekSeq":3,"relay":true}
{"t":"pong","c":1767225600000,"s":1767225600012}
{"t":"feed","actor":"","action":"","target":"","reason":"","at":"<ISO-8601>"}
{"t":"chat","text":"...","from":"<name>"}
@@ -86,7 +96,9 @@ Field notes:
media URL. `mediaUrl` is omitted when there is no media; it is a built
`/m/...` URL for a host file (host part = what the client dialled), otherwise
the shared link.
`welcome.state` has the same shape as `state`.
`welcome.state` has the same shape as `state`. `relayUrl` is present only
when `state.relay` is: this member's `/j/...` URL for the current
Jellyfin media (private like `token`).
- `state` is sent every second and on every change. `position` is the value at
`serverTime` (server clock); clients extrapolate while `transport` is
`playing`, on the server's clock (see `pong`). `finished`: the media
@@ -94,7 +106,9 @@ Field notes:
`eventId` increases on seek, swap and transport changes.
`mediaId` increases on swap. `media.duration` is 0 until a client reports
it. `seekBy` (member id) and `seekSeq` are present only while the current
`eventId` came from a seek.
`eventId` came from a seek. `relay` (omitted when false): the server can
relay the current Jellyfin media. `members[].jf` is the member's last
`report.jf`, omitted when it never sent one.
- `pong.c` echoes the ping's `c`; `pong.s` is the server's clock in epoch ms
(the clock `serverTime` uses). With `t3` the local receive time, the offset
is `s - (c + t3) / 2`, accurate to half the round trip.
@@ -107,6 +121,37 @@ Field notes:
`feed` with `action:"chat"`. Render the `chat` frame only; the reference
client drops the textless feed entry.
## Jellyfin media
```
<ref> = {"serverId":"<Jellyfin ServerId>","itemId":"<32 hex>",
"mediaSourceId":"...","title":"Pilot","subtitle":"Show - S01E01",
"runtimeSecs":1440.5}
```
- `serverId`, `itemId` required; `mediaSourceId`, `subtitle`, `runtimeSecs`
optional (omitted when absent); `title` always sent (may be empty).
`runtimeSecs` is informational: the sync clock still uses the duration a
client reports.
- The server keeps only the ref: for Jellyfin media no URL or token is stored
or broadcast (a `url` sent along is dropped). `serverId`, `itemId` and
`mediaSourceId` must match `[A-Za-z0-9-]{1,64}`, otherwise `bad swap media`.
`media.name` is the swap's name, else the ref's `title`; names are
sanitized like link names.
- Each client resolves the ref with its own account on that server. A client
without one plays the relay URL when `state.relay` is true: `welcome.relayUrl`,
or after a swap built like the `/m` one from the host it dialled:
`http://<host>/j/default/<token>/<itemId>`.
- `GET /j/<sid>/<token>/<itemId>`: the server fetches
`/Videos/<itemId>/stream?static=true&mediaSourceId=<id>` (the original
file) with its own credential and streams it through. `Range` is forwarded
as sent; status, `Content-Range`, `Content-Length`, `Content-Type` and
`Accept-Ranges` come back as Jellyfin sent them. Errors: 401 bad token,
403 kicked or not the current item, 404 no relay for the current media,
410 unknown session, 502 Jellyfin unreachable or rejecting the server's
credential. Bytes count toward `bytesServed`.
- Pasted Jellyfin links sent as plain `url` stay plain links.
## Join and reconnect
`hello` then `welcome`, then the app opens `mediaUrl`, seeks to the server
+115 -21
View File
@@ -5,6 +5,91 @@ import 'package:path/path.dart' as p;
import 'package:yaml/yaml.dart';
import 'errors.dart';
import 'jellyfin/client_info.dart';
/// A Jellyfin login the server uses to relay the current Jellyfin media to
/// viewers without their own access (`GET /j/...`). Primitives only (it is
/// part of [AppConfig.toMap]). [client], [device], [deviceId] and [version]
/// must be the ones [token] was issued with: Jellyfin overwrites a token's
/// stored device fields with whatever the request header says.
class JellyfinCredential {
/// Server base URL, no trailing slash needed.
final String baseUrl;
/// The server's `ServerId`; only refs with this id are relayed. Empty:
/// the server looks it up at start (`/System/Info/Public`).
final String serverId;
final String token;
final String client;
final String device;
final String deviceId;
final String version;
const JellyfinCredential({
required this.baseUrl,
this.serverId = '',
required this.token,
required this.client,
required this.device,
required this.deviceId,
required this.version,
});
JellyfinClientInfo get clientInfo =>
JellyfinClientInfo(client: client, device: device, deviceId: deviceId, version: version);
JellyfinCredential withServerId(String id) => JellyfinCredential(
baseUrl: baseUrl,
serverId: id,
token: token,
client: client,
device: device,
deviceId: deviceId,
version: version);
/// Null when [value] is absent; throws [ConfigException] (without the
/// values: the map holds a token) when it is not a usable credential.
static JellyfinCredential? fromMap(Object? value) {
if (value == null) return null;
if (value is! Map) throw const ConfigException('jellyfin must be a map');
String field(String key, {bool required = true}) {
final v = value[key];
if (v == null && !required) return '';
if (v is! String || (required && v.trim().isEmpty)) {
throw ConfigException('jellyfin.$key must be a non-empty string');
}
return v.trim();
}
return JellyfinCredential(
baseUrl: field('baseUrl'),
serverId: field('serverId', required: false),
token: field('token'),
client: field('client'),
device: field('device'),
deviceId: field('deviceId'),
version: field('version'),
);
}
Map<String, Object?> toMap() => {
'baseUrl': baseUrl,
'serverId': serverId,
'token': token,
'client': client,
'device': device,
'deviceId': deviceId,
'version': version,
};
/// A file (YAML, or JSON by `.json` extension) holding just these keys:
/// the dedicated server's `--jellyfin` flag, keeping the token off the
/// command line.
static Future<JellyfinCredential> loadFile(String path) async =>
JellyfinCredential.fromMap(await readConfigMapFile(path))!;
/// Never prints the token.
@override
String toString() => 'JellyfinCredential($baseUrl, serverId: $serverId)';
}
class AppConfig {
final int port;
@@ -18,6 +103,8 @@ class AppConfig {
/// Secret the hosting app's own client sends in hello to be marked host
/// (unkickable). Empty means no member is the host (dedicated server).
final String hostKey;
/// Optional login for relaying Jellyfin media to guests; null = no relay.
final JellyfinCredential? jellyfin;
const AppConfig({
required this.port,
@@ -27,6 +114,7 @@ class AppConfig {
this.logLevel = 'INFO',
this.advertiseHost = '',
this.hostKey = '',
this.jellyfin,
});
factory AppConfig.defaults() =>
@@ -42,7 +130,10 @@ class AppConfig {
logLevel: (map['logLevel'] as String?) ?? 'INFO',
advertiseHost: (map['advertiseHost'] as String?) ?? '',
hostKey: (map['hostKey'] as String?) ?? '',
jellyfin: JellyfinCredential.fromMap(map['jellyfin']),
);
} on ConfigException {
rethrow;
} catch (e) {
throw ConfigException('invalid config map: $e');
}
@@ -56,29 +147,11 @@ class AppConfig {
'logLevel': logLevel,
'advertiseHost': advertiseHost,
'hostKey': hostKey,
if (jellyfin != null) 'jellyfin': jellyfin!.toMap(),
};
static Future<AppConfig> loadFile(String path) async {
final file = File(path);
if (!await file.exists()) {
throw ConfigException('config file not found: $path');
}
final text = await file.readAsString();
try {
if (path.endsWith('.json')) {
final decoded = jsonDecode(text);
if (decoded is! Map) throw const ConfigException('root must be a map');
return AppConfig.fromMap(Map<String, Object?>.from(decoded));
}
final yamlMap = loadYaml(text);
if (yamlMap is! YamlMap) throw const ConfigException('root must be a map');
return AppConfig.fromMap(
Map<String, Object?>.from(yamlMap.map((k, v) => MapEntry('$k', v))));
} catch (e) {
if (e is ConfigException) rethrow;
throw ConfigException('failed to parse $path: $e');
}
}
static Future<AppConfig> loadFile(String path) async =>
AppConfig.fromMap(await readConfigMapFile(path));
static String defaultPath(String appName) {
final home =
@@ -86,3 +159,24 @@ class AppConfig {
return p.join(home, '.config', appName, 'config.yaml');
}
}
/// Reads a YAML or JSON (by `.json` extension) file whose root is a map.
/// Parse errors are reported without the source excerpt: the file may hold
/// a token.
Future<Map<String, Object?>> readConfigMapFile(String path) async {
final file = File(path);
if (!await file.exists()) {
throw ConfigException('config file not found: $path');
}
final text = await file.readAsString();
Object? root;
try {
root = path.endsWith('.json') ? jsonDecode(text) : loadYaml(text);
} on YamlException catch (e) {
throw ConfigException('failed to parse $path: ${e.message} (line ${(e.span?.start.line ?? -1) + 1})');
} on FormatException catch (e) {
throw ConfigException('failed to parse $path: ${e.message}');
}
if (root is! Map) throw const ConfigException('root must be a map');
return {for (final e in root.entries) '${e.key}': e.value};
}
+1 -1
View File
@@ -1,5 +1,5 @@
abstract class RelayConstants {
static const int protoVersion = 6;
static const int protoVersion = 7;
static const int chunkBytes = 256 * 1024;
static const int creditWindowChunks = 8;
+117 -5
View File
@@ -45,22 +45,97 @@ class HelloUser {
HelloUser(name: (json['name'] as String?) ?? '', avatar: (json['avatar'] as String?) ?? '');
}
/// A Jellyfin item the session plays, by reference: each viewer resolves it
/// with its own account (or through the server's relay). Never carries a URL
/// or a token.
class JellyfinRef {
/// Jellyfin `ServerId` (`/System/Info/Public`), the key accounts are stored by.
final String serverId;
final String itemId;
final String? mediaSourceId;
/// Display text: item name, and e.g. "Show - S01E02" for episodes.
final String title;
final String? subtitle;
/// Length from Jellyfin's RunTimeTicks, informational; the sync clock
/// still uses the length a client reports.
final double? runtimeSecs;
const JellyfinRef({
required this.serverId,
required this.itemId,
this.mediaSourceId,
this.title = '',
this.subtitle,
this.runtimeSecs,
});
Map<String, Object?> toJson() => {
'serverId': serverId,
'itemId': itemId,
if (mediaSourceId != null) 'mediaSourceId': mediaSourceId,
'title': title,
if (subtitle != null) 'subtitle': subtitle,
if (runtimeSecs != null) 'runtimeSecs': runtimeSecs,
};
factory JellyfinRef.fromJson(Map<String, Object?> json) {
final runtime = json['runtimeSecs'];
return JellyfinRef(
serverId: (json['serverId'] as String?) ?? '',
itemId: (json['itemId'] as String?) ?? '',
mediaSourceId: json['mediaSourceId'] as String?,
title: (json['title'] as String?) ?? '',
subtitle: json['subtitle'] as String?,
runtimeSecs: runtime == null ? null : finiteDouble(runtime, 'runtimeSecs'),
);
}
@override
bool operator ==(Object other) =>
other is JellyfinRef &&
other.serverId == serverId &&
other.itemId == itemId &&
other.mediaSourceId == mediaSourceId &&
other.title == title &&
other.subtitle == subtitle &&
other.runtimeSecs == runtimeSecs;
@override
int get hashCode =>
Object.hash(serverId, itemId, mediaSourceId, title, subtitle, runtimeSecs);
}
class HelloMedia {
final String name;
final int size;
final double duration;
final String? url;
const HelloMedia({required this.name, required this.size, required this.duration, this.url});
/// Set for Jellyfin media; then there is no [url] (the server drops one).
final JellyfinRef? jellyfin;
const HelloMedia(
{required this.name, required this.size, required this.duration, this.url, this.jellyfin});
Map<String, Object?> toJson() => {'name': name, 'size': size, 'duration': duration, if (url != null) 'url': url};
HelloMedia copyWith({double? duration}) => HelloMedia(
name: name, size: size, duration: duration ?? this.duration, url: url, jellyfin: jellyfin);
factory HelloMedia.fromJson(Map<String, Object?> json) => HelloMedia(
Map<String, Object?> toJson() => {
'name': name,
'size': size,
'duration': duration,
if (url != null) 'url': url,
if (jellyfin != null) 'jellyfin': jellyfin!.toJson(),
};
factory HelloMedia.fromJson(Map<String, Object?> json) {
final jf = json['jellyfin'];
return HelloMedia(
name: (json['name'] as String?) ?? '',
size: finiteInt(json['size'], 'size', fallback: 0),
duration: finiteDouble(json['duration'], 'duration'),
url: json['url'] as String?,
jellyfin: jf is Map ? JellyfinRef.fromJson(Map<String, Object?>.from(jf)) : null,
);
}
}
class HelloMessage {
final int proto;
@@ -134,14 +209,19 @@ class WelcomeMessage {
final String token;
final String memberId;
final String? mediaUrl;
/// This member's `/j/...` URL for the current Jellyfin media when the
/// server can relay it (`state.relay`); private like [token].
final String? relayUrl;
final Map<String, Object?>? state;
const WelcomeMessage({required this.token, this.memberId = '', this.mediaUrl, this.state});
const WelcomeMessage(
{required this.token, this.memberId = '', this.mediaUrl, this.relayUrl, this.state});
Map<String, Object?> toJson() => {
't': 'welcome',
'token': token,
'memberId': memberId,
if (mediaUrl != null) 'mediaUrl': mediaUrl,
if (relayUrl != null) 'relayUrl': relayUrl,
if (state != null) 'state': state,
};
@@ -152,6 +232,7 @@ class WelcomeMessage {
token: (json['token'] as String?) ?? '',
memberId: (json['memberId'] as String?) ?? '',
mediaUrl: json['mediaUrl'] as String?,
relayUrl: json['relayUrl'] as String?,
state: state is Map ? Map<String, Object?>.from(state) : null,
);
}
@@ -257,12 +338,16 @@ class ReportMessage {
final double duration;
/// The `mediaId` the client has open; [duration] belongs to it.
final int mediaId;
/// Jellyfin access by `ServerId`: true = plays with its own account,
/// false = through the server's relay. Null leaves the last one in place.
final Map<String, bool>? jf;
const ReportMessage(
{required this.position,
required this.buffered,
required this.eventId,
this.duration = 0,
this.mediaId = 0});
this.mediaId = 0,
this.jf});
Map<String, Object?> toJson() => {
't': 'report',
@@ -271,6 +356,7 @@ class ReportMessage {
'eventId': eventId,
if (duration > 0) 'duration': duration,
if (mediaId != 0) 'mediaId': mediaId,
if (jf != null) 'jf': jf,
};
factory ReportMessage.fromJson(Map<String, Object?> json) {
@@ -281,10 +367,25 @@ class ReportMessage {
eventId: finiteInt(json['eventId'], 'eventId', fallback: 0),
duration: finiteDouble(json['duration'], 'duration'),
mediaId: finiteInt(json['mediaId'], 'mediaId'),
jf: jfAccessFromJson(json['jf']),
);
}
}
/// Reads a `jf` map (`{serverId: bool}`), dropping non-bool entries and
/// keys over 64 characters, at most 8 entries (it rides in every `state`);
/// null when absent or not a map.
Map<String, bool>? jfAccessFromJson(Object? value) {
if (value is! Map) return null;
final out = <String, bool>{};
for (final e in value.entries) {
if (out.length >= 8) break;
final k = e.key;
if (k is String && k.length <= 64 && e.value is bool) out[k] = e.value as bool;
}
return out;
}
/// Clock probe: the client sends its own clock in [clientMs]; the server
/// answers with a [PongMessage] carrying it back plus its clock.
class PingMessage {
@@ -333,6 +434,8 @@ class MemberView {
final bool inSet;
final double buffered;
final bool stale;
/// Last `report.jf` of this member; empty when never reported.
final Map<String, bool> jf;
const MemberView({
required this.id,
required this.name,
@@ -341,6 +444,7 @@ class MemberView {
required this.inSet,
required this.buffered,
required this.stale,
this.jf = const {},
});
Map<String, Object?> toJson() => {
@@ -351,6 +455,7 @@ class MemberView {
'inSet': inSet,
'buffered': buffered,
'stale': stale,
if (jf.isNotEmpty) 'jf': jf,
};
factory MemberView.fromJson(Map<String, Object?> json) => MemberView(
@@ -361,6 +466,7 @@ class MemberView {
inSet: (json['inSet'] as bool?) ?? false,
buffered: finiteDouble(json['buffered'], 'buffered'),
stale: (json['stale'] as bool?) ?? false,
jf: jfAccessFromJson(json['jf']) ?? const {},
);
}
@@ -376,6 +482,9 @@ class StateBroadcast {
/// current event is not a seek.
final String? seekBy;
final int? seekSeq;
/// The server can relay the current Jellyfin media (`/j/...`) for viewers
/// without their own access to it.
final bool relay;
const StateBroadcast({
required this.eventId,
required this.transport,
@@ -386,6 +495,7 @@ class StateBroadcast {
required this.members,
this.seekBy,
this.seekSeq,
this.relay = false,
});
Map<String, Object?> toJson() => {
@@ -399,6 +509,7 @@ class StateBroadcast {
'members': members.map((m) => m.toJson()).toList(),
if (seekBy != null) 'seekBy': seekBy,
if (seekSeq != null) 'seekSeq': seekSeq,
if (relay) 'relay': true,
};
factory StateBroadcast.fromJson(Map<String, Object?> json) {
@@ -420,6 +531,7 @@ class StateBroadcast {
: const [],
seekBy: json['seekBy'] as String?,
seekSeq: json['seekSeq'] == null ? null : finiteInt(json['seekSeq'], 'seekSeq'),
relay: (json['relay'] as bool?) ?? false,
);
}
}
+10 -3
View File
@@ -22,6 +22,8 @@ class EngineMember {
bool ready = false;
bool inSet = false;
double buffered = 0;
/// Last reported Jellyfin access by ServerId (`report.jf`).
Map<String, bool> jf = const {};
DateTime lastSeen;
EngineMember({
required this.id,
@@ -39,6 +41,7 @@ class EngineMember {
inSet: inSet,
buffered: buffered,
stale: stale,
jf: jf,
);
}
@@ -234,17 +237,18 @@ class SyncEngine {
}
void applyReport(String token, double reportPosition, double buffered, int reportEventId,
{double duration = 0, int mediaId = 0}) {
{double duration = 0, int mediaId = 0, Map<String, bool>? jf}) {
final member = members[token];
if (member == null) return;
if (!reportPosition.isFinite) return;
_seen(member);
if (jf != null) member.jf = Map.unmodifiable(jf);
member.buffered = !buffered.isFinite || buffered < 0 ? 0 : buffered;
final m = media;
// First report of the length wins; only for the media the client has open.
if (m != null && m.duration <= 0 && mediaId == this.mediaId &&
duration.isFinite && duration > 0) {
media = HelloMedia(name: m.name, size: m.size, duration: duration, url: m.url);
media = m.copyWith(duration: duration);
}
if (reportEventId < eventId) return;
evaluate();
@@ -317,7 +321,9 @@ class SyncEngine {
}
}
StateBroadcast broadcast() {
/// [relay]: whether the server can relay the current Jellyfin media; the
/// engine does not know the server's credential.
StateBroadcast broadcast({bool relay = false}) {
final bySeek = _seekEventId == eventId;
return StateBroadcast(
eventId: eventId,
@@ -329,6 +335,7 @@ class SyncEngine {
members: members.values.map((m) => m.view(stale: isStale(m))).toList(),
seekBy: bySeek ? _seekBy : null,
seekSeq: bySeek ? _seekSeq : null,
relay: relay,
);
}
}
+220
View File
@@ -0,0 +1,220 @@
import 'dart:convert';
import 'dart:io';
import 'package:sync_relay_core/core.dart';
import 'package:test/test.dart';
Map<String, Object?> wire(Map<String, Object?> json) =>
Map<String, Object?>.from(jsonDecode(jsonEncode(json)) as Map);
void main() {
const ref = JellyfinRef(
serverId: 'abc123',
itemId: '0123456789abcdef0123456789abcdef',
mediaSourceId: 'fedcba9876543210fedcba9876543210',
title: 'Pilot',
subtitle: 'Show - S01E01',
runtimeSecs: 1440.5,
);
group('protocol', () {
test('swap with a Jellyfin ref round-trips, no url', () {
const swap = SwapIntent(media: HelloMedia(name: 'Pilot', size: 0, duration: 0, jellyfin: ref));
final json = wire(swap.toJson());
expect(json['media'], {
'name': 'Pilot',
'size': 0,
'duration': 0.0,
'jellyfin': {
'serverId': 'abc123',
'itemId': '0123456789abcdef0123456789abcdef',
'mediaSourceId': 'fedcba9876543210fedcba9876543210',
'title': 'Pilot',
'subtitle': 'Show - S01E01',
'runtimeSecs': 1440.5,
},
});
final back = SwapIntent.fromJson(json).media;
expect(back.jellyfin, ref);
expect(back.url, isNull);
});
test('minimal ref omits optional fields; url media has no jellyfin', () {
const minimal = JellyfinRef(serverId: 's', itemId: 'i');
expect(minimal.toJson(), {'serverId': 's', 'itemId': 'i', 'title': ''});
expect(JellyfinRef.fromJson(wire(minimal.toJson())), minimal);
final url = HelloMedia.fromJson(wire(const HelloMedia(name: 'a', size: 1, duration: 2, url: 'http://x/a').toJson()));
expect(url.jellyfin, isNull);
expect(url.toJson().containsKey('jellyfin'), isFalse);
});
test('non-finite runtimeSecs is rejected', () {
expect(() => JellyfinRef.fromJson({'serverId': 's', 'itemId': 'i', 'runtimeSecs': double.infinity}),
throwsA(isA<ProtocolException>()));
});
test('state relay flag and member jf round-trip, absent by default', () {
final state = StateBroadcast(
eventId: 1,
transport: 'held',
position: 0,
serverTime: DateTime.utc(2026),
mediaId: 2,
media: const HelloMedia(name: 'Pilot', size: 0, duration: 0, jellyfin: ref).toJson(),
members: const [
MemberView(id: 'm1', name: 'a', ready: false, inSet: false, buffered: 0, stale: false, jf: {'abc123': false}),
MemberView(id: 'm2', name: 'b', ready: false, inSet: false, buffered: 0, stale: false),
],
relay: true,
);
final json = wire(state.toJson());
expect(json['relay'], true);
expect((json['members'] as List)[0], containsPair('jf', {'abc123': false}));
expect((json['members'] as List)[1], isNot(contains('jf')));
final back = StateBroadcast.fromJson(json);
expect(back.relay, isTrue);
expect(back.members[0].jf, {'abc123': false});
expect(back.members[1].jf, isEmpty);
expect(HelloMedia.fromJson(back.media!).jellyfin, ref);
final plain = StateBroadcast(
eventId: 1, transport: 'held', position: 0, serverTime: DateTime.utc(2026), mediaId: 1, members: const []);
expect(plain.toJson().containsKey('relay'), isFalse);
expect(StateBroadcast.fromJson(wire(plain.toJson())).relay, isFalse);
});
test('welcome relayUrl round-trips, omitted when null', () {
const w = WelcomeMessage(token: 't', memberId: 'm1', relayUrl: 'http://h:1/j/default/t/i');
expect(WelcomeMessage.fromJson(wire(w.toJson())).relayUrl, 'http://h:1/j/default/t/i');
expect(const WelcomeMessage(token: 't').toJson().containsKey('relayUrl'), isFalse);
});
test('report jf: round-trip, absent = null, junk dropped and capped', () {
const r = ReportMessage(position: 1, buffered: 2, eventId: 3, jf: {'abc123': true});
expect(wire(r.toJson())['jf'], {'abc123': true});
expect(ReportMessage.fromJson(wire(r.toJson())).jf, {'abc123': true});
expect(ReportMessage.fromJson(wire(const ReportMessage(position: 1, buffered: 2, eventId: 3).toJson())).jf,
isNull);
final junk = ReportMessage.fromJson({
't': 'report',
'position': 0,
'buffered': 0,
'eventId': 0,
'jf': {
'ok': true,
'bad': 'yes',
'x' * 65: true,
for (var i = 0; i < 20; i++) 's$i': false,
},
}).jf!;
expect(junk['ok'], isTrue);
expect(junk.containsKey('bad'), isFalse);
expect(junk.length, 8);
});
});
group('engine', () {
late DateTime t;
late SyncEngine engine;
setUp(() {
t = DateTime.utc(2026);
engine = SyncEngine(clock: () => t);
engine.applyJoin('a', 'alice', '');
});
test('swap with a ref broadcasts the ref, and a learned duration keeps it', () {
expect(engine.applySwap('a', const HelloMedia(name: 'Pilot', size: 0, duration: 0, jellyfin: ref)), isTrue);
expect(engine.mediaId, 2);
engine.applyReport('a', 0, 0, engine.eventId, duration: 1441, mediaId: 2);
final media = HelloMedia.fromJson(engine.broadcast().media!);
expect(media.duration, 1441);
expect(media.jellyfin, ref);
expect(media.url, isNull);
});
test('broadcast carries relay only when asked', () {
expect(engine.broadcast().relay, isFalse);
expect(engine.broadcast(relay: true).relay, isTrue);
});
test('report jf is sticky per member and shows in the member view', () {
engine.applyReport('a', 0, 0, engine.eventId, jf: {'abc123': true});
engine.applyReport('a', 0, 0, engine.eventId);
expect(engine.broadcast().members.single.jf, {'abc123': true});
engine.applyReport('a', 0, 0, engine.eventId, jf: {'abc123': false});
expect(engine.broadcast().members.single.jf, {'abc123': false});
});
});
group('config', () {
const cred = JellyfinCredential(
baseUrl: 'http://jf.local:8096',
serverId: 'abc123',
token: 'SECRET-TOKEN',
client: 'Watch Together',
device: 'host-pc',
deviceId: 'dev-1',
version: '1.2.3',
);
test('AppConfig map round-trips the credential as primitives', () {
final map = const AppConfig(port: 1, secret: '', name: 'n', jellyfin: cred).toMap();
expect(map['jellyfin'], {
'baseUrl': 'http://jf.local:8096',
'serverId': 'abc123',
'token': 'SECRET-TOKEN',
'client': 'Watch Together',
'device': 'host-pc',
'deviceId': 'dev-1',
'version': '1.2.3',
});
final back = AppConfig.fromMap(wire(map)).jellyfin!;
expect(back.toMap(), cred.toMap());
expect(const AppConfig(port: 1, secret: '', name: 'n').toMap().containsKey('jellyfin'), isFalse);
expect(AppConfig.fromMap(const {}).jellyfin, isNull);
});
test('serverId optional; missing fields fail without echoing values', () {
final m = cred.toMap()..remove('serverId');
expect(JellyfinCredential.fromMap(m)!.serverId, '');
final bad = cred.toMap()..remove('device');
expect(
() => AppConfig.fromMap({'jellyfin': bad}),
throwsA(isA<ConfigException>()
.having((e) => e.message, 'message', allOf(contains('jellyfin.device'), isNot(contains('SECRET'))))));
});
test('toString never prints the token', () {
expect('$cred', isNot(contains('SECRET')));
});
test('clientInfo sends exactly the configured fields', () {
expect(cred.clientInfo.authorizationHeader(cred.token),
'MediaBrowser Client="Watch%20Together", Device="host-pc", DeviceId="dev-1", Version="1.2.3", Token="SECRET-TOKEN"');
});
test('credential file (YAML) loads; parse errors do not echo the file', () async {
final dir = await Directory.systemTemp.createTemp('jfcred');
try {
final ok = File('${dir.path}/jf.yaml');
await ok.writeAsString('baseUrl: http://jf.local:8096\ntoken: SECRET-TOKEN\nclient: Watch Together\n'
'device: host-pc\ndeviceId: dev-1\nversion: 1.2.3\n');
final loaded = await JellyfinCredential.loadFile(ok.path);
expect(loaded.token, 'SECRET-TOKEN');
expect(loaded.serverId, '');
final cfg = File('${dir.path}/cfg.yaml');
await cfg.writeAsString('port: 9\njellyfin:\n baseUrl: http://x\n token: SECRET-TOKEN\n'
' client: c\n device: d\n deviceId: i\n version: v\n');
expect((await AppConfig.loadFile(cfg.path)).jellyfin!.device, 'd');
final broken = File('${dir.path}/bad.yaml');
await broken.writeAsString('token: SECRET-TOKEN\n : : [\n');
await expectLater(
JellyfinCredential.loadFile(broken.path),
throwsA(isA<ConfigException>()
.having((e) => e.message, 'message', isNot(contains('SECRET')))));
} finally {
await dir.delete(recursive: true);
}
});
});
}
+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,
});
}
+35 -6
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);
mountSyncRoute(_app, hub, (sid, token, media, dialledHost) {
final name = Uri.encodeComponent(media?.name ?? 'file');
mountJellyfinRelayRoute(
_app, sessions, () => hub.jellyfin, () => _relayClient ??= HttpClient());
// advertiseHost is only an explicit override; otherwise echo what the client dialled.
final authority = config.advertiseHost.isNotEmpty
String authority(String dialledHost) => config.advertiseHost.isNotEmpty
? '${config.advertiseHost}:$boundPort'
: dialledHost;
return 'http://$authority/m/$sid/$token/$name';
});
mountSyncRoute(_app, hub, (sid, token, media, dialledHost) {
final name = Uri.encodeComponent(media?.name ?? 'file');
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', () {