forked from Foxyz/sync_replay_project
Restructure step 1: lasting identity, presence and protocol 16
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:
1 parent
51c1c5ab78
commit
9f2586b7ff
85 files changed
+5198
-1902
No files matched your search
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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');
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user