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

+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;
+122 -10
View File
@@ -45,21 +45,96 @@ 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(
name: (json['name'] as String?) ?? '',
size: finiteInt(json['size'], 'size', fallback: 0),
duration: finiteDouble(json['duration'], 'duration'),
url: json['url'] as String?,
);
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 {
@@ -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,
);
}
}