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:
1 parent
fb2927c094
commit
45f4c1bc84
18 files changed
+1363
-66
No files matched your search
@@ -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,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;
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user