Restructure step 1: lasting identity, presence and protocol 16
ci / core (push) Successful in 28s
ci / server (push) Successful in 1m5s
ci / updater-windows (push) Successful in 5m48s

Server and core: version ranges on both sides with a coded refusal; a
client id maps to the same member and token for the server's life; a
drop is `gone` for a 10-minute grace keeping ready, presence, queue
items, relay and avatar; `bye` leaves at once; ordered takeover (seq,
replaced/stale); presence joining/in/away/gone with one PresencePolicy;
joiners never pause playback and hold a held gate at most ~15 s;
state.hold; feed ids, seq and replay; the relay lives through its
source's grace and continues on re-dial; queue `by` lasting, auto
items and cancel; MediaRef carried; client ids never logged.

App: persisted client id and seq; Connection with unstable/lost/
replaced states, backoff with jitter, no lost/back pair per flap; a drop
keeps the session (state, player, media, relay source); presence in the
strip and members card; sit out = away intent; feed matched by id;
auto sit-out (idle when not playing, minimised for a while; never when
alone) replaces auto-unready; bye on close and Leave, not on update
restart; relay source re-dials through its grace.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
foxyzandClaude Opus 5.5 committed 2026-10-15 21:39:47 +03:00
1 parent 51c1c5ab78
commit 9f2586b7ff
85 files changed
+5198 -1902

No files matched your search

+1
View File
@@ -4,6 +4,7 @@ export 'src/logging.dart';
export 'src/diag_share.dart';
export 'src/clock_sync.dart';
export 'src/config.dart';
export 'src/presence.dart';
export 'src/protocol.dart';
export 'src/queue.dart';
export 'src/relay.dart';
+27 -5
View File
@@ -1,5 +1,12 @@
abstract class RelayConstants {
static const int protoVersion = 15;
/// The wire version this build speaks best (`hello.proto`,
/// `welcome.proto`); always [protoMax].
static const int protoVersion = protoMax;
/// The range of wire versions this build accepts (`hello.protoMin` ..
/// `hello.proto` on a client, `/health.protoMin` .. `protoMax` on a
/// server). Additions within a version family raise [protoMax] only.
static const int protoMin = 16;
static const int protoMax = 16;
// Relay (`/r/...`): a member's bytes through the server's buffer.
static const int relaySliceBytes = 1 << 20;
@@ -44,10 +51,25 @@ abstract class RelayConstants {
static const int stateHeartbeatSecs = 1;
static const int pingIntervalSecs = 5;
/// Server liveness defaults ([Liveness]): a member silent this long is
/// stale, then removed.
static const int staleAfterSecs = 15;
static const int dropAfterSecs = 30;
/// Server liveness defaults ([Liveness], PROTOCOL.md "Presence"): a
/// socket without a frame for [silenceSecs] is closed and its member is
/// `gone`; a `gone` member is kept [graceSecs] (`welcome.grace`), then
/// it leaves; the feed says `gone` only after [goneNoticeSecs] (a quick
/// return says nothing). A joiner holds a HELD gate at most
/// [joinerHoldSecs] (D2) and stays `joining` at most [joiningTimeoutSecs].
static const int silenceSecs = 15;
static const int graceSecs = 600;
static const int goneNoticeSecs = 10;
static const int joinerHoldSecs = 15;
static const int joiningTimeoutSecs = 60;
/// Feed lines the server keeps for `hello.feedAfter` (the replay in
/// `welcome.feed`).
static const int feedReplayLines = 50;
/// `hello.client`: an opaque per-install id, base64url, this long.
static const int minClientIdLength = 16;
static const int maxClientIdLength = 64;
/// `media.ref` ([MediaRef]) as JSON: at most this many bytes.
static const int maxMediaRefBytes = 2048;
// Base64-char count of the avatar string, enforced at hello and
// profile. 256 KiB leaves wide headroom over any 128x128 pipeline PNG
+3 -2
View File
@@ -117,7 +117,7 @@ void relogLine(String tag, String line) {
// `"name":"value"` or `Name="value"` (Jellyfin's MediaBrowser header).
const _secretNames = r'(?:x-emby-token|x-mediabrowser-token|x-emby-authorization|authorization|set-cookie|cookie|'
r'api_?key|access_?token|refresh_?token|id_?token|auth_?token|token|password|passwd|secret|host_?key|'
r'sessdata|bili_jct|client_?secret)';
r'sessdata|bili_jct|client_?secret|client_?id|client)';
final _jsonSecret = RegExp('"($_secretNames)"\\s*:\\s*"(?:[^"\\\\]|\\\\.)*"', caseSensitive: false);
final _quotedSecret = RegExp('\\b($_secretNames)\\s*=\\s*"[^"]*"', caseSensitive: false);
// Header-like `Name: value` runs to the end of the line (a cookie
@@ -135,7 +135,8 @@ final _userInfo = RegExp(r'^([a-z]+://)[^/@\s]+@', caseSensitive: false);
/// [s] with nothing secret left: every URL's query string down to its
/// parameter names (signed CDN links, `api_key`), user:password@ in URLs,
/// member tokens in `/r/ /m/ /j/ /a/ /i/ /l/` paths, and the values of
/// passwords, tokens, API keys, cookies and auth headers. Applied to
/// passwords, tokens, API keys, cookies, auth headers and the per-install
/// client id (`hello.client`). Applied to
/// every log line ([formatLogLine]).
String redactLog(String s) {
if (s.isEmpty) return s;
+124
View File
@@ -0,0 +1,124 @@
import 'constants.dart';
// Presence (D1, PROTOCOL.md "Presence"): where a member stands, and one
// table of what each standing counts for. Pure, so the server's engine and
// the app read the same rules.
/// A member's presence, `members[].presence` on the wire ([wire]).
enum Presence {
/// New, back from `gone`, or back from `away`: loading the current media
/// in the background until its reports hold it with `bufferNeed`, then
/// [watching]. Wire `joining`.
joining,
/// Watching, in full (D1 "In"). Wire `in` (a Dart keyword, hence the name).
watching,
/// Sat out (by itself or by someone else): still in the server (chat,
/// feed, chosen notifications), never counted for the gate, the ready
/// counter or watched tracking. Wire `away`.
away,
/// Disconnected and kept for the grace period (`welcome.grace`) with
/// everything it had (ready flag, queue items, relay, picture). Wire
/// `gone`.
gone;
String get wire => this == watching ? 'in' : name;
/// Null for anything else.
static Presence? parse(Object? v) {
if (v == 'in') return watching;
for (final p in values) {
if (p != watching && p.name == v) return p;
}
return null;
}
}
/// Why a member set itself (or someone) `away` or `in`
/// (`presence.reason`): by hand, idle, its window minimised, or the app
/// closing (`bye`, reserved for the app's own record).
enum PresenceReason {
manual,
idle,
minimised,
bye;
static PresenceReason parse(Object? v) {
for (final r in values) {
if (r.name == v) return r;
}
return manual;
}
}
/// What a presence is asked about ([PresencePolicy.countsFor]).
enum Purpose {
/// The ready gate: who the server waits for before it plays.
gate,
/// "2/3 ready": who the ready counter counts.
readyCounter,
/// Watched tracking (Jellyfin played, history): who is watching.
tracking,
/// Getting Up next ready (warm relay readers, preloads).
preload,
/// Social notifications (joins, queue adds, chat pop-ups) in full.
notifySocial,
}
/// The one table of what a presence counts for (D1, D2):
///
/// | purpose | joining | in | away | gone |
/// |----------------|---------|-----|------|------|
/// | gate | D2 * | yes | no | no |
/// | readyCounter | yes | yes | no | no |
/// | tracking | no | yes | no | no |
/// | preload | yes | yes | no | no |
/// | notifySocial | yes | yes | no | no |
///
/// \* A `joining` member never holds a PLAYING gate (it loads in the
/// background and comes in already in sync); while HELD it holds at most
/// [joinerHold] from the moment it started joining, then the others go on
/// without it.
class PresencePolicy {
/// How long a joiner may hold a HELD gate (D2).
final Duration joinerHold;
const PresencePolicy({this.joinerHold = const Duration(seconds: RelayConstants.joinerHoldSecs)});
/// Whether a member with [presence] counts for [purpose]. For
/// [Purpose.gate] a `joining` member needs the gate's context: [playing]
/// (the transport is `playing`) and [joiningFor] (how long it has been
/// joining, on the server's clock).
bool countsFor(Presence presence, Purpose purpose,
{bool playing = false, Duration joiningFor = Duration.zero}) {
switch (presence) {
case Presence.watching:
return true;
case Presence.away:
case Presence.gone:
return false;
case Presence.joining:
switch (purpose) {
case Purpose.gate:
return !playing && joiningFor < joinerHold;
case Purpose.tracking:
return false;
case Purpose.readyCounter:
case Purpose.preload:
case Purpose.notifySocial:
return true;
}
}
}
}
/// D9 "alone": no other member is connected. [others] are the presences
/// of everyone but oneself; `in`, `joining` and `away` are company, `gone`
/// is not. Auto sit-out (never when alone) and the social notifications
/// read this one definition.
bool isAlone(Iterable<Presence> others) => others.every((p) => p == Presence.gone);
+576 -61
View File
@@ -1,5 +1,8 @@
import 'dart:convert';
import 'constants.dart';
import 'errors.dart';
import 'presence.dart';
import 'queue.dart';
/// Reads an optional JSON number as a finite double. JSON like 1e400 decodes to
@@ -97,6 +100,89 @@ class JellyfinRef {
Object.hash(serverId, itemId, mediaSourceId, title, subtitle, runtimeSecs);
}
/// What a media is, independent of where its bytes come from (`media.ref`):
/// an episode, a movie or an extra, by AniList and/or TVDB ids. Carried
/// as is in `swap.media`, `state.media` and `queue[].media` (the server
/// keeps it, at most [RelayConstants.maxMediaRefBytes] of JSON); nothing
/// produces it yet (step 4).
class MediaRef {
static const kinds = {'episode', 'movie', 'extra'};
/// `episode`, `movie` or `extra`.
final String kind;
final String title;
final int? anilistId;
/// The episode number within the AniList entry.
final int? anilistEp;
final int? tvdbId;
/// TVDB season (null: unknown or none, e.g. absolute numbering).
final int? season;
final int? ep;
/// Absolute episode number across seasons.
final int? absolute;
const MediaRef(
{required this.kind,
this.title = '',
this.anilistId,
this.anilistEp,
this.tvdbId,
this.season,
this.ep,
this.absolute});
Map<String, Object?> toJson() => {
'kind': kind,
if (title.isNotEmpty) 'title': title,
if (anilistId != null) 'anilistId': anilistId,
if (anilistEp != null) 'anilistEp': anilistEp,
if (tvdbId != null) 'tvdbId': tvdbId,
if (season != null) 'season': season,
if (ep != null) 'ep': ep,
if (absolute != null) 'absolute': absolute,
};
/// Null when [j] is not a ref (no known `kind`); throws
/// [ProtocolException] when it is larger than
/// [RelayConstants.maxMediaRefBytes] as JSON. Ids that are not
/// non-negative integers are dropped; the title is one line of at most
/// 200 characters.
static MediaRef? fromJson(Object? j) {
if (j is! Map) return null;
if (utf8.encode(jsonEncode(j)).length > RelayConstants.maxMediaRefBytes) {
throw const ProtocolException('media.ref too large');
}
final kind = j['kind'];
if (kind is! String || !kinds.contains(kind)) return null;
int? n(Object? v) => v is num && v.isFinite && v >= 0 && v == v.roundToDouble() ? v.toInt() : null;
final title = j['title'] is String ? (j['title'] as String).replaceAll(RegExp(r'\s+'), ' ').trim() : '';
return MediaRef(
kind: kind,
title: title.length > 200 ? title.substring(0, 200) : title,
anilistId: n(j['anilistId']),
anilistEp: n(j['anilistEp']),
tvdbId: n(j['tvdbId']),
season: n(j['season']),
ep: n(j['ep']),
absolute: n(j['absolute']),
);
}
@override
bool operator ==(Object other) =>
other is MediaRef &&
other.kind == kind &&
other.title == title &&
other.anilistId == anilistId &&
other.anilistEp == anilistEp &&
other.tvdbId == tvdbId &&
other.season == season &&
other.ep == ep &&
other.absolute == absolute;
@override
int get hashCode => Object.hash(kind, title, anilistId, anilistEp, tvdbId, season, ep, absolute);
}
class HelloMedia {
final String name;
final int size;
@@ -106,13 +192,16 @@ class HelloMedia {
final JellyfinRef? jellyfin;
/// Relayed media (bytes from the source member through the server).
final MediaRelay? relay;
/// What it is ([MediaRef]); null when unknown.
final MediaRef? ref;
const HelloMedia(
{required this.name,
required this.size,
required this.duration,
this.url,
this.jellyfin,
this.relay});
this.relay,
this.ref});
HelloMedia copyWith({double? duration, int? size, MediaRelay? relay}) => HelloMedia(
name: name,
@@ -120,7 +209,8 @@ class HelloMedia {
duration: duration ?? this.duration,
url: url,
jellyfin: jellyfin,
relay: relay ?? this.relay);
relay: relay ?? this.relay,
ref: ref);
Map<String, Object?> toJson() => {
'name': name,
@@ -129,6 +219,7 @@ class HelloMedia {
if (url != null) 'url': url,
if (jellyfin != null) 'jellyfin': jellyfin!.toJson(),
if (relay != null) 'relay': relay!.toJson(),
if (ref != null) 'ref': ref!.toJson(),
};
factory HelloMedia.fromJson(Map<String, Object?> json) {
@@ -141,6 +232,7 @@ class HelloMedia {
url: json['url'] as String?,
jellyfin: jf is Map ? JellyfinRef.fromJson(Map<String, Object?>.from(jf)) : null,
relay: relay is Map ? MediaRelay.fromJson(Map<String, Object?>.from(relay)) : null,
ref: MediaRef.fromJson(json['ref']),
);
}
}
@@ -185,7 +277,10 @@ class RelayTrackSpec {
/// buffer asks for bytes, and the media's average bitrate. [queueId]:
/// the Up next item this relay was started for (its warm relay) before
/// it went up; viewers open its `/r/.../<queueId>/...` URLs, so their
/// preloads of it carry on.
/// preloads of it carry on. [sourceGone]: the source member is `gone`
/// (since [sourceGoneSince]): the relay and its buffer stay, viewers play
/// what is buffered; it stays set once the source left for good (the
/// relay has ended then: `live` false, `/r` answers 404).
class MediaRelay {
final String source;
final bool fetch;
@@ -194,6 +289,8 @@ class MediaRelay {
final int rate;
final int need;
final String queueId;
final bool sourceGone;
final DateTime? sourceGoneSince;
const MediaRelay(
{this.source = '',
this.fetch = false,
@@ -201,7 +298,9 @@ class MediaRelay {
this.tracks = const [],
this.rate = 0,
this.need = 0,
this.queueId = ''});
this.queueId = '',
this.sourceGone = false,
this.sourceGoneSince});
/// The source is measured slower than the media plays (both known):
/// below 85 % of [need], so a rate close to it does not flicker.
@@ -214,14 +313,26 @@ class MediaRelay {
static bool validTracks(List<RelayTrackSpec> t) =>
t.length <= maxTracks && (t.length > 1 || (t.length == 1 && t.single.pieces));
MediaRelay copyWith({bool? live, List<RelayTrackSpec>? tracks, int? rate, int? need}) => MediaRelay(
source: source,
fetch: fetch,
live: live ?? this.live,
tracks: tracks ?? this.tracks,
rate: rate ?? this.rate,
need: need ?? this.need,
queueId: queueId);
/// [sourceGoneSince] null with [sourceGone] true: since now is unknown,
/// keep the earlier one; pass [sourceGone] false to clear both.
MediaRelay copyWith(
{bool? live,
List<RelayTrackSpec>? tracks,
int? rate,
int? need,
bool? sourceGone,
DateTime? sourceGoneSince}) =>
MediaRelay(
source: source,
fetch: fetch,
live: live ?? this.live,
tracks: tracks ?? this.tracks,
rate: rate ?? this.rate,
need: need ?? this.need,
queueId: queueId,
sourceGone: sourceGone ?? this.sourceGone,
sourceGoneSince:
(sourceGone ?? this.sourceGone) ? (sourceGoneSince ?? this.sourceGoneSince) : null);
Map<String, Object?> toJson() => {
if (source.isNotEmpty) 'source': source,
@@ -231,6 +342,8 @@ class MediaRelay {
if (rate > 0) 'rate': rate,
if (need > 0) 'need': need,
if (queueId.isNotEmpty) 'queueId': queueId,
if (sourceGone) 'sourceGone': true,
if (sourceGone && sourceGoneSince != null) 'sourceGoneSince': sourceGoneSince!.toUtc().toIso8601String(),
};
/// Unknown kinds, a single byte track or too many make it one file.
@@ -238,6 +351,7 @@ class MediaRelay {
final t = json['tracks'];
final specs = t is List ? [for (final e in t) RelayTrackSpec.fromJson(e)] : const <RelayTrackSpec?>[];
final ok = !specs.contains(null) && validTracks(specs.cast<RelayTrackSpec>());
final gone = json['sourceGone'] == true;
return MediaRelay(
source: json['source'] is String ? json['source'] as String : '',
fetch: json['fetch'] == true,
@@ -245,59 +359,167 @@ class MediaRelay {
tracks: ok ? List.unmodifiable(specs.cast<RelayTrackSpec>()) : const [],
rate: _byteRate(json['rate']),
need: _byteRate(json['need']),
queueId: json['queueId'] is String ? json['queueId'] as String : '');
queueId: json['queueId'] is String ? json['queueId'] as String : '',
sourceGone: gone,
sourceGoneSince: gone ? DateTime.tryParse('${json['sourceGoneSince']}') : null);
}
/// A bytes-per-second field: a finite non-negative number, else 0.
static int _byteRate(Object? v) => v is num && v.isFinite && v > 0 ? v.round() : 0;
}
/// The highest wire version both sides speak: the client's
/// [clientMin]..[clientMax] (`hello.protoMin`..`hello.proto`) against this
/// build's [RelayConstants.protoMin]..[RelayConstants.protoMax]; null when
/// the ranges do not meet.
int? pickProto(int clientMin, int clientMax,
{int serverMin = RelayConstants.protoMin, int serverMax = RelayConstants.protoMax}) {
final hi = clientMax < serverMax ? clientMax : serverMax;
final lo = clientMin > serverMin ? clientMin : serverMin;
return hi >= lo ? hi : null;
}
/// What this app can do (`hello.caps`, `members[].caps`): serve a relay
/// ([source]), resolve links with yt-dlp ([resolve]), play Jellyfin with
/// its own account ([jellyfin]). Unknown entries are ignored. Wire: an
/// object of `true` flags, e.g. `{"source":true,"resolve":true}`.
class ClientCaps {
final bool source;
final bool resolve;
final bool jellyfin;
const ClientCaps({this.source = false, this.resolve = false, this.jellyfin = false});
bool get isEmpty => !source && !resolve && !jellyfin;
Map<String, Object?> toJson() => {
if (source) 'source': true,
if (resolve) 'resolve': true,
if (jellyfin) 'jellyfin': true,
};
factory ClientCaps.fromJson(Object? j) => j is Map
? ClientCaps(source: j['source'] == true, resolve: j['resolve'] == true, jellyfin: j['jellyfin'] == true)
: const ClientCaps();
@override
bool operator ==(Object other) =>
other is ClientCaps && other.source == source && other.resolve == resolve && other.jellyfin == jellyfin;
@override
int get hashCode => Object.hash(source, resolve, jellyfin);
}
/// `hello.client`: an opaque per-install id ([RelayConstants.minClientIdLength]
/// to [RelayConstants.maxClientIdLength] characters of base64url). Private:
/// never broadcast, never logged.
bool validClientId(String id) =>
id.length >= RelayConstants.minClientIdLength &&
id.length <= RelayConstants.maxClientIdLength &&
RegExp(r'^[A-Za-z0-9_-]+$').hasMatch(id);
/// The first frame of a `/sync` socket. A proto 16 app sends `media: {}`
/// (ignored) so a proto 15 server, which requires it, answers with its
/// "proto mismatch" instead of a parse error.
class HelloMessage {
/// The client's highest wire version (`hello.proto`).
final int proto;
/// The client's lowest wire version (`hello.protoMin`); defaults to
/// [proto] when absent.
final int protoMin;
final String secret;
final HelloUser user;
final HelloMedia media;
/// The hosting app's own client proves it is the host with this; empty
/// for everyone else.
final String hostKey;
/// Token from this client's previous `welcome`, sent on reconnect: if the
/// server still has that member (an unclean drop it has not noticed), the
/// connection takes it over instead of leaving a ghost.
final String resume;
/// Per-install id ([validClientId]): a hello with a known one takes that
/// member over. Empty: a fresh member every time (tools, tests).
final String client;
/// Increasing per [client] (every connect of this install sends a higher
/// one): an older hello than the member's last gets `err stale`.
final int seq;
/// App version (`"1.3.0"`), shown as `members[].app`.
final String app;
/// `linux`, `windows`, `macos` or `android`; logged, not broadcast.
final String platform;
final ClientCaps caps;
/// The last `feed.seq` this client saw from this server: `welcome.feed`
/// replays what came after it (at most [RelayConstants.feedReplayLines]).
/// Null: no replay.
final int? feedAfter;
const HelloMessage(
{required this.proto,
{this.proto = RelayConstants.protoMax,
int? protoMin,
required this.secret,
required this.user,
required this.media,
this.hostKey = '',
this.resume = ''});
this.client = '',
this.seq = 0,
this.app = '',
this.platform = '',
this.caps = const ClientCaps(),
this.feedAfter})
: protoMin = protoMin ?? (proto < RelayConstants.protoMin ? proto : RelayConstants.protoMin);
Map<String, Object?> toJson() => {
't': 'hello',
'protoMin': protoMin,
'proto': proto,
'secret': secret,
'user': user.toJson(),
'media': media.toJson(),
'media': const <String, Object?>{},
if (hostKey.isNotEmpty) 'hostKey': hostKey,
if (resume.isNotEmpty) 'resume': resume,
if (client.isNotEmpty) 'client': client,
if (seq != 0) 'seq': seq,
if (app.isNotEmpty) 'app': app,
if (platform.isNotEmpty) 'platform': platform,
if (!caps.isEmpty) 'caps': caps.toJson(),
if (feedAfter != null) 'feedAfter': feedAfter,
};
/// The client's version range read leniently from a raw hello, before
/// [HelloMessage.fromJson]: a hello of another version may not parse.
/// Null when it names none.
static ({int min, int max})? protoRangeOf(Map<String, Object?> json) {
final p = json['proto'];
if (p is! num || !p.isFinite) return null;
final m = json['protoMin'];
final max = p.toInt();
final min = m is num && m.isFinite && m.toInt() <= max ? m.toInt() : max;
return (min: min, max: max);
}
factory HelloMessage.fromJson(Map<String, Object?> json) {
if (json['t'] != 'hello') throw const ProtocolException('not a hello message');
final user = json['user'];
final media = json['media'];
if (user is! Map || media is! Map) throw const ProtocolException('hello missing user/media');
if (user is! Map) throw const ProtocolException('hello missing user');
final client = json['client'] ?? '';
if (client is! String || (client.isNotEmpty && !validClientId(client))) {
throw const ProtocolException('bad client id');
}
final proto = finiteInt(json['proto'], 'proto', fallback: RelayConstants.protoMax);
final feedAfter = json['feedAfter'];
return HelloMessage(
proto: finiteInt(json['proto'], 'proto', fallback: RelayConstants.protoVersion),
proto: proto,
protoMin: finiteInt(json['protoMin'], 'protoMin', fallback: proto),
secret: (json['secret'] as String?) ?? '',
user: HelloUser.fromJson(Map<String, Object?>.from(user)),
media: HelloMedia.fromJson(Map<String, Object?>.from(media)),
hostKey: (json['hostKey'] as String?) ?? '',
resume: (json['resume'] as String?) ?? '',
client: client,
seq: finiteInt(json['seq'], 'seq'),
app: _short(json['app'], 32),
platform: _short(json['platform'], 16),
caps: ClientCaps.fromJson(json['caps']),
feedAfter: feedAfter == null ? null : finiteInt(feedAfter, 'feedAfter'),
);
}
}
/// A short plain string field: one line, at most [max] characters.
String _short(Object? v, int max) {
if (v is! String) return '';
final line = v.replaceAll(RegExp(r'\s+'), ' ').trim();
return line.length > max ? line.substring(0, max) : line;
}
class SyncState {
final double position;
final bool paused;
@@ -328,46 +550,210 @@ class WelcomeMessage {
/// server can relay it (`state.relay`); private like [token].
final String? relayUrl;
final Map<String, Object?>? state;
/// The wire version of this connection ([pickProto]).
final int proto;
/// The hello took over this client's member (same [memberId] and
/// [token], everything it had kept); false for a new member.
final bool resumed;
/// Random per server boot: a client that held a member of another
/// instance knows the server restarted (not that its grace ran out).
final String instance;
/// Seconds a `gone` member is kept ([RelayConstants.graceSecs]).
final int grace;
/// Feed lines after `hello.feedAfter`, oldest first, each `replayed`.
final List<FeedMessage> feed;
const WelcomeMessage(
{required this.token, this.memberId = '', this.mediaUrl, this.relayUrl, this.state});
{required this.token,
this.memberId = '',
this.mediaUrl,
this.relayUrl,
this.state,
this.proto = RelayConstants.protoMax,
this.resumed = false,
this.instance = '',
this.grace = RelayConstants.graceSecs,
this.feed = const []});
Map<String, Object?> toJson() => {
't': 'welcome',
'proto': proto,
'token': token,
'memberId': memberId,
'resumed': resumed,
'instance': instance,
'grace': grace,
if (mediaUrl != null) 'mediaUrl': mediaUrl,
if (relayUrl != null) 'relayUrl': relayUrl,
if (state != null) 'state': state,
if (feed.isNotEmpty) 'feed': [for (final f in feed) f.toJson()],
};
factory WelcomeMessage.fromJson(Map<String, Object?> json) {
if (json['t'] != 'welcome') throw const ProtocolException('not a welcome message');
final state = json['state'];
final feed = json['feed'];
return 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,
proto: finiteInt(json['proto'], 'proto', fallback: 0),
resumed: json['resumed'] == true,
instance: json['instance'] is String ? json['instance'] as String : '',
grace: finiteInt(json['grace'], 'grace', fallback: 0),
feed: feed is List
? [
for (final f in feed)
if (f is Map && f['t'] == 'feed') FeedMessage.fromJson(Map<String, Object?>.from(f)),
]
: const [],
);
}
}
/// Sits [target] (a member id) out, or with [out] false brings oneself
/// back. Out: connected, but not in the ready gate and not playing.
class OutIntent {
final String target;
final bool out;
const OutIntent({required this.target, required this.out});
/// Error codes of `err.code` (PROTOCOL.md "Errors"). Terminal ones
/// ([version], [secret], [replaced], [stale], a refused `hello` with
/// [rejected] or [bad]) are followed by a close ([CloseCodes]).
enum ErrCode {
/// No common wire version: `min`/`max`/`server` say the server's.
version,
/// Wrong `hello.secret`.
secret,
/// Understood but refused (an avatar too large, a fetch the server
/// cannot do).
rejected,
/// Malformed (a bad hello, a non-finite number, a bad swap or queue item).
bad,
/// Another hello with this client id took the member over (the client
/// stops: "opened in another window").
replaced,
/// This hello is older (`hello.seq`) than the member's current one.
stale,
/// Not allowed for this member (e.g. setting someone else `in`).
forbidden,
/// Over a rate limit (reserved; nudges over the cap are dropped silently).
rate,
/// A list is full (Up next at [RelayConstants.maxQueueItems]).
full;
Map<String, Object?> toJson() => {'t': 'out', 'target': target, 'out': out};
factory OutIntent.fromJson(Map<String, Object?> json) {
if (json['t'] != 'out') throw const ProtocolException('not an out intent');
return OutIntent(target: (json['target'] as String?) ?? '', out: (json['out'] as bool?) ?? true);
static ErrCode? parse(Object? v) {
for (final c in values) {
if (c.name == v) return c;
}
return null;
}
}
/// Close codes of the server's sockets (PROTOCOL.md "Close codes").
abstract class CloseCodes {
/// Relay channel: not (or no longer) the source; the source stops.
static const relayNotSource = 4001;
/// Relay channel: its `info` differs from the media's first one (another
/// file); the relay ends and the source stops.
static const relayOtherMedia = 4002;
/// Relay channel: a newer channel for the same relay replaced it; the
/// source stops.
static const relayReplaced = 4003;
/// `/sync`: refused at hello, `err` `version`.
static const version = 4010;
/// `/sync`: refused at hello, `err` `secret`.
static const secret = 4011;
/// `/sync`: refused at hello, `err` `rejected` or `bad`.
static const refused = 4012;
/// `/sync`: another hello took the member over (`err` `replaced`).
static const replaced = 4013;
/// `/sync`: an older hello than the member's (`err` `stale`).
static const stale = 4014;
/// `/sync`: no frame for the silence limit; the member is `gone`.
static const silent = 4015;
/// `/sync`: the member left (`bye`, or grace over).
static const left = 1000;
}
/// Server to client: `{"t":"err","code":..., "message":..., "for":...}`.
/// [message] is plain text for people (a proto 15 app shows it as is);
/// [code] is for programs. For [ErrCode.version]: [min]/[max] the server's
/// range and [server] its app version. [forType]: the `t` of the frame
/// that failed, when there is one (`for` on the wire).
class ErrorFrame {
final ErrCode? code;
final String message;
final String? forType;
final int? min;
final int? max;
final String? server;
const ErrorFrame({required this.code, required this.message, this.forType, this.min, this.max, this.server});
/// The stable refusal of a hello whose versions do not meet ours:
/// `{"t":"err","code":"version","min":16,"max":16,"server":"1.3.0",
/// "message":"proto mismatch: got 15, want 16..16"}`.
factory ErrorFrame.version({required int got, required String server}) => ErrorFrame(
code: ErrCode.version,
message: 'proto mismatch: got $got, want ${RelayConstants.protoMin}..${RelayConstants.protoMax}',
forType: 'hello',
min: RelayConstants.protoMin,
max: RelayConstants.protoMax,
server: server);
Map<String, Object?> toJson() => {
't': 'err',
if (code != null) 'code': code!.name,
'message': message,
if (forType != null) 'for': forType,
if (min != null) 'min': min,
if (max != null) 'max': max,
if (server != null) 'server': server,
};
/// A proto 15 server's `err` has no [code].
factory ErrorFrame.fromJson(Map<String, Object?> json) {
if (json['t'] != 'err') throw const ProtocolException('not an err frame');
final min = json['min'], max = json['max'];
return ErrorFrame(
code: ErrCode.parse(json['code']),
message: json['message'] is String ? json['message'] as String : '',
forType: json['for'] is String ? json['for'] as String : null,
min: min is num && min.isFinite ? min.toInt() : null,
max: max is num && max.isFinite ? max.toInt() : null,
server: json['server'] is String ? json['server'] as String : null,
);
}
}
/// Client to server: set [target] (a member id; empty = oneself) [state]
/// `away` (anyone may, on anyone) or `in` (only oneself; else `err`
/// `forbidden`). Coming back `in` goes through `joining`.
class PresenceIntent {
final String target;
/// [Presence.watching] (`in`) or [Presence.away].
final Presence state;
final PresenceReason reason;
const PresenceIntent({this.target = '', required this.state, this.reason = PresenceReason.manual});
Map<String, Object?> toJson() =>
{'t': 'presence', if (target.isNotEmpty) 'target': target, 'state': state.wire, 'reason': reason.name};
factory PresenceIntent.fromJson(Map<String, Object?> json) {
if (json['t'] != 'presence') throw const ProtocolException('not a presence intent');
final state = Presence.parse(json['state']);
if (state != Presence.watching && state != Presence.away) {
throw const ProtocolException('presence state must be in or away');
}
return PresenceIntent(
target: json['target'] is String ? json['target'] as String : '',
state: state!,
reason: PresenceReason.parse(json['reason']));
}
}
/// Client to server: leave now (the Leave button, the app closing). The
/// member leaves at once (feed `leave`, no grace) and the socket closes.
class ByeMessage {
const ByeMessage();
Map<String, Object?> toJson() => {'t': 'bye'};
}
/// Client to server: poke [target] (a member id). The server passes it on
/// to that member only, as a [NudgeNotice].
class NudgeIntent {
@@ -632,21 +1018,28 @@ class MemberView {
/// none.
final String avatarHash;
final bool ready;
/// Counts for the ready gate now: in the set and its presence counts
/// ([PresencePolicy], [Purpose.gate]).
final bool inSet;
final double buffered;
final bool stale;
/// Where it stands ([Presence]).
final Presence presence;
/// When it went `gone`; null otherwise.
final DateTime? goneSince;
/// Last `report.jf` of this member; empty when never reported.
final Map<String, bool> jf;
/// The hosting app's own member (sent the server's host key).
final bool host;
/// Sitting out: connected, outside the ready gate, not playing.
final bool out;
/// Why its player cannot open the current media (`report.error`); empty
/// while it can. Errored: outside the ready gate.
final String error;
/// Bytes per second the server sent this member lately (its file, the
/// Jellyfin relay or the relay); 0 when none.
final int rate;
/// Its app version (`hello.app`); empty when it sent none.
final String app;
/// What its app can do (`hello.caps`).
final ClientCaps caps;
const MemberView({
required this.id,
required this.name,
@@ -654,12 +1047,14 @@ class MemberView {
required this.ready,
required this.inSet,
required this.buffered,
required this.stale,
this.presence = Presence.watching,
this.goneSince,
this.jf = const {},
this.host = false,
this.out = false,
this.error = '',
this.rate = 0,
this.app = '',
this.caps = const ClientCaps(),
});
Map<String, Object?> toJson() => {
@@ -669,28 +1064,105 @@ class MemberView {
'ready': ready,
'inSet': inSet,
'buffered': buffered,
'stale': stale,
'presence': presence.wire,
if (presence == Presence.gone && goneSince != null) 'goneSince': goneSince!.toUtc().toIso8601String(),
if (jf.isNotEmpty) 'jf': jf,
if (host) 'host': true,
if (out) 'out': true,
if (error.isNotEmpty) 'error': error,
if (rate > 0) 'rate': rate,
if (app.isNotEmpty) 'app': app,
if (!caps.isEmpty) 'caps': caps.toJson(),
};
factory MemberView.fromJson(Map<String, Object?> json) => MemberView(
id: (json['id'] as String?) ?? '',
name: (json['name'] as String?) ?? '',
avatarHash: (json['avatarHash'] as String?) ?? '',
ready: (json['ready'] as bool?) ?? false,
inSet: (json['inSet'] as bool?) ?? false,
buffered: finiteDouble(json['buffered'], 'buffered'),
stale: (json['stale'] as bool?) ?? false,
jf: jfAccessFromJson(json['jf']) ?? const {},
host: json['host'] == true,
out: json['out'] == true,
error: memberErrorFromJson(json['error']),
rate: MediaRelay._byteRate(json['rate']),
);
factory MemberView.fromJson(Map<String, Object?> json) {
final presence = Presence.parse(json['presence']) ?? Presence.watching;
return MemberView(
id: (json['id'] as String?) ?? '',
name: (json['name'] as String?) ?? '',
avatarHash: (json['avatarHash'] as String?) ?? '',
ready: (json['ready'] as bool?) ?? false,
inSet: (json['inSet'] as bool?) ?? false,
buffered: finiteDouble(json['buffered'], 'buffered'),
presence: presence,
goneSince: presence == Presence.gone ? DateTime.tryParse('${json['goneSince']}') : null,
jf: jfAccessFromJson(json['jf']) ?? const {},
host: json['host'] == true,
error: memberErrorFromJson(json['error']),
rate: MediaRelay._byteRate(json['rate']),
app: _short(json['app'], 32),
caps: ClientCaps.fromJson(json['caps']),
);
}
}
/// Why the server is HELD (`state.hold`), most pressing first: a joiner
/// still loading within its bound ([joining], [until] when the bound
/// ends), someone not ready ([ready]), someone's buffer short ([buffer]),
/// the same while a relayed media's source is `gone` ([source]), or
/// nobody in the set to start ([start]: someone has to ready up).
enum HoldReason {
joining,
ready,
buffer,
source,
start;
static HoldReason? parse(Object? v) {
for (final r in values) {
if (r.name == v) return r;
}
return null;
}
}
/// `state.hold`: present while `transport` is `held` with media up.
/// [members]: the member ids the gate waits for (empty with
/// [HoldReason.start]). [until]: when that wait ends by itself (the
/// joiners' bound), on the server's clock.
class HoldInfo {
final HoldReason reason;
final List<String> members;
final DateTime? until;
const HoldInfo({required this.reason, this.members = const [], this.until});
Map<String, Object?> toJson() => {
'reason': reason.name,
'members': members,
if (until != null) 'until': until!.toUtc().toIso8601String(),
};
static HoldInfo? fromJson(Object? j) {
if (j is! Map) return null;
final reason = HoldReason.parse(j['reason']);
if (reason == null) return null;
final m = j['members'];
return HoldInfo(
reason: reason,
members: m is List ? m.whereType<String>().toList() : const [],
until: j['until'] == null ? null : DateTime.tryParse('${j['until']}'));
}
}
/// `state.advance` (reserved, step 6): the shared countdown to Up next's
/// first item when the media finishes. At [at] + [secs] (server clock) the
/// server sets `queuePlay` to [id]; `{"t":"queue","op":"cancel"}` clears
/// it for everyone. The server parses and broadcasts it but never starts
/// one yet.
class QueueAdvance {
final String id;
final DateTime at;
final int secs;
const QueueAdvance({required this.id, required this.at, required this.secs});
Map<String, Object?> toJson() => {'id': id, 'at': at.toUtc().toIso8601String(), 'secs': secs};
static QueueAdvance? fromJson(Object? j) {
if (j is! Map || j['id'] is! String) return null;
final at = DateTime.tryParse('${j['at']}');
final secs = j['secs'];
if (at == null || secs is! num || !secs.isFinite || secs < 0) return null;
return QueueAdvance(id: j['id'] as String, at: at, secs: secs.toInt());
}
}
class StateBroadcast {
@@ -733,6 +1205,10 @@ class StateBroadcast {
/// The queued item someone asked to play now, waiting for the member
/// who can put it up; null when none.
final String? queuePlay;
/// Why it is held ([HoldInfo]); null unless `held` with media up.
final HoldInfo? hold;
/// The shared countdown to Up next (reserved, [QueueAdvance]).
final QueueAdvance? advance;
const StateBroadcast({
required this.eventId,
required this.transport,
@@ -747,6 +1223,8 @@ class StateBroadcast {
this.relayNext = false,
this.queue = const [],
this.queuePlay,
this.hold,
this.advance,
});
Map<String, Object?> toJson() => {
@@ -764,6 +1242,8 @@ class StateBroadcast {
if (relayNext) 'relayNext': true,
if (queue.isNotEmpty) 'queue': [for (final q in queue) q.toJson()],
if (queuePlay != null) 'queuePlay': queuePlay,
if (hold != null) 'hold': hold!.toJson(),
if (advance != null) 'advance': advance!.toJson(),
};
factory StateBroadcast.fromJson(Map<String, Object?> json) {
@@ -791,31 +1271,62 @@ class StateBroadcast {
? (json['queue'] as List).map(QueueItem.fromJson).whereType<QueueItem>().toList()
: const [],
queuePlay: json['queuePlay'] is String ? json['queuePlay'] as String : null,
hold: HoldInfo.fromJson(json['hold']),
advance: QueueAdvance.fromJson(json['advance']),
);
}
}
/// One line of the activity feed. [actor] / [target] are display names
/// (as they were then); [actorId] / [targetId] the member ids (`server`
/// for the server's own lines, empty when none), so clients match people
/// by id, not by name. [seq] increases by one per line for the server's
/// life; [replayed] marks lines repeated in `welcome.feed` (log them, do
/// not toast them).
class FeedMessage {
final String actor;
final String action;
final String? target;
final String reason;
final DateTime at;
final String actorId;
final String targetId;
final int seq;
final bool replayed;
const FeedMessage({
required this.actor,
required this.action,
this.target,
this.reason = '',
required this.at,
this.actorId = '',
this.targetId = '',
this.seq = 0,
this.replayed = false,
});
FeedMessage copyWith({int? seq, bool? replayed}) => FeedMessage(
actor: actor,
action: action,
target: target,
reason: reason,
at: at,
actorId: actorId,
targetId: targetId,
seq: seq ?? this.seq,
replayed: replayed ?? this.replayed);
Map<String, Object?> toJson() => {
't': 'feed',
'seq': seq,
'actor': actor,
if (actorId.isNotEmpty) 'actorId': actorId,
'action': action,
if (target != null) 'target': target,
if (targetId.isNotEmpty) 'targetId': targetId,
'reason': reason,
'at': at.toIso8601String(),
if (replayed) 'replayed': true,
};
factory FeedMessage.fromJson(Map<String, Object?> json) {
@@ -826,6 +1337,10 @@ class FeedMessage {
target: json['target'] as String?,
reason: (json['reason'] as String?) ?? '',
at: DateTime.tryParse('${json['at']}') ?? DateTime.now().toUtc(),
actorId: json['actorId'] is String ? json['actorId'] as String : '',
targetId: json['targetId'] is String ? json['targetId'] as String : '',
seq: finiteInt(json['seq'], 'seq'),
replayed: json['replayed'] == true,
);
}
}
+100 -18
View File
@@ -2,6 +2,7 @@ import 'dart:typed_data';
import 'constants.dart';
import 'errors.dart';
import 'presence.dart';
import 'protocol.dart';
import 'sync_engine.dart';
@@ -36,7 +37,10 @@ enum QueueHow {
/// One entry of Up next as everyone sees it. [media] is what the swap
/// puts up, without a URL for [QueueHow.server], [QueueHow.through] and
/// [QueueHow.file] (the server, or the adder's app, keeps it). [key]: the
/// adder's own handle for what it keeps (adder-only items).
/// adder's own handle for what it keeps (adder-only items). [auto]: the
/// continuation of media [forMedia] (the next episode): at most one, always
/// after the others, replaced by a newer one, dropped by any swap that does
/// not put it up (wire `auto` and `for`).
class QueueItem {
final String id;
final HelloMedia media;
@@ -52,6 +56,9 @@ class QueueItem {
final String by;
final String byName;
final String key;
final bool auto;
/// The `mediaId` an [auto] item continues; 0 otherwise.
final int forMedia;
const QueueItem({
this.id = '',
required this.media,
@@ -63,10 +70,12 @@ class QueueItem {
this.by = '',
this.byName = '',
this.key = '',
this.auto = false,
this.forMedia = 0,
});
QueueItem copyWith({String? id, HelloMedia? media, String? title, String? sub, double? duration,
String? thumb, String? by, String? byName, String? key}) =>
String? thumb, String? by, String? byName, String? key, bool? auto, int? forMedia}) =>
QueueItem(
id: id ?? this.id,
media: media ?? this.media,
@@ -78,6 +87,8 @@ class QueueItem {
by: by ?? this.by,
byName: byName ?? this.byName,
key: key ?? this.key,
auto: auto ?? this.auto,
forMedia: forMedia ?? this.forMedia,
);
Map<String, Object?> toJson() => {
@@ -91,6 +102,8 @@ class QueueItem {
if (by.isNotEmpty) 'by': by,
if (byName.isNotEmpty) 'byName': byName,
if (key.isNotEmpty) 'key': key,
if (auto) 'auto': true,
if (auto) 'for': forMedia,
};
/// Null when [j] is not an item this side can read.
@@ -111,6 +124,8 @@ class QueueItem {
by: j['by'] is String ? j['by'] as String : '',
byName: j['byName'] is String ? j['byName'] as String : '',
key: j['key'] is String ? j['key'] as String : '',
auto: j['auto'] == true,
forMedia: j['auto'] == true && j['for'] is num && (j['for'] as num).isFinite ? (j['for'] as num).toInt() : 0,
);
}
}
@@ -118,8 +133,9 @@ class QueueItem {
/// Client to server: one change to Up next. [op] `add` (with [item],
/// [next] = right after what plays instead of at the end, [thumb] base64
/// picture), `move` ([id] to index [to]), `remove` ([id]; [why] set when it
/// is skipped because it cannot play), `play` ([id] now) or `skip` (the
/// first item now).
/// is skipped because it cannot play), `play` ([id] now), `skip` (the
/// first item now) or `cancel` (drops the [QueueItem.auto] item and the
/// countdown, `state.advance`, for everyone).
class QueueIntent {
final String op;
final QueueItem? item;
@@ -137,8 +153,9 @@ class QueueIntent {
const QueueIntent.remove(String id, {String why = ''}) : this._('remove', id: id, why: why);
const QueueIntent.play(String id) : this._('play', id: id);
const QueueIntent.skip() : this._('skip');
const QueueIntent.cancel() : this._('cancel');
static const ops = {'add', 'move', 'remove', 'play', 'skip'};
static const ops = {'add', 'move', 'remove', 'play', 'skip', 'cancel'};
Map<String, Object?> toJson() => {
't': 'queue',
@@ -210,26 +227,54 @@ class EngineQueue {
return i < 0 ? null : _items[i].privateUrl;
}
/// Adds [item] (already cleaned) with a fresh id, at the end or, with
/// [next], first. Null when full ([RelayConstants.maxQueueItems]).
/// The continuation item ([QueueItem.auto]), always last; null when none.
QueueItem? get auto {
final i = _autoIndex;
return i < 0 ? null : _items[i].item;
}
int get _autoIndex => _items.indexWhere((e) => e.item.auto);
/// Adds [item] (already cleaned) with a fresh id, at the end (before the
/// [auto] item) or, with [next], first. An [QueueItem.auto] item goes
/// last and replaces the earlier one. Null when full
/// ([RelayConstants.maxQueueItems]).
QueueItem? add(QueueItem item, {bool next = false, String? privateUrl, Uint8List? thumb}) {
if (item.auto) dropAuto();
if (_items.length >= RelayConstants.maxQueueItems) return null;
final t = thumb == null ? null : ArtImage(thumb);
final added = item.copyWith(id: 'q${_nextId++}', thumb: t?.hash ?? '');
final e = _Entry(added, privateUrl, t == null ? null : (hash: t.hash, bytes: t.bytes));
if (next) {
if (item.auto) {
_items.add(e);
} else if (next) {
_items.insert(0, e);
} else {
_items.add(e);
final a = _autoIndex;
_items.insert(a < 0 ? _items.length : a, e);
}
return added;
}
/// Drops the [auto] item; returns it (null when there was none).
QueueItem? dropAuto() {
final i = _autoIndex;
if (i < 0) return null;
final a = _items.removeAt(i).item;
if (play == a.id) _clearPlay();
return a;
}
/// Moves [id] to [to] (clamped). False when unknown or unchanged.
bool move(String id, int to) {
final i = _index(id);
if (i < 0) return false;
final target = to.clamp(0, _items.length - 1);
// The auto item stays after the others: it does not move, nothing
// moves past it.
final a = _autoIndex;
if (i == a) return false;
final last = _items.length - (a < 0 ? 1 : 2);
final target = to.clamp(0, last);
if (target == i) return false;
final e = _items.removeAt(i);
_items.insert(target, e);
@@ -257,8 +302,10 @@ class EngineQueue {
}
/// A swap happened: [id] (when it came from Up next) leaves the list,
/// and any pending play request is over.
/// the [auto] item goes too unless it is [id], and any pending play
/// request is over.
void swapped(String? id) {
if (auto?.id != id) dropAuto();
if (id != null) {
final i = _index(id);
if (i >= 0) _items.removeAt(i);
@@ -290,20 +337,40 @@ class EngineQueue {
}
}
/// Why a `queue` message was refused: the `err` the sender gets.
typedef QueueRefusal = ({ErrCode code, String message});
/// The `queue` messages applied to the engine's [SyncEngine.queue], with
/// their feed lines (`queue-add`, `queue-remove`, `queue-skip`). Each
/// returns whether the queue changed; [applyQueueAdd] an error text for the
/// sender instead (null when added).
/// their feed lines (`queue-add`, `queue-remove`, `queue-skip`,
/// `advance-cancel`). Each returns whether the queue changed;
/// [applyQueueAdd] a refusal for the sender instead (null when added).
extension EngineQueueOps on SyncEngine {
/// [item] is already cleaned by the server (see `cleanQueueAdd`).
String? applyQueueAdd(String token, QueueItem item,
/// [item] is already cleaned by the server (see `cleanQueueAdd`). An
/// [QueueItem.auto] item must continue the current media
/// ([QueueItem.forMedia] = [SyncEngine.mediaId]) and come from who put
/// it up ([SyncEngine.swapBy]) or, when that member is not `in`, from the
/// first member who is.
QueueRefusal? applyQueueAdd(String token, QueueItem item,
{bool next = false, String? privateUrl, Uint8List? thumb}) {
final member = members[token];
if (member == null) return null;
if (item.auto) {
if (media == null || item.forMedia != mediaId) {
return (code: ErrCode.bad, message: 'bad message: an auto item must name the current media');
}
final swapper = members[swapBy];
final firstIn = members.values.where((m) => m.presence == Presence.watching).firstOrNull;
final allowed = token == swapBy ||
((swapper == null || swapper.presence != Presence.watching) && identical(firstIn, member));
if (!allowed) {
return (code: ErrCode.forbidden, message: 'only who put this media up adds what continues it');
}
}
final added = queue.add(item.copyWith(by: member.id, byName: member.name),
next: next, privateUrl: privateUrl, thumb: thumb);
if (added == null) return 'Up next is full (${RelayConstants.maxQueueItems} items)';
feed.add(FeedMessage(actor: member.name, action: 'queue-add', target: added.title, at: now));
if (added == null) return (code: ErrCode.full, message: 'Up next is full (${RelayConstants.maxQueueItems} items)');
feed.add(FeedMessage(
actor: member.name, actorId: member.id, action: 'queue-add', target: added.title, at: now));
return null;
}
@@ -319,6 +386,7 @@ extension EngineQueueOps on SyncEngine {
if (gone == null) return false;
feed.add(FeedMessage(
actor: member.name,
actorId: member.id,
action: why.isEmpty ? 'queue-remove' : 'queue-skip',
target: gone.title,
reason: why,
@@ -333,4 +401,18 @@ extension EngineQueueOps on SyncEngine {
if (target == null || queue.play == target) return false;
return queue.requestPlay(target, now);
}
/// `cancel`: drops the continuation ([EngineQueue.auto]) and the
/// countdown ([SyncEngine.advance]) for everyone (feed `advance-cancel`).
bool applyQueueCancel(String token) {
final member = members[token];
if (member == null) return false;
final dropped = queue.dropAuto();
final hadAdvance = advance != null;
advance = null;
if (dropped == null && !hadAdvance) return false;
feed.add(FeedMessage(
actor: member.name, actorId: member.id, action: 'advance-cancel', target: dropped?.title, at: now));
return true;
}
}
+21 -6
View File
@@ -335,16 +335,26 @@ class _Pull {
/// Close code the server uses for "you are not (or no longer) the source of
/// the current media": the source stops instead of reconnecting.
const relayCloseNotSource = 4001;
const relayCloseNotSource = CloseCodes.relayNotSource;
/// Close codes after which a source stops for good: not the source
/// (4001), another media than the relay's (4002), replaced by a newer
/// channel (4003).
const relayStopCodes = {CloseCodes.relayNotSource, CloseCodes.relayOtherMedia, CloseCodes.relayReplaced};
/// Serves [media] on the relay channel at [url] (`ws://host/r/<sid>/<token>/source`)
/// until [close]. Reconnects with backoff when the channel drops; stops for
/// good when the server closes it with [relayCloseNotSource].
/// until [close]. Reconnects with backoff when the channel drops (the
/// server keeps the relay through the source's grace and pulls what it
/// still lacks); stops for good on [relayStopCodes]. [name] and
/// [duration] (seconds, 0 unknown) go in its `info`, so a re-dial is
/// recognised as the same media.
class RelaySource {
final Uri url;
final RelayMedia media;
final String name;
final double duration;
final Duration Function(int attempt) backoff;
RelaySource(this.url, this.media, {Duration Function(int attempt)? backoff})
RelaySource(this.url, this.media, {this.name = '', this.duration = 0, Duration Function(int attempt)? backoff})
: backoff = backoff ?? ((a) => Duration(milliseconds: min(8000, 500 * (1 << min(a, 4)))));
final window = RelayWindow();
@@ -384,6 +394,8 @@ class RelaySource {
unawaited(_loop({
't': 'info',
if (relayTrackSpecs(tracks).isEmpty) 'size': infos.single['size'] else 'tracks': infos,
if (name.isNotEmpty) 'name': name,
if (duration > 0 && duration.isFinite) 'duration': duration,
}));
}
@@ -398,14 +410,17 @@ class RelaySource {
break;
}
_ws = ws;
// A half-open channel (a dead link without a FIN) is noticed by
// its missing pong, closed, and dialled again.
ws.pingInterval = const Duration(seconds: RelayConstants.pingIntervalSecs);
_log.info('relay channel up: $where${attempt > 0 ? ' (after $attempt retries)' : ''}');
attempt = 0;
ws.add(jsonEncode(info));
await for (final m in ws) {
if (m is String) _onText(ws, m);
}
if (ws.closeCode == relayCloseNotSource) {
_log.info('relay channel: not the source any more, stopping ($where, sent $bytesSent bytes)');
if (relayStopCodes.contains(ws.closeCode)) {
_log.info('relay channel: ${ws.closeCode == CloseCodes.relayNotSource ? 'not the source any more' : ws.closeCode == CloseCodes.relayOtherMedia ? 'another media than the relay\'s' : 'replaced by a newer channel'}, stopping ($where, sent $bytesSent bytes)');
_closed = true;
} else if (!_closed) {
_log.warning('relay channel down: $where, code ${ws.closeCode} ${ws.closeReason ?? ''}; reconnecting');
+366 -148
View File
@@ -4,26 +4,38 @@ import 'dart:typed_data';
import 'package:crypto/crypto.dart';
import 'constants.dart';
import 'presence.dart';
import 'protocol.dart';
import 'queue.dart';
typedef SyncClock = DateTime Function();
/// How long the server bears a silent member. [staleAfter] and [dropAfter]
/// are measured on the engine's clock ([SyncEngine.clock]) by its 1 s
/// sweep, so a test with a fake clock steps through them without waiting;
/// How the server treats silence, drops and joiners (PROTOCOL.md
/// "Presence"). Everything but [pingInterval] is measured on the engine's
/// clock ([SyncEngine.clock]) by its 1 s sweep ([SyncEngine.tick]), so a
/// test with a fake clock steps through them without waiting;
/// [pingInterval] is the WebSocket's own timer (real time).
class Liveness {
/// No frame for longer than this: stale (out of the set, feed `stale`).
final Duration staleAfter;
/// No frame for longer than this: the member is removed (feed `leave`)
/// and its socket closed.
final Duration dropAfter;
/// No frame for longer than this: the socket is closed and the member
/// is `gone`.
final Duration silence;
/// A `gone` member is kept this long (`welcome.grace`), then it leaves.
final Duration grace;
/// The feed says `gone` only once a member has been gone this long (a
/// quick return says nothing).
final Duration goneNotice;
/// A `joining` member holds a HELD gate at most this long (D2).
final Duration joinerHold;
/// A `joining` member is `in` after this at the latest.
final Duration joiningTimeout;
/// WebSocket ping on every `/sync` socket.
final Duration pingInterval;
const Liveness({
this.staleAfter = const Duration(seconds: RelayConstants.staleAfterSecs),
this.dropAfter = const Duration(seconds: RelayConstants.dropAfterSecs),
this.silence = const Duration(seconds: RelayConstants.silenceSecs),
this.grace = const Duration(seconds: RelayConstants.graceSecs),
this.goneNotice = const Duration(seconds: RelayConstants.goneNoticeSecs),
this.joinerHold = const Duration(seconds: RelayConstants.joinerHoldSecs),
this.joiningTimeout = const Duration(seconds: RelayConstants.joiningTimeoutSecs),
this.pingInterval = const Duration(seconds: RelayConstants.pingIntervalSecs),
});
}
@@ -63,24 +75,41 @@ String avatarHashOf(List<int> bytes) => sha256.convert(bytes).toString().substri
class EngineMember {
final String id;
final String token;
/// `hello.client` (empty: none, the member cannot be taken over).
/// Private: never broadcast, never logged.
final String client;
/// The `hello.seq` of the connection that holds it now.
int seq;
String name;
/// Picture bytes, served at `/a/...`; only [avatarHash] is broadcast.
Uint8List? avatarBytes;
String avatarHash = '';
bool ready = false;
/// In the ready gate's set (joined it while not playing, readied, or
/// was there at a swap); whether it counts also depends on [presence]
/// and [error] ([SyncEngine.countsForGate]).
bool inSet = false;
double buffered = 0;
/// Last reported Jellyfin access by ServerId (`report.jf`).
Map<String, bool> jf = const {};
/// The hosting app's own member (set by the server from the host key).
bool host = false;
/// Sitting out: never in the set, never ready, its buffer ignored.
bool out = false;
/// Its player cannot open the current media (`report.error`): outside
/// the ready gate until a report clears it or the media changes.
String error = '';
/// Bytes per second the server sent it lately (set by the server).
int rate = 0;
/// `hello.app`, `hello.platform`, `hello.caps` of its last hello.
String app;
String platform;
ClientCaps caps;
Presence presence;
/// When [presence] began (joining since, gone since, ...).
DateTime presenceSince;
/// Gone: whether the feed has said so yet (after [Liveness.goneNotice]).
bool goneNoticed = false;
/// Gone: it was `away` before, and comes back `away`.
bool awayBeforeGone = false;
DateTime lastSeen;
EngineMember({
required this.id,
@@ -88,7 +117,14 @@ class EngineMember {
required this.name,
String avatar = '',
required DateTime now,
}) : lastSeen = now {
this.client = '',
this.seq = 0,
this.app = '',
this.platform = '',
this.caps = const ClientCaps(),
this.presence = Presence.watching,
}) : lastSeen = now,
presenceSince = now {
setAvatar(avatar);
}
@@ -111,19 +147,24 @@ class EngineMember {
return true;
}
MemberView view({required bool stale}) => MemberView(
/// When it went `gone`; null otherwise.
DateTime? get goneSince => presence == Presence.gone ? presenceSince : null;
MemberView view({required bool inGate}) => MemberView(
id: id,
name: name,
avatarHash: avatarHash,
ready: ready,
inSet: inSet,
inSet: inGate,
buffered: buffered,
stale: stale,
presence: presence,
goneSince: goneSince,
jf: jf,
host: host,
out: out,
error: error,
rate: rate,
app: app,
caps: caps,
);
}
@@ -148,22 +189,45 @@ class ArtImage {
ArtImage(this.bytes) : hash = avatarHashOf(bytes);
}
/// The feed lines the engine made: [pending] until the server sends them
/// ([drain]); each gets the next [FeedMessage.seq]. The last
/// [RelayConstants.feedReplayLines] stay for a reconnect's replay ([after]).
class EngineFeed {
final List<FeedMessage> pending = [];
void add(FeedMessage message) => pending.add(message);
final List<FeedMessage> _recent = [];
int _seq = 0;
/// The `seq` of the last line (0 before any).
int get lastSeq => _seq;
void add(FeedMessage message) {
final line = message.copyWith(seq: ++_seq);
pending.add(line);
_recent.add(line);
if (_recent.length > RelayConstants.feedReplayLines) _recent.removeAt(0);
}
List<FeedMessage> drain() {
final out = List<FeedMessage>.from(pending);
pending.clear();
return out;
}
/// Kept lines after [seq] (`hello.feedAfter`), oldest first, marked
/// `replayed`. Lines still [pending] are left out (they go out to
/// everyone anyway).
List<FeedMessage> after(int seq) => [
for (final f in _recent)
if (f.seq > seq && !pending.contains(f)) f.copyWith(replayed: true),
];
}
class SyncEngine {
final SyncClock clock;
final Liveness liveness;
Duration get staleAfter => liveness.staleAfter;
Duration get dropAfter => liveness.dropAfter;
final PresencePolicy policy;
final EngineFeed feed = EngineFeed();
/// Members by token, in join order.
final Map<String, EngineMember> members = {};
int eventId = 0;
@@ -181,6 +245,15 @@ class SyncEngine {
/// Up next ([EngineQueue]): in `state.queue`, edited by `queue`
/// messages, taken from by swaps that name an item.
final EngineQueue queue = EngineQueue();
/// The shared countdown to Up next (`state.advance`); reserved: only
/// `cancel` touches it today.
QueueAdvance? advance;
/// A member went `gone` (its socket closed or fell silent): the server
/// closes the socket and marks a relay it serves.
void Function(EngineMember member)? onGone;
/// A member left for good (`bye`, grace over): the server forgets its
/// token and ends a relay it served.
void Function(EngineMember member)? onLeft;
int _nextId = 1;
// Who sent the seek that produced [_seekEventId], for the echo check.
String? _seekBy;
@@ -189,6 +262,7 @@ class SyncEngine {
SyncEngine({SyncClock? clock, this.liveness = const Liveness()})
: clock = clock ?? (() => DateTime.now().toUtc()),
policy = PresencePolicy(joinerHold: liveness.joinerHold),
serverTime = (clock ?? (() => DateTime.now().toUtc()))();
DateTime get now => clock();
@@ -199,8 +273,13 @@ class SyncEngine {
return position + t.difference(serverTime).inMilliseconds / 1000.0;
}
bool isStale(EngineMember member, [DateTime? at]) =>
(at ?? now).difference(member.lastSeen) > staleAfter;
/// Whether [member] counts for the ready gate now: in the set, no
/// player error, and its presence counts ([PresencePolicy], D2).
bool countsForGate(EngineMember member, [DateTime? at]) =>
member.inSet &&
member.error.isEmpty &&
policy.countsFor(member.presence, Purpose.gate,
playing: transport == Transport.playing, joiningFor: (at ?? now).difference(member.presenceSince));
/// Media length in seconds, null while nobody has reported it.
double? get duration {
@@ -218,39 +297,160 @@ class SyncEngine {
return left.clamp(0.0, full).toDouble();
}
/// Any frame from a member: fresh again. Back in the set: a ready member
/// the stale sweep (or a swap while it was stale) took out (ready &&
/// !inSet only arises from those), and any member back from stale while
/// not playing (like a join then).
void _seen(EngineMember member) {
final wasStale = isStale(member);
member.lastSeen = now;
if (!member.out &&
member.error.isEmpty &&
!member.inSet &&
(member.ready || (wasStale && transport != Transport.playing))) {
member.inSet = true;
}
/// Any frame from a member.
void _seen(EngineMember member) => member.lastSeen = now;
/// Any frame from [token]'s socket (a ping, a chat): it is not silent.
void touch(String token) {
final m = members[token];
if (m != null) _seen(m);
}
/// Heartbeat: stale sweep, end of media.
FeedMessage _line(String action, {EngineMember? actor, EngineMember? target, String reason = ''}) => FeedMessage(
actor: actor?.name ?? 'server',
actorId: actor?.id ?? 'server',
action: action,
target: target?.name,
targetId: target?.id ?? '',
reason: reason,
at: now);
/// Heartbeat (1 s): silence, joining timeouts, gone notices, grace,
/// a stale play request, end of media.
void tick() {
sweepStale();
sweepSilent();
sweepPresence();
queue.tick(now);
evaluate();
}
String applyJoin(String token, String name, String avatar) {
/// Joining: loads in the background until its reports hold the media
/// (D2); with nothing up there is nothing to load.
Presence get _arriving => media == null ? Presence.watching : Presence.joining;
/// A new member ([client] empty: one that cannot be taken over).
String applyJoin(String token, String name, String avatar,
{String client = '', int seq = 0, String app = '', String platform = '', ClientCaps caps = const ClientCaps()}) {
final id = 'm${_nextId++}';
// Joining while not playing: in the set, so the start waits for them;
// while playing, out until they ready (a late joiner never pauses).
members[token] =
EngineMember(id: id, token: token, name: name, avatar: avatar, now: now)
..inSet = transport != Transport.playing;
feed.add(FeedMessage(actor: name, action: 'join', at: now));
// Joining while not playing: in the set, so the start waits for them
// (at most [Liveness.joinerHold]); while playing, out until they ready
// (a late joiner never pauses).
final m = members[token] = EngineMember(
id: id,
token: token,
name: name,
avatar: avatar,
now: now,
client: client,
seq: seq,
app: app,
platform: platform,
caps: caps,
presence: _arriving)
..inSet = transport != Transport.playing;
feed.add(_line('join', actor: m));
evaluate();
return id;
}
/// The member with `hello.client` [client]; null for none (or empty).
EngineMember? byClient(String client) {
if (client.isEmpty) return null;
for (final m in members.values) {
if (m.client == client) return m;
}
return null;
}
/// A newer hello of the same client took [token]'s member over (see
/// `hello.client`): back from `gone` it is `joining` again (`away` if it
/// was), ready flag kept, feed `return` when the feed had said `gone`.
/// Returns whether it came back from `gone`.
bool applyTakeover(String token,
{required int seq, String app = '', String platform = '', ClientCaps caps = const ClientCaps()}) {
final m = members[token];
if (m == null) return false;
m
..seq = seq
..app = app
..platform = platform
..caps = caps;
_seen(m);
if (m.presence != Presence.gone) return false;
final noticed = m.goneNoticed;
m
..goneNoticed = false
..presence = m.awayBeforeGone ? Presence.away : _arriving
..presenceSince = now
..awayBeforeGone = false
..inSet = m.error.isEmpty && (m.ready || transport != Transport.playing);
if (noticed) feed.add(_line('return', actor: m));
evaluate();
return true;
}
/// [token]'s socket closed or fell silent: `gone`, kept for the grace
/// with everything it had. No feed line yet ([Liveness.goneNotice]).
void applyGone(String token) {
final m = members[token];
if (m == null || m.presence == Presence.gone) return;
m
..awayBeforeGone = m.presence == Presence.away
..presence = Presence.gone
..presenceSince = now
..goneNoticed = false;
evaluate();
onGone?.call(m);
}
/// Members with no frame for [Liveness.silence] go `gone`. Returns
/// their tokens.
List<String> sweepSilent() {
final t = now;
final silent = [
for (final m in members.values)
if (m.presence != Presence.gone && t.difference(m.lastSeen) > liveness.silence) m.token,
];
for (final token in silent) {
applyGone(token);
}
return silent;
}
/// Joining timeouts, the delayed `gone` line, grace expiry (leave).
void sweepPresence() {
final t = now;
for (final m in members.values.toList()) {
final since = t.difference(m.presenceSince);
switch (m.presence) {
case Presence.joining:
if (since >= liveness.joiningTimeout) _becomeIn(m);
case Presence.gone:
if (since >= liveness.grace) {
applyLeave(m.token, reason: 'grace');
} else if (!m.goneNoticed && since >= liveness.goneNotice) {
m.goneNoticed = true;
feed.add(_line('gone', actor: m));
}
case Presence.watching:
case Presence.away:
break;
}
}
}
/// Joining is over: `in`. In the set if it is ready, or (not playing)
/// if it was in it; while playing an unready member waits for its own
/// `ready`, so coming in never pauses anyone.
void _becomeIn(EngineMember m) {
if (m.presence != Presence.joining) return;
m
..presence = Presence.watching
..presenceSince = now
..inSet = m.error.isEmpty && (m.ready || (transport != Transport.playing && m.inSet));
evaluate();
}
/// Name and/or picture change ([ProfileIntent]); null leaves it, an
/// empty [avatar] removes the picture. [name] is already cleaned.
void applyProfile(String token, {String? name, String? avatar}) {
@@ -262,9 +462,10 @@ class SyncEngine {
if (renamed) member.name = name;
final pictured = avatar != null && member.setAvatar(avatar);
if (renamed) {
feed.add(FeedMessage(actor: name, action: 'rename', target: oldName, at: now));
feed.add(FeedMessage(
actor: name, actorId: member.id, action: 'rename', target: oldName, targetId: member.id, at: now));
} else if (pictured) {
feed.add(FeedMessage(actor: member.name, action: 'avatar', at: now));
feed.add(_line('avatar', actor: member));
}
}
@@ -309,19 +510,14 @@ class SyncEngine {
if (banner != null) 'banner': banner!.hash,
};
/// A reconnect took over [token]'s member (see `hello.resume`).
void applyResume(String token) {
final member = members[token];
if (member == null) return;
_seen(member);
evaluate();
}
String? applyLeave(String token) {
/// [token]'s member leaves for good: `bye` ([reason] `bye`), or its
/// grace ran out (`grace`). Feed `leave`. Returns its id.
String? applyLeave(String token, {String reason = ''}) {
final removed = members.remove(token);
if (removed == null) return null;
feed.add(FeedMessage(actor: removed.name, action: 'leave', at: now));
feed.add(_line('leave', actor: removed, reason: reason));
evaluate();
onLeft?.call(removed);
return removed.id;
}
@@ -332,74 +528,80 @@ class SyncEngine {
return null;
}
/// `presence` from [actorToken] on [targetId] (empty: itself). `away`:
/// anyone may set anyone (the host too); `in`: only itself, through
/// `joining` while media is up (D2). Returns [ErrCode.forbidden] when
/// someone else is set `in`, else null (no-ops and unknown targets
/// included).
ErrCode? applyPresence(String actorToken, String targetId, Presence state,
{PresenceReason reason = PresenceReason.manual}) {
final actor = members[actorToken];
if (actor == null) return null;
_seen(actor);
final target = targetId.isEmpty ? actor : byId(targetId);
if (target == null) return null;
final self = identical(actor, target);
final why = reason == PresenceReason.manual ? '' : reason.name;
if (state == Presence.away) {
if (target.presence == Presence.gone) {
if (target.awayBeforeGone) return null;
target.awayBeforeGone = true;
} else {
if (target.presence == Presence.away) return null;
target
..presence = Presence.away
..presenceSince = now;
}
feed.add(_line('away', actor: actor, target: self ? null : target, reason: why));
evaluate();
return null;
}
if (state != Presence.watching) return null;
if (!self) return ErrCode.forbidden;
if (target.presence != Presence.away) return null;
target
..presence = _arriving
..presenceSince = now
..inSet = target.error.isEmpty && (target.ready || transport != Transport.playing);
feed.add(_line('in', actor: actor, reason: why));
evaluate();
return null;
}
void applyReady(String token, bool ready, {String source = 'manual'}) {
final member = members[token];
if (member == null) return;
_seen(member);
// Sitting out: back first ([applyOut]), then ready.
if (member.out) return;
if (member.ready == ready && (member.inSet || member.error.isNotEmpty)) {
evaluate();
return;
}
member.ready = ready;
if (ready && member.error.isEmpty) member.inSet = true;
feed.add(FeedMessage(
actor: member.name,
action: ready ? 'ready' : 'unready',
reason: source,
at: now));
feed.add(_line(ready ? 'ready' : 'unready', actor: member, reason: source));
evaluate();
}
/// Ready (or unready) [target] (a member id) or `all`. Members `away`
/// are left alone.
void applyForce(String actorToken, String target, bool ready) {
final actor = members[actorToken];
final actorName = actor?.name ?? '?';
if (target == 'all') {
for (final member in members.values) {
if (member.out) continue;
if (member.presence == Presence.away) continue;
member.ready = ready;
if (ready && member.error.isEmpty) member.inSet = true;
}
} else {
final member = byId(target);
if (member == null || member.out) return;
if (member == null || member.presence == Presence.away) return;
member.ready = ready;
if (ready && member.error.isEmpty) member.inSet = true;
feed.add(FeedMessage(
actor: actorName,
action: ready ? 'force-ready' : 'force-unready',
target: member.name,
at: now));
feed.add(_line(ready ? 'force-ready' : 'force-unready', actor: actor, target: member));
evaluate();
return;
}
feed.add(FeedMessage(
actor: actorName,
action: ready ? 'force-play' : 'force-hold',
at: now));
evaluate();
}
/// Sits the member [targetId] out ([out] true; anyone may, the host
/// included), or brings [actorToken]'s own member back ([out] false;
/// only oneself). Out: not ready, not in the set, so it never blocks.
/// Back: in the set like a fresh join (unless playing), still unready:
/// its own `ready` follows once it has buffered.
void applyOut(String actorToken, String targetId, bool out) {
final actor = members[actorToken];
final member = byId(targetId);
if (actor == null || member == null) return;
_seen(actor);
final self = identical(actor, member);
if (!out && !self) return;
if (member.out == out) return;
member.out = out;
member.ready = false;
member.buffered = 0;
member.inSet = !out && member.error.isEmpty && transport != Transport.playing;
feed.add(FeedMessage(
actor: actor.name, action: out ? 'out' : 'back', target: self ? null : member.name, at: now));
feed.add(_line(ready ? 'force-play' : 'force-hold', actor: actor));
evaluate();
}
@@ -414,7 +616,7 @@ class SyncEngine {
_seekBy = member.id;
_seekSeq = seq;
_seekEventId = eventId;
feed.add(FeedMessage(actor: member.name, action: 'seek', at: now));
feed.add(_line('seek', actor: member));
// Seeking back from the end: held, and resumes once the gate passes.
if (transport == Transport.finished) {
transport = Transport.held;
@@ -423,11 +625,13 @@ class SyncEngine {
}
/// Returns false (no state change) for media with non-finite numbers.
/// [queueId]: the Up next item this swap puts up; it leaves the queue.
/// [queueId]: the Up next item this swap puts up; it leaves the queue
/// (any other swap drops the continuation item, [EngineQueue.auto]).
bool applySwap(String actorToken, HelloMedia next, {String? queueId}) {
if (!next.duration.isFinite || next.size < 0) return false;
final actor = members[actorToken];
queue.swapped(queueId);
advance = null;
mediaId++;
media = next;
swapBy = actorToken;
@@ -438,14 +642,13 @@ class SyncEngine {
eventId++;
transport = Transport.held;
// Ready flags stay; everyone here now waits for everyone else here now
// (a stale member rejoins the set with its next frame if still ready).
final t = now;
// (who counts is up to presence: `away` and `gone` never do).
for (final member in members.values) {
member.error = '';
member.inSet = !member.out && !isStale(member, t);
member.inSet = true;
member.buffered = 0;
}
feed.add(FeedMessage(actor: actor?.name ?? '?', action: 'swap', at: now));
feed.add(_line('swap', actor: actor));
return true;
}
@@ -460,59 +663,46 @@ class SyncEngine {
// A report for an earlier media (sent before the swap reached the
// client) says nothing about this one's buffer.
final earlier = mediaId != 0 && mediaId != this.mediaId;
member.buffered =
member.out || earlier || !buffered.isFinite || buffered < 0 ? 0 : buffered;
member.buffered = earlier || !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 = m.copyWith(duration: duration);
}
if (member.presence == Presence.joining) {
// Joining ends when it cannot open the media (it stays out of the
// gate by its error), or once it holds the media at the server's
// position with the buffer the gate needs: it comes in in sync.
final t = now;
if (member.error.isNotEmpty) {
_becomeIn(member);
} else if (!earlier &&
reportEventId >= eventId &&
(reportPosition - positionAt(t)).abs() <= RelayConstants.driftSeekThresholdSecs &&
member.buffered >= requiredBufferSecs(t)) {
_becomeIn(member);
}
}
if (reportEventId < eventId) return;
evaluate();
}
/// An error takes the member out of the set (the gate stops waiting for
/// it); clearing it puts it back like a join: in the set unless playing
/// (then from its next `ready`).
/// it); clearing it puts it back like a join: in the set unless playing,
/// then only if it is ready (the others hold for its buffer).
void _setError(EngineMember member, String error) {
if (member.error == error) return;
member.error = error;
if (error.isNotEmpty) {
member.inSet = false;
} else if (!member.out) {
member.inSet = transport != Transport.playing;
} else {
member.inSet = member.ready || transport != Transport.playing;
}
// Leaving the set may let the others start.
evaluate();
}
void sweepStale() {
var changed = false;
for (final member in members.values) {
if (isStale(member) && member.inSet) {
member.inSet = false;
changed = true;
feed.add(FeedMessage(actor: member.name, action: 'stale', reason: 'no signal', at: now));
}
}
if (changed) evaluate();
}
List<String> sweepDropped() {
final t = now;
final dropped = <String>[];
for (final member in members.values.toList()) {
if (t.difference(member.lastSeen) > dropAfter) {
members.remove(member.token);
dropped.add(member.token);
feed.add(FeedMessage(actor: member.name, action: 'leave', reason: 'silent too long', at: t));
}
}
if (dropped.isNotEmpty) evaluate();
return dropped;
}
void evaluate() {
final t = now;
if (transport == Transport.finished) return;
@@ -524,39 +714,65 @@ class SyncEngine {
serverTime = t;
eventId++;
transport = Transport.finished;
feed.add(FeedMessage(actor: 'server', action: 'finished', at: t));
feed.add(_line('finished'));
return;
}
final blocked = members.values
.where((m) => m.inSet)
.any((m) => !m.ready || m.buffered < need || isStale(m, t));
final blocked =
members.values.where((m) => countsForGate(m, t)).any((m) => !m.ready || m.buffered < need);
if (blocked) {
position = positionAt(t);
serverTime = t;
eventId++;
transport = Transport.held;
feed.add(FeedMessage(actor: 'server', action: 'pause', at: t));
feed.add(_line('pause'));
}
return;
}
final inSet = members.values.where((m) => m.inSet).toList();
final inSet = members.values.where((m) => countsForGate(m, t)).toList();
if (inSet.isEmpty) return;
final allGood = inSet.every(
(m) => m.ready && m.buffered >= need && !isStale(m, t));
if (allGood) {
if (inSet.every((m) => m.ready && m.buffered >= need)) {
serverTime = t;
eventId++;
transport = Transport.playing;
feed.add(FeedMessage(actor: 'server', action: 'resume', at: t));
feed.add(_line('resume'));
}
}
/// `state.hold` while HELD with media up: who the gate waits for and why
/// ([HoldReason]). [sourceGone]: the current media's relay source is
/// `gone` (a short buffer then reads [HoldReason.source]).
HoldInfo? holdInfo({bool sourceGone = false}) {
if (transport != Transport.held || media == null) return null;
final t = now;
final need = requiredBufferSecs(t);
final gate = members.values.where((m) => countsForGate(m, t)).toList();
if (gate.isEmpty) return const HoldInfo(reason: HoldReason.start);
final joining = gate.where((m) => m.presence == Presence.joining && (!m.ready || m.buffered < need)).toList();
if (joining.isNotEmpty) {
final until = joining.map((m) => m.presenceSince.add(liveness.joinerHold)).reduce((a, b) => a.isBefore(b) ? a : b);
return HoldInfo(reason: HoldReason.joining, members: [for (final m in joining) m.id], until: until);
}
final unready = gate.where((m) => !m.ready).toList();
if (unready.isNotEmpty) return HoldInfo(reason: HoldReason.ready, members: [for (final m in unready) m.id]);
final short = gate.where((m) => m.buffered < need).toList();
if (short.isNotEmpty) {
return HoldInfo(
reason: sourceGone ? HoldReason.source : HoldReason.buffer, members: [for (final m in short) m.id]);
}
return null;
}
/// [relay]: whether the server can relay the current Jellyfin media; the
/// engine does not know the server's credential.
/// [warm]: the server's warm relay for Up next's first item, shown as
/// that item's `media.relay`.
StateBroadcast broadcast({bool relay = false, bool relayNext = false, ({String id, MediaRelay relay})? warm}) {
/// that item's `media.relay`. [sourceGone]: see [holdInfo].
StateBroadcast broadcast(
{bool relay = false,
bool relayNext = false,
({String id, MediaRelay relay})? warm,
bool sourceGone = false}) {
final bySeek = _seekEventId == eventId;
final t = now;
return StateBroadcast(
eventId: eventId,
transport: transport.name,
@@ -570,7 +786,7 @@ class SyncEngine {
if (members[swapBy]?.id case final id?) 'swapBy': id,
if (artJson != null) 'art': artJson,
},
members: members.values.map((m) => m.view(stale: isStale(m))).toList(),
members: [for (final m in members.values) m.view(inGate: countsForGate(m, t))],
seekBy: bySeek ? _seekBy : null,
seekSeq: bySeek ? _seekSeq : null,
relay: relay,
@@ -580,6 +796,8 @@ class SyncEngine {
warm != null && q.id == warm.id ? q.copyWith(media: q.media.copyWith(relay: warm.relay)) : q,
],
queuePlay: queue.play,
hold: holdInfo(sourceGone: sourceGone),
advance: advance,
);
}
}