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,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
Reference in new issue
Block a user