Protocol 14: a warm relay preloads relayed Up next items for everyone

The server keeps one small relay (32 MiB ahead, 8 behind) for Up next's
first relayed item, keyed by its queue id at a URL that stays the same
after the swap: fetched by the server for server-fetch links, fed by
the adder's app for through-me links and files, by a member with an
account for member-passed Jellyfin items. Every app preloads from it;
the swap promotes it with its buffer. Dropped when the item stops
being first or its adder leaves. Measured switch: ~1.6 s cold, 20-40 ms
preloaded. ROADMAP gains the research item on server-side fetching of
member-resolved URLs.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
foxyzandClaude Opus 5.5 committed 2026-10-11 17:53:29 +03:00
1 parent e52a7633a0
commit d8585fb78e
17 files changed
+821 -151

No files matched your search

+7 -1
View File
@@ -1,5 +1,5 @@
abstract class RelayConstants {
static const int protoVersion = 13;
static const int protoVersion = 14;
// Relay (`/r/...`): a member's bytes through the server's buffer.
static const int relaySliceBytes = 1 << 20;
@@ -24,6 +24,12 @@ abstract class RelayConstants {
/// A viewer request waits this long for its bytes (source away) before
/// the server gives up on it.
static const int relayWaitSecs = 60;
/// The warm relay (Up next's first relayed item, before it goes up)
/// keeps this much ahead of and behind its viewers' preloads, besides
/// the pins: about what a preload player reads ahead (48 MiB), never the
/// whole media.
static const int relayWarmAheadBytes = 32 << 20;
static const int relayWarmBackBytes = 8 << 20;
static const int seekDebounceMs = 300;
static const double driftSeekThresholdSecs = 5.0;
+17 -4
View File
@@ -182,7 +182,10 @@ class RelayTrackSpec {
/// them, the server fills them in for [fetch] once it has read the link.
/// [rate] and [need] are the server's measures, 0 while unknown: bytes per
/// second the source (or the server's own fetch) delivers while the
/// buffer asks for bytes, and the media's average bitrate.
/// 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.
class MediaRelay {
final String source;
final bool fetch;
@@ -190,8 +193,15 @@ class MediaRelay {
final List<RelayTrackSpec> tracks;
final int rate;
final int need;
final String queueId;
const MediaRelay(
{this.source = '', this.fetch = false, this.live = false, this.tracks = const [], this.rate = 0, this.need = 0});
{this.source = '',
this.fetch = false,
this.live = false,
this.tracks = const [],
this.rate = 0,
this.need = 0,
this.queueId = ''});
/// The source is measured slower than the media plays (both known):
/// below 85 % of [need], so a rate close to it does not flicker.
@@ -210,7 +220,8 @@ class MediaRelay {
live: live ?? this.live,
tracks: tracks ?? this.tracks,
rate: rate ?? this.rate,
need: need ?? this.need);
need: need ?? this.need,
queueId: queueId);
Map<String, Object?> toJson() => {
if (source.isNotEmpty) 'source': source,
@@ -219,6 +230,7 @@ class MediaRelay {
if (validTracks(tracks)) 'tracks': [for (final t in tracks) t.toJson()],
if (rate > 0) 'rate': rate,
if (need > 0) 'need': need,
if (queueId.isNotEmpty) 'queueId': queueId,
};
/// Unknown kinds, a single byte track or too many make it one file.
@@ -232,7 +244,8 @@ class MediaRelay {
live: json['live'] == true,
tracks: ok ? List.unmodifiable(specs.cast<RelayTrackSpec>()) : const [],
rate: _byteRate(json['rate']),
need: _byteRate(json['need']));
need: _byteRate(json['need']),
queueId: json['queueId'] is String ? json['queueId'] as String : '');
}
/// A bytes-per-second field: a finite non-negative number, else 0.
+3
View File
@@ -348,6 +348,9 @@ class RelaySource {
var _tracks = const <RelayTrack>[];
/// The media's tracks once [start] has opened it (empty before).
List<RelayTrack> get tracks => _tracks;
/// Opens the media (a page is resolved) and starts serving; throws
/// when it cannot be read.
Future<void> start() async {
+7 -2
View File
@@ -535,7 +535,9 @@ class SyncEngine {
/// [relay]: whether the server can relay the current Jellyfin media; the
/// engine does not know the server's credential.
StateBroadcast broadcast({bool relay = false, bool relayNext = false}) {
/// [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}) {
final bySeek = _seekEventId == eventId;
return StateBroadcast(
eventId: eventId,
@@ -555,7 +557,10 @@ class SyncEngine {
seekSeq: bySeek ? _seekSeq : null,
relay: relay,
relayNext: relayNext,
queue: queue.items,
queue: [
for (final q in queue.items)
warm != null && q.id == warm.id ? q.copyWith(media: q.media.copyWith(relay: warm.relay)) : q,
],
queuePlay: queue.play,
);
}
+125 -48
View File
@@ -32,22 +32,39 @@ List<(int, int)> relayBudgets(List<int> sizes,
/// video, 160 kbit/s for audio.
double _piecesRate(String kind) => kind == 'audio' ? 20000 : 500000;
/// One track as the source (or the fetcher) described it: a byte size,
/// or a piece list.
typedef _TrackInfo = ({int? size, RelayPieceList? pieces});
/// One track as the source (or the fetcher) described it: its kind, a
/// byte size or a piece list.
typedef _TrackInfo = ({String kind, int? size, RelayPieceList? pieces});
/// The relay of the current media: one buffer per track and where the
/// bytes come from. One per relayed swap; [dispose] on the next swap or
/// server stop (the buffers are memory only, so that is the whole cleanup).
/// A relayed media on the server: one buffer per track and where the
/// bytes come from. The relay of the current media (one per relayed swap;
/// [dispose] on the next swap or server stop: the buffers are memory
/// only, so that is the whole cleanup), or the warm relay of Up next's
/// first item ([warm], keyed by [queueId]), which a swap of that item
/// [promote]s to the current one, buffer and all.
class ActiveRelay {
final int mediaId;
/// The media it relays; -1 while it is warm.
int mediaId;
final String name;
/// Token of the member whose machine serves the bytes; null when the
/// server fetches the link itself.
final String? sourceToken;
/// server fetches the link itself, or (a warm Jellyfin item) until a
/// member with an account dials in.
String? sourceToken;
/// That member's id (kept after it leaves); empty with [fetch].
String sourceId;
/// The server reads the link itself.
final bool fetch;
/// The Up next item it was started for (the warm relay); kept once
/// promoted, so the `/r/.../<queueId>/...` URLs keep working.
final String? queueId;
bool _warm;
final void Function() onLiveChanged;
/// The server's own fetch could not read the link (with the reason).
void Function(String reason)? onFail;
bool failed = false;
/// One per track, in track order ([RelayBuffer] for bytes, [PieceBuffer]
/// for pieces); empty until the server has read a fetched link.
/// for pieces); empty until the tracks are known (a fetched link read,
/// a warm source's first `info`).
List<TrackBuffer> buffers = const [];
/// `media.relay.tracks`: empty for one file.
List<RelayTrackSpec> tracks = const [];
@@ -57,11 +74,17 @@ class ActiveRelay {
var _local = const <LocalRelayFeed>[];
RelicWebSocket? _channel;
var _channelFeeds = const <ChannelRelayFeed>[];
List<_TrackInfo>? _infos;
/// Shared by the tracks: time any of them waits on the source.
final clock = FeedClock();
final _rate = SourceRate();
ActiveRelay._(this.mediaId, this.name, this.sourceToken, this.onLiveChanged);
ActiveRelay._(this.mediaId, this.name, this.sourceToken, this.onLiveChanged,
{this.sourceId = '', this.fetch = false, this.queueId, bool warm = false})
: _warm = warm;
/// Up next's first item, before it goes up.
bool get warm => _warm;
/// Takes the bytes the source sent so far, for [rate]; the server calls
/// it once a second.
@@ -71,6 +94,9 @@ class ActiveRelay {
/// (`media.relay.rate`); 0 until measured.
int get rate => _rate.rate;
/// Bytes the buffers hold now.
int get cachedBytes => buffers.fold(0, (a, b) => a + b.cachedBytes);
/// The media's average bytes per second for a [duration] in seconds
/// (`media.relay.need`): byte tracks by size, pieces tracks from the
/// pieces in so far; 0 while any track is unknown.
@@ -106,6 +132,12 @@ class ActiveRelay {
if (!_known.isCompleted) _known.complete();
}
/// The tracks a warm source described in its first `info`.
static List<RelayTrackSpec> _specsOf(List<_TrackInfo> infos) =>
infos.length == 1 && infos.single.pieces == null
? const []
: [for (final t in infos) RelayTrackSpec(t.kind.isEmpty ? 'video' : t.kind, pieces: t.pieces != null)];
/// The info of every track matches [tracks] (bytes for a byte track,
/// pieces for a pieces one).
bool _matches(List<_TrackInfo> infos) {
@@ -118,10 +150,8 @@ class ActiveRelay {
/// Attaches every buffer to its feed.
void _attachAll(List<RelayFeed> feeds, List<_TrackInfo> infos) {
_budget([
for (var i = 0; i < infos.length; i++)
infos[i].size ?? (_piecesRate(tracks[i].kind) * infos[i].pieces!.duration).round(),
]);
_infos = infos;
_budget();
for (var i = 0; i < buffers.length; i++) {
final b = buffers[i];
if (b is PieceBuffer) {
@@ -132,8 +162,18 @@ class ActiveRelay {
}
}
void _budget(List<int> sizes) {
final b = relayBudgets(sizes);
/// The tracks share the budget by size: a warm relay's small one
/// ([RelayConstants.relayWarmAheadBytes]), else the full one.
void _budget() {
final infos = _infos;
if (infos == null) return;
final sizes = [
for (var i = 0; i < infos.length; i++)
infos[i].size ?? (_piecesRate(infos[i].kind) * infos[i].pieces!.duration).round(),
];
final b = _warm
? relayBudgets(sizes, ahead: RelayConstants.relayWarmAheadBytes, back: RelayConstants.relayWarmBackBytes)
: relayBudgets(sizes);
for (var i = 0; i < buffers.length; i++) {
buffers[i]
..ahead = b[i].$1
@@ -144,22 +184,33 @@ class ActiveRelay {
/// Bytes from member [sourceToken] over its relay channel, as the
/// [tracks] its swap named (empty: one file).
factory ActiveRelay.fromMember(int mediaId, String name, String sourceToken,
{List<RelayTrackSpec> tracks = const [], required void Function() onLiveChanged}) =>
ActiveRelay._(mediaId, name, sourceToken, onLiveChanged).._setTracks(tracks);
{List<RelayTrackSpec> tracks = const [], String sourceId = '', required void Function() onLiveChanged}) =>
ActiveRelay._(mediaId, name, sourceToken, onLiveChanged, sourceId: sourceId).._setTracks(tracks);
/// The warm relay of Up next's item [queueId] fed by member
/// [sourceToken] (null: the first qualified member to dial in); its
/// tracks are what the source's first `info` says.
factory ActiveRelay.warmFromMember(String queueId, String name, String? sourceToken,
{String sourceId = '', required void Function() onLiveChanged}) =>
ActiveRelay._(-1, name, sourceToken, onLiveChanged, sourceId: sourceId, queueId: queueId, warm: true);
/// The server reads [url] itself: a media file as is, a web page
/// through [ytDlp] (one file, video + audio tracks, or segmented
/// playlists). [onFail] gets the reason when it cannot.
static ActiveRelay fetch(int mediaId, String name, Uri url,
{YtDlp? ytDlp, required void Function() onLiveChanged, void Function(String reason)? onFail}) {
final r = ActiveRelay._(mediaId, name, null, onLiveChanged);
/// playlists). [onFail] gets the reason when it cannot. With [queueId]
/// it is that Up next item's warm relay.
static ActiveRelay fetchLink(int mediaId, String name, Uri url,
{YtDlp? ytDlp, String? queueId, required void Function() onLiveChanged, void Function(String reason)? onFail}) {
final r = ActiveRelay._(mediaId, name, null, onLiveChanged, fetch: true, queueId: queueId, warm: queueId != null)
..onFail = onFail;
final media = r._media = LinkRelayMedia(url, ytDlp: ytDlp);
unawaited(() async {
try {
final tracks = await media.open();
final infos = <_TrackInfo>[
for (final t in tracks)
t.pieces != null ? (size: null, pieces: await t.pieces!.list()) : (size: await t.bytes!.size(), pieces: null),
t.pieces != null
? (kind: t.kind, size: null, pieces: await t.pieces!.list())
: (kind: t.kind, size: await t.bytes!.size(), pieces: null),
];
if (r._disposed) return;
r._setTracks(relayTrackSpecs(tracks));
@@ -168,17 +219,34 @@ class ActiveRelay {
onLiveChanged();
} catch (e) {
_log.warning('relay fetch: cannot read the link: $e');
if (!r._disposed) onFail?.call('$e');
if (!r._disposed) {
r.failed = true;
r.onFail?.call('$e');
}
}
}());
return r;
}
/// A member relay whose source has not described its tracks yet takes
/// [tracks] from the swap that puts it up.
void ensureTracks(List<RelayTrackSpec> tracks) {
if (!fetch && !_known.isCompleted) _setTracks(tracks);
}
/// The warm relay becomes media [mediaId]'s: its buffer and feed carry
/// on, with the full budget.
void promote(int mediaId) {
this.mediaId = mediaId;
_warm = false;
_budget();
}
var _disposed = false;
bool get live => buffers.isNotEmpty && buffers.every((b) => b.live);
/// The tracks are known (a fetched link has been read).
bool get known => _known.isCompleted && !_disposed;
bool get known => _known.isCompleted && !_disposed && buffers.isNotEmpty;
/// Waits until the tracks are known, up to [RelayConstants.relayWaitSecs].
Future<bool> whenKnown() async {
@@ -214,6 +282,13 @@ class ActiveRelay {
if (ch != null) unawaited(_closeNotSource(ch));
}
/// The source dialled in with its tracks: a warm relay learns them
/// here; false when they are not this relay's.
bool _channelInfo(List<_TrackInfo> infos) {
if (!_known.isCompleted) _setTracks(_specsOf(infos));
return _matches(infos);
}
void _attachChannel(RelicWebSocket ws, List<_TrackInfo> infos) {
final old = _channel;
if (old != null && !identical(old, ws)) unawaited(old.tryClose());
@@ -243,19 +318,21 @@ class ActiveRelay {
/// malformed.
List<_TrackInfo>? _infoTracks(Map j) {
final size = j['size'], tracks = j['tracks'];
if (size is int) return size > 0 ? [(size: size, pieces: null)] : null;
if (size is int) return size > 0 ? [(kind: '', size: size, pieces: null)] : null;
if (tracks is! List || tracks.isEmpty || tracks.length > MediaRelay.maxTracks) return null;
final out = <_TrackInfo>[];
for (final t in tracks) {
if (t is! Map) return null;
final kind = t['kind'];
if (!MediaRelay.trackKinds.contains(kind)) return null;
if (t['as'] == 'pieces') {
final list = RelayPieceList.fromJson(t);
if (list == null) return null;
out.add((size: null, pieces: list));
out.add((kind: kind as String, size: null, pieces: list));
} else {
final n = t['size'];
if (n is! int || n <= 0) return null;
out.add((size: n, pieces: null));
out.add((kind: kind as String, size: n, pieces: null));
}
}
return out;
@@ -270,32 +347,32 @@ Future<void> _closeNotSource(RelicWebSocket ws) async {
}
/// `GET /r/<sid>/<token>/source` (WebSocket): the source member's relay
/// channel. `GET /r/<sid>/<token>/<mediaId>/<name>`: a viewer reads the
/// relayed file, Range as for `/m`; `.../<mediaId>/<track>/<name>` one
/// track of a media with tracks (a pieces track's media playlist), and
/// `.../<mediaId>/<track>/p/<i>` piece `i` of a pieces track. [relay]
/// gives the current relay.
/// channel; `?q=<queueId>` for the warm relay of that Up next item.
/// `GET /r/<sid>/<token>/<key>/<name>`: a viewer reads the relayed file,
/// Range as for `/m`; `.../<key>/<track>/<name>` one track of a media
/// with tracks (a pieces track's media playlist), and
/// `.../<key>/<track>/p/<i>` piece `i` of a pieces track. `<key>` is the
/// media id, or the queue id of a warm (or promoted) relay. [relayAt]
/// finds the relay of a key, [sourceOf] the relay a token may feed.
void mountRelayRoutes(
RelicApp app,
SessionMap sessions,
ActiveRelay? Function() relay, {
SessionMap sessions, {
required ActiveRelay? Function(String key) relayAt,
required ActiveRelay? Function(String token, String? queueId) sourceOf,
void Function(String token, MediaLog log)? onLog,
}) {
app.get('/r/:sid/:token/source', (final req) {
final sid = req.pathParameters.raw[#sid] ?? '';
final token = req.pathParameters.raw[#token] ?? '';
final queueId = req.url.queryParameters['q'];
final session = sessions.get(sid);
final r = relay();
final ok = session != null &&
session.gate(token) == MediaGate.ok &&
r != null &&
r.sourceToken == token;
final found = session != null && session.gate(token) == MediaGate.ok ? sourceOf(token, queueId) : null;
return WebSocketUpgrade((final ws) async {
if (!ok) {
final r = found;
if (r == null) {
await _closeNotSource(ws);
return;
}
final r = relay()!;
ws.pingInterval = Duration(seconds: RelayConstants.pingIntervalSecs);
var attached = false;
try {
@@ -313,7 +390,7 @@ void mountRelayRoutes(
case 'info':
if (attached) continue;
final infos = _infoTracks(j);
if (infos == null || !r._matches(infos)) {
if (infos == null || !r._channelInfo(infos)) {
// Not the media this swap named: the source stops.
_log.warning('relay channel: info does not match the media (${r.name})');
await _closeNotSource(ws);
@@ -359,7 +436,7 @@ void mountRelayRoutes(
final stopwatch = Stopwatch()..start();
final sid = req.pathParameters.raw[#sid] ?? '';
final token = req.pathParameters.raw[#token] ?? '';
final mediaId = int.tryParse(req.pathParameters.raw[#mediaId] ?? '');
final key = req.pathParameters.raw[#mediaId] ?? '';
final track = tracked ? int.tryParse(req.pathParameters.raw[#seg] ?? '') ?? -1 : null;
final session = sessions.get(sid);
if (session == null) return _plain(410);
@@ -369,9 +446,9 @@ void mountRelayRoutes(
default:
return _plain(401);
}
final r = relay();
// A player still on an older media must not get the new one's bytes.
if (r == null || r.mediaId != mediaId) return _plain(404);
final r = relayAt(key);
if (r == null) return _plain(404);
final notConnected = Response(503,
body: Body.fromString('relay source not connected'),
headers: Headers.build((h) => h['retry-after'] = ['2']));
@@ -421,7 +498,7 @@ void mountRelayRoutes(
token,
MediaLog(
method: req.method.name,
path: '/r/$sid/$shortToken/$mediaId/${track == null ? '' : '$track/'}${r.name}',
path: '/r/$sid/$shortToken/$key/${track == null ? '' : '$track/'}${r.name}',
rangeHeader: rangeHeader,
status: res.status,
bytesWritten: end - res.start + 1,
+206 -22
View File
@@ -61,7 +61,8 @@ class ServerHub {
: '$head: $why';
}
StateBroadcast stateBroadcast() => engine.broadcast(relay: relayAvailable, relayNext: relayNextAvailable);
StateBroadcast stateBroadcast() =>
engine.broadcast(relay: relayAvailable, relayNext: relayNextAvailable, warm: _warmView());
/// The relay of the current media (`/r/...`), if it is relayed.
ActiveRelay? relay;
@@ -69,19 +70,178 @@ class ServerHub {
/// Replaces the relay (null: the new media is not relayed). The old
/// buffer is dropped with it.
void setRelay(ActiveRelay? next) {
relay?.dispose();
if (!identical(relay, next)) relay?.dispose();
relay = next;
}
/// The warm relay: Up next's first item relayed before it goes up, so
/// every viewer preloads it (see ARCHITECTURE "Getting ready"); null
/// when that item is not relayed (or nobody would read it).
ActiveRelay? warm;
void _setWarm(ActiveRelay? next) {
if (!identical(warm, next)) warm?.dispose();
warm = next;
}
/// Drops both relays (server stop).
void dropRelays() {
setRelay(null);
_setWarm(null);
}
/// `queue[0].media.relay` while the warm relay exists.
({String id, MediaRelay relay})? _warmView() {
final w = warm, id = w?.queueId;
if (w == null || id == null) return null;
return (
id: id,
relay: MediaRelay(
source: w.fetch ? '' : w.sourceId, fetch: w.fetch, live: w.live, tracks: w.live ? w.tracks : const []),
);
}
/// Keeps [warm] on Up next's first item when it is relayed: a link the
/// server fetches (its fetch starts now), a link passed on or a file
/// (its adder's app feeds it, while someone else is here to read it;
/// not the host's own file), a
/// Jellyfin item that someone here cannot play and the server's login
/// does not cover (the first member with an account feeds it). Called
/// on every change; anything else drops it.
void syncWarm() {
final first = engine.queue.items.firstOrNull;
final w = warm;
if (w != null && (first == null || w.queueId != first.id || !_keepWarm(w, first))) _setWarm(null);
if (warm == null && first != null) _setWarm(_newWarm(first));
}
bool _othersHere(String? memberId) => engine.members.values.any((m) => m.id != memberId);
/// A Jellyfin [ref] someone here cannot play while someone can, outside
/// the server's login.
bool _jellyfinNeedsWarm(JellyfinRef ref) =>
!_onLogin(ref) &&
engine.members.values.any((m) => !m.out && m.jf[ref.serverId] != true) &&
engine.members.values.any((m) => m.jf[ref.serverId] == true);
bool _keepWarm(ActiveRelay w, QueueItem first) {
switch (first.how) {
case QueueHow.server:
return w.fetch;
case QueueHow.through:
case QueueHow.file:
final adder = engine.byId(first.by);
return adder != null && adder.token == w.sourceToken && _othersHere(adder.id);
case QueueHow.jellyfin:
final ref = first.media.jellyfin;
final src = w.sourceToken;
return ref != null && _jellyfinNeedsWarm(ref) && (src == null || engine.members.containsKey(src));
case QueueHow.direct:
return false;
}
}
ActiveRelay? _newWarm(QueueItem first) {
final id = first.id;
final name = sanitizeMediaName(first.media.name.isEmpty ? first.title : first.media.name);
late final ActiveRelay r;
switch (first.how) {
case QueueHow.server:
final url = Uri.tryParse(engine.queue.privateUrlOf(id) ?? '');
if (url == null || session.secret.isEmpty) return null;
r = ActiveRelay.fetchLink(-1, name, url, ytDlp: ytDlp, queueId: id, onLiveChanged: () => relayLiveChanged(r));
case QueueHow.through:
case QueueHow.file:
final adder = engine.byId(first.by);
// The host's own file plays from its `/m` URL, never relayed.
if (adder == null || !_othersHere(adder.id) || (adder.host && first.how == QueueHow.file)) return null;
r = ActiveRelay.warmFromMember(id, name, adder.token,
sourceId: adder.id, onLiveChanged: () => relayLiveChanged(r));
case QueueHow.jellyfin:
final ref = first.media.jellyfin;
if (ref == null || !_jellyfinNeedsWarm(ref)) return null;
r = ActiveRelay.warmFromMember(id, name, null, onLiveChanged: () => relayLiveChanged(r));
case QueueHow.direct:
return null;
}
return r;
}
/// The relay `/r/.../<key>/...` reads: the current one by its media id,
/// or by the queue id it was started for, or the warm one.
ActiveRelay? relayAt(String key) {
final r = relay, w = warm;
final mediaId = int.tryParse(key);
if (mediaId != null) return r != null && r.mediaId == mediaId ? r : null;
if (key.isEmpty) return null;
if (w != null && w.queueId == key) return w;
if (r != null && r.queueId == key) return r;
return null;
}
/// The relay member [token] may feed on its channel: the current one
/// ([queueId] null), or the one of Up next item [queueId] (warm or
/// already promoted). A warm Jellyfin item takes the first member with
/// an account on that server that dials in.
ActiveRelay? sourceOf(String token, String? queueId) {
if (queueId == null || queueId.isEmpty) {
final r = relay;
return r != null && r.sourceToken == token ? r : null;
}
final r = relayAt(queueId);
if (r == null || r.fetch) return null;
if (r.sourceToken == null && identical(r, warm)) {
final ref = engine.queue.item(queueId)?.media.jellyfin;
final m = engine.members[token];
if (ref == null || m == null || m.jf[ref.serverId] != true) return null;
r
..sourceToken = token
..sourceId = m.id;
broadcastState();
}
return r.sourceToken == token ? r : null;
}
/// A swap of Up next item [queueId] by [token]: its warm relay, when it
/// can become the media's (the same kind of relay; a member's from its
/// own source with the same tracks), else null. Taken either way: the
/// item goes up now.
ActiveRelay? takeWarm(String? queueId, String token, {required bool fetch, List<RelayTrackSpec> tracks = const []}) {
final w = warm;
if (queueId == null || w == null || w.queueId != queueId) return null;
warm = null;
final ok = fetch
? w.fetch && !w.failed
: !w.fetch && w.sourceToken == token && (!w.known || _sameTracks(w.tracks, tracks));
if (ok) {
w.ensureTracks(tracks);
return w;
}
w.dispose();
return null;
}
static bool _sameTracks(List<RelayTrackSpec> a, List<RelayTrackSpec> b) =>
a.length == b.length && [for (var i = 0; i < a.length; i++) a[i] == b[i]].every((x) => x);
/// `media.relay` for a relayed swap by [token]: who serves the bytes
/// and, from a member, its tracks (the server finds a fetched link's).
MediaRelay relayFor(String token, MediaRelay requested) => requested.fetch
? const MediaRelay(fetch: true)
: MediaRelay(source: engine.members[token]?.id ?? '', tracks: requested.tracks);
/// `media.relay` of a promoted warm relay [w] (Up next item [queueId]).
MediaRelay promotedRelay(ActiveRelay w, String queueId) => w.fetch
? MediaRelay(fetch: true, live: w.live, tracks: w.live ? w.tracks : const [], queueId: queueId)
: MediaRelay(source: w.sourceId, live: w.live, tracks: w.tracks, queueId: queueId);
/// The relay's channel came up or went down, or a fetched link was read:
/// `media.relay.live` and `tracks` follow.
/// `media.relay.live` and `tracks` follow (the warm one's in `queue`).
void relayLiveChanged(ActiveRelay r) {
if (identical(warm, r)) {
broadcastState();
return;
}
final m = engine.media;
if (!identical(relay, r) || m?.relay == null) return;
final was = m!.relay!;
@@ -192,6 +352,7 @@ class ServerHub {
}
void changed() {
syncWarm();
broadcastState();
broadcastFeed();
}
@@ -301,7 +462,8 @@ void mountSyncRoute(
String Function(String sid, String token, SessionMedia? media, String host)
urlBuilder, {
String Function(String sid, String token, String itemId, String host)? relayUrlBuilder,
String Function(String sid, String token, int mediaId, String name, List<RelayTrackSpec> tracks, String host)?
/// `key`: the media id, or the queue id of a relay started in Up next.
String Function(String sid, String token, String key, String name, List<RelayTrackSpec> tracks, String host)?
relayMediaUrlBuilder,
}) {
app.get('/sync', (final req) {
@@ -374,15 +536,22 @@ void mountSyncRoute(
// Relayed for viewers without access: from the swapper's
// machine (its own sign-in never leaves it).
final relayReq = parsed.relay == null || parsed.relay!.fetch ? null : parsed.relay;
// Up next's warm relay of this item carries on as its relay.
final promoted = relayReq == null || ref == null ? null : hub.takeWarm(queueId, token, fetch: false);
final media = ref == null
? null
: HelloMedia(
name: sanitizeMediaName(parsed.name.isEmpty ? ref.title : parsed.name),
name: promoted?.name ?? sanitizeMediaName(parsed.name.isEmpty ? ref.title : parsed.name),
size: parsed.size,
duration: parsed.duration,
jellyfin: ref,
relay: relayReq == null ? null : hub.relayFor(token, const MediaRelay()));
relay: relayReq == null
? null
: promoted != null
? hub.promotedRelay(promoted, queueId!)
: hub.relayFor(token, const MediaRelay()));
if (media == null || !hub.engine.applySwap(token, media, queueId: queueId)) {
promoted?.dispose();
wsErr(ws, 'bad swap media');
return;
}
@@ -392,7 +561,11 @@ void mountSyncRoute(
duration: media.duration,
jellyfin: ref,
mediaId: hub.engine.mediaId);
hub.setRelay(relayReq == null ? null : _memberRelay(hub, token, media.name, const []));
hub.setRelay(relayReq == null
? null
: promoted != null
? (promoted..promote(hub.engine.mediaId))
: _memberRelay(hub, token, media.name, const []));
hub.changed();
return;
}
@@ -415,27 +588,38 @@ void mountSyncRoute(
wsErr(ws, 'server fetch needs a server password');
return;
}
// Up next's warm relay of this item carries on as its relay
// (its buffer, its feed, its `/r/.../<queueId>/` URLs).
final promoted = hub.takeWarm(queueId, token, fetch: relayReq.fetch, tracks: relayReq.tracks);
final shown = promoted?.name ?? name;
final media = HelloMedia(
name: name,
name: shown,
size: parsed.size,
duration: parsed.duration,
relay: hub.relayFor(token, relayReq));
relay: promoted != null ? hub.promotedRelay(promoted, queueId!) : hub.relayFor(token, relayReq));
if (!hub.engine.applySwap(token, media, queueId: queueId)) {
promoted?.dispose();
wsErr(ws, 'bad swap media');
return;
}
session.media = SessionMedia(
name: name, size: parsed.size, duration: parsed.duration, mediaId: hub.engine.mediaId);
name: shown, size: parsed.size, duration: parsed.duration, mediaId: hub.engine.mediaId);
void failed(String why) {
wsErr(ws, hub.fetchFailText(why));
hub.fetchFailed(shown);
}
late final ActiveRelay active;
active = relayReq.fetch
? ActiveRelay.fetch(hub.engine.mediaId, name, link!,
ytDlp: hub.ytDlp,
onLiveChanged: () => hub.relayLiveChanged(active),
onFail: (why) {
wsErr(ws, hub.fetchFailText(why));
hub.fetchFailed(name);
})
: _memberRelay(hub, token, name, media.relay!.tracks);
if (promoted != null) {
active = promoted..promote(hub.engine.mediaId);
if (active.fetch) {
active.onFail = failed;
}
} else {
active = relayReq.fetch
? ActiveRelay.fetchLink(hub.engine.mediaId, name, link!,
ytDlp: hub.ytDlp, onLiveChanged: () => hub.relayLiveChanged(active), onFail: failed)
: _memberRelay(hub, token, name, media.relay!.tracks);
}
hub.setRelay(active);
hub.changed();
return;
@@ -626,8 +810,8 @@ void mountSyncRoute(
relayed.mediaId == media.mediaId &&
relayMediaUrlBuilder != null
? (relayed.known
? relayMediaUrlBuilder(
'default', activeToken, relayed.mediaId, relayed.name, relayed.tracks, dialledHost)
? relayMediaUrlBuilder('default', activeToken, relayed.queueId ?? '${relayed.mediaId}', media.name,
relayed.tracks, dialledHost)
: null)
: media?.file != null
? urlBuilder('default', activeToken, media, dialledHost)
@@ -656,6 +840,6 @@ void mountSyncRoute(
ActiveRelay _memberRelay(ServerHub hub, String token, String name, List<RelayTrackSpec> tracks) {
late final ActiveRelay r;
r = ActiveRelay.fromMember(hub.engine.mediaId, name, token,
tracks: tracks, onLiveChanged: () => hub.relayLiveChanged(r));
tracks: tracks, sourceId: hub.engine.members[token]?.id ?? '', onLiveChanged: () => hub.relayLiveChanged(r));
return r;
}
+4 -4
View File
@@ -94,7 +94,7 @@ class SyncServer {
);
});
mountSessionMediaRoute(_app, sessions);
mountRelayRoutes(_app, sessions, () => hub.relay);
mountRelayRoutes(_app, sessions, relayAt: hub.relayAt, sourceOf: hub.sourceOf);
mountAvatarRoute(_app, hub);
mountArtRoute(_app, hub);
mountJellyfinRelayRoute(
@@ -108,8 +108,8 @@ class SyncServer {
return 'http://${authority(dialledHost)}/m/$sid/$token/$name';
}, relayUrlBuilder: (sid, token, itemId, dialledHost) =>
'http://${authority(dialledHost)}/j/$sid/$token/${Uri.encodeComponent(itemId)}',
relayMediaUrlBuilder: (sid, token, mediaId, name, tracks, dialledHost) =>
relayViewerUrl('http://${authority(dialledHost)}/r/$sid/$token/$mediaId', name, tracks: tracks));
relayMediaUrlBuilder: (sid, token, key, name, tracks, dialledHost) =>
relayViewerUrl('http://${authority(dialledHost)}/r/$sid/$token/$key', name, tracks: tracks));
_app.fallback = (final req) => Response.notFound(
body: Body.fromString('not found'),
);
@@ -181,7 +181,7 @@ class SyncServer {
_heartbeat = null;
_relayClient?.close(force: true);
_relayClient = null;
hub.setRelay(null);
hub.dropRelays();
await hub.closeAllSockets();
await _app.close();
_server = null;
+155 -2
View File
@@ -92,9 +92,10 @@ void main() {
return m;
}
Future<RelaySource> startSource(_Member m, {RelayMedia? from}) async {
Future<RelaySource> startSource(_Member m, {RelayMedia? from, String? queueId}) async {
final src = RelaySource(
Uri.parse('ws://127.0.0.1:${server.boundPort}/r/default/${m.welcome.token}/source'),
Uri.parse('ws://127.0.0.1:${server.boundPort}/r/default/${m.welcome.token}/source'
'${queueId == null ? '' : '?q=$queueId'}'),
from ?? RelayMedia.single(FileRelayBytes(file)),
backoff: (_) => const Duration(milliseconds: 100));
await src.start();
@@ -444,6 +445,158 @@ void main() {
});
});
group('warm relay (Up next)', () {
late HttpServer origin;
var originBytes = 0;
setUp(() async {
originBytes = 0;
origin = await HttpServer.bind(InternetAddress.loopbackIPv4, 0);
origin.listen((req) async {
final r = resolveMediaRequest(rangeHeader: req.headers.value('range'), size: bytes.length, filename: 'x.mkv');
req.response.statusCode = r.status;
r.headers.forEach(req.response.headers.set);
if (r.status == 200 || r.status == 206) {
final end = r.end ?? bytes.length - 1;
originBytes += end - r.start + 1;
req.response.add(bytes.sublist(r.start, end + 1));
}
await req.response.close();
});
});
tearDown(() => origin.close(force: true));
List<Map> queueOf(_Member m) => [for (final q in (m.lastState?['queue'] as List?) ?? const []) q as Map];
Map? headRelay(_Member m) => (queueOf(m).firstOrNull?['media'] as Map?)?['relay'] as Map?;
Future<String> add(_Member m, Map<String, Object?> item, {bool next = false}) async {
final had = {for (final q in queueOf(m)) q['id']};
m.send({'t': 'queue', 'op': 'add', 'at': next ? 'next' : 'end', 'item': item});
await _until(() => queueOf(m).any((q) => !had.contains(q['id'])), 'queued');
return queueOf(m).firstWhere((q) => !had.contains(q['id']))['id'] as String;
}
Map<String, Object?> through(String key) => {
'media': {'name': 'ep 01.mkv', 'size': bytes.length, 'duration': 0},
'how': 'through',
'title': 'Ep 1',
'key': key,
};
String qUrl(_Member v, String queueId) =>
'http://127.0.0.1:${server.boundPort}/r/default/${v.welcome.token}/$queueId/${Uri.encodeComponent('ep 01.mkv')}';
test('a link the server fetches: preloaded from its queue id, promoted at the swap with its buffer', () async {
final alice = await join('alice');
final bob = await join('bob');
final id = await add(alice, {
'media': {'name': 'ep 01.mkv', 'size': 0, 'duration': 0, 'url': 'http://127.0.0.1:${origin.port}/x.mkv'},
'how': 'server',
'title': 'Ep 1',
});
await _until(() => headRelay(bob)?['live'] == true, 'warm relay live');
expect(headRelay(bob), {'fetch': true, 'live': true});
expect(jsonEncode(bob.lastState), isNot(contains('x.mkv')));
expect((await get(qUrl(bob, id))).$3, bytes);
// Small: the warm budget is far over this file, so all of it is in.
final fetched = originBytes;
alice.send({
't': 'swap',
'queueId': id,
'media': {'name': 'Ep 1', 'size': 0, 'duration': 0, 'relay': {'fetch': true}},
});
await _until(() => (bob.media?['relay'] as Map?)?['queueId'] == id, 'promoted');
expect(bob.media!['relay'], {'fetch': true, 'live': true, 'queueId': id});
expect(bob.media!['name'], 'ep 01.mkv', reason: 'the URL name the preload used');
expect(queueOf(bob), isEmpty);
expect(server.hub.warm, isNull);
// The same buffer: both URLs serve it, nothing is fetched again.
expect((await get(qUrl(bob, id))).$3, bytes);
expect((await get(viewerUrl(bob, mediaIdOf(bob)))).$3, bytes);
expect(originBytes, fetched);
final carol = await join('carol');
expect(carol.welcome.mediaUrl, qUrl(carol, id));
});
test('a link passed on: its adder feeds it before the swap, and the swap keeps that source', () async {
final alice = await join('alice');
final bob = await join('bob');
final id = await add(alice, through('k-abcdefgh'));
expect(headRelay(bob), {'source': alice.welcome.memberId});
final src = await startSource(alice, queueId: id);
await _until(() => headRelay(bob)?['live'] == true, 'warm relay live');
expect((await get(qUrl(bob, id), range: 'bytes=0-99')).$3, bytes.sublist(0, 100));
// Only its source may feed it.
final impostor = await startSource(bob, queueId: id);
await impostor.done.timeout(const Duration(seconds: 5));
alice.send({
't': 'swap',
'queueId': id,
'media': {'name': 'ep 01.mkv', 'size': bytes.length, 'duration': 0, 'relay': {}},
});
await _until(() => (bob.media?['relay'] as Map?)?['queueId'] == id, 'promoted');
expect(bob.media!['relay'], {'source': alice.welcome.memberId, 'live': true, 'queueId': id});
expect((await get(qUrl(bob, id))).$3, bytes);
expect(src.bytesSent, lessThan(bytes.length * 1.3));
// Still the source of the media it now relays.
await Future<void>.delayed(const Duration(milliseconds: 200));
expect(server.hub.relay!.live, isTrue);
});
test('dropped when the item stops being first: its URLs 404, its source stops', () async {
final alice = await join('alice');
final bob = await join('bob');
final id = await add(alice, through('k-abcdefgh'));
final src = await startSource(alice, queueId: id);
await _until(() => headRelay(bob)?['live'] == true, 'warm relay live');
await add(alice, through('k-ijklmnop'), next: true);
await src.done.timeout(const Duration(seconds: 5));
expect((await get(qUrl(bob, id))).$1, 404);
expect(server.hub.warm!.queueId, isNot(id));
});
test('not for an adder alone; dropped when its adder leaves', () async {
final alice = await join('alice');
await add(alice, through('k-abcdefgh'));
expect(headRelay(alice), isNull);
final bob = await join('bob');
await _until(() => headRelay(bob) != null, 'warm relay once someone could read it');
await alice.ws.close();
await _until(() => bob.lastState!['members'] is List && (bob.lastState!['members'] as List).length == 1, 'left');
expect(queueOf(bob), hasLength(1));
expect(headRelay(bob), isNull);
expect(server.hub.warm, isNull);
});
test('a Jellyfin item: fed by the first member with an account, for those without', () async {
final alice = await join('alice');
final bob = await join('bob');
void report(_Member m, bool access) => m.send({
't': 'report', 'position': 0, 'buffered': 0, 'eventId': 0, 'jf': {'srv1': access}, //
});
report(alice, true);
report(bob, false);
final id = await add(alice, {
'media': {
'name': 'Pilot',
'size': 0,
'duration': 0,
'jellyfin': {'serverId': 'srv1', 'itemId': '0123456789abcdef0123456789abcdef', 'title': 'Pilot'},
},
'how': 'jellyfin',
'title': 'Pilot',
});
await _until(() => headRelay(bob) != null, 'warm relay');
expect(headRelay(bob), isEmpty, reason: 'nobody feeds it yet');
final refused = await startSource(bob, queueId: id);
await refused.done.timeout(const Duration(seconds: 5));
await startSource(alice, queueId: id);
await _until(() => headRelay(bob)?['live'] == true, 'live');
expect(headRelay(bob)!['source'], alice.welcome.memberId);
report(bob, true);
await _until(() => headRelay(alice) == null, 'dropped once everyone has an account');
});
});
group('buffer', () {
// A feed that serves [bytes] in-process and counts what it sends.
late RelayBuffer buf;
+2 -2
View File
@@ -64,8 +64,8 @@ void main() {
});
group('core welcome + proto', () {
test('proto 12: avatars by hash, profile intent', () {
expect(RelayConstants.protoVersion, 13);
test('proto 14: avatars by hash, profile intent', () {
expect(RelayConstants.protoVersion, 14);
const v = MemberView(id: 'm1', name: 'a', ready: false, inSet: false, buffered: 0, stale: false);
expect(v.toJson().containsKey('avatarHash'), isFalse);
expect(v.toJson().containsKey('avatar'), isFalse);