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,
);
}