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

+39 -3
View File
@@ -595,6 +595,32 @@ How bytes move:
- Cleanup: the buffer is memory only. Any swap disposes it (its URLs then
404, the source is closed with 4001 and stops), and so does server
stop. A crash frees it with the process.
- Warm relay (`ServerHub.syncWarm`, run on every change; wire in
[PROTOCOL.md](PROTOCOL.md) "Relay"): a second, small `ActiveRelay` for
Up next's first item while it waits, keyed by its queue id, so viewers
preload it (see "Getting ready" under "Up next"). A server-fetched
link starts its fetch then; a link passed on or a file is fed by its
adder's app (a second channel, `/r/.../source?q=<queueId>`), only
while someone else is in the server and not for the host's own file;
a Jellyfin item someone present cannot play (and the server's login
does not cover) by the first member with an account that dials in.
Its budget is 32 MiB ahead and 8 MiB behind its viewers
(`relayWarmAheadBytes`, split by track size with the usual 8 MiB
floor) plus the pins: about what a preload player reads, so the
server holds at most that much on top of the current relay (there is
no global relay budget; each relay is bounded by its own). Like any
buffer it pulls nothing while nobody preloads. The swap that puts the
item up (`swap.queueId`, from its source, or anyone for a fetch, with
the same tracks) promotes it (`ServerHub.takeWarm`): the same buffer,
feed and source channel become the media's relay with the full
budget, `media.relay.queueId` names the item and the media keeps the
item's name, so its `/r/.../<queueId>/...` URLs (and the preloads of
them) stay valid. Any other swap of it, the item no longer first (a
removal, a reorder, Play now of another), its adder leaving, or server
stop drops it (its URLs 404, its source gets 4001); a restart starts
with none. A manual swap to something else keeps it (the item is
still first). A failed warm fetch is not promoted: the swap fetches
again and reports the reason.
- Source away: viewers keep playing from the buffer; a request waits up
to 60 s for missing bytes. The source reconnects with backoff (same
token after a resume); a different size (or piece count and roles)
@@ -917,10 +943,17 @@ every `state` and feed), the pure rules in `queue_logic.dart`, the UI in
"Jellyfin relay"). A link everyone opens is that link; the app that
puts it up also probes it (`AppYtDlp.probe` with `keep`: kept 35
minutes instead of 10, a refresh replaces it only once it is in; swaps
read it from there). A link passed on "through me" and a file only on
read it from there). A link passed on "through me" and a file on
their adder's app (the link, the path; not the host's own file, which
plays from `/m`). A server-fetched link and others' relayed items have
nothing to get ready (the relay starts with the swap; see ROADMAP). It
plays from `/m`), which also feeds the server's warm relay of it
(`QueueController._feedWarm`, `SessionCoordinator.feedWarm`; a
Jellyfin item's from the member who puts it up, with its resolved
stream, when the server asks for one); everyone else, and everyone
for a server-fetched link, preloads that warm relay's URL
(`SessionCoordinator.warmRelayUrl`) once `queue[0].media.relay.live`
says bytes can flow (see "Relay"). At the swap the adder's app skips
re-reading the link's sizes (its warm source has them) and that
source carries on as the media's. It
is resolved as soon as it is first there, again every 30 minutes, and
dropped when it stops being first.
**Preload**: once resolved, the stream is opened in a second, hidden
@@ -952,6 +985,9 @@ every `state` and feed), the pure rules in `queue_logic.dart`, the UI in
account holders and a `/j` guest): ⏭ to the server playing 59 to
86 ms for the next episode and 35 ms for a direct link, against about
1.5 s cold; at 300 ms and 12 Mbit/s 52 and 103 ms against about 2.9 s.
Relayed, from the warm relay (same setup, loopback server): a link
passed on 21 to 22 ms, a server-fetched link 35 to 37 ms, against
about 1.6 s cold.
`SwitchTrace` (env `SYNC_RELAY_TRACE_SWITCH=1` prints it) times each
phase of a media change: state, resolved, open, player (a fresh
player is up; see "Player lifecycle" under "Media"), opened or
+7 -1
View File
@@ -56,7 +56,8 @@ and `e2e_check` (simulated viewers against a live server).
`seek_drag_e2e`, `end_of_file_e2e`, `readahead_e2e`, `jellyfin_e2e` (a
local fake Jellyfin over HTTP; tests never talk to a real Jellyfin),
`queue_switch_e2e` (times Up next switches against a delayed, rate-capped
fake Jellyfin and link host; `WT_SWITCH_PRELOAD=0` for the cold path,
fake Jellyfin and link host: an episode, a direct link, a link passed
on and a server-fetched one; `WT_SWITCH_PRELOAD=0` for the cold path,
`WT_SWITCH_DELAY_MS` / `WT_SWITCH_RATE_KBS` for the link). The sync and
end-of-file ones expect `~/Videos/chiyo.mp4`; others generate media with
ffmpeg into `WT_TEST_TMP`. Headless mpv (no video output attached) won't
@@ -77,6 +78,11 @@ Traps:
the `flutter_tester` by PID). `AppShell(videoBuilder:)` takes a stand-in,
so shell tests run the real player layout without it; otherwise test pure
logic (`StateDiff`, coordinator without widgets).
- A `SyncServer` started inside a test: relic reports an error writing a
response (a relay response cut short when its relay is dropped at a
swap or stop) to the zone the server started in, which fails the
test; outside tests it is only logged. Start it in `runZonedGuarded`
and fail on anything else (`queue_switch_e2e`).
- Never depend on a server-side WebSocket `onDone`; assert presence with
deadline polling.
- `sub.asFuture()` can resolve before `onDone`; poll with a deadline.
+38 -14
View File
@@ -1,4 +1,4 @@
# Protocol (proto = 13)
# Protocol (proto = 14)
Wire format only. Behavior (gate, clock, timeouts) is in
[ARCHITECTURE.md](ARCHITECTURE.md). Source of truth:
@@ -31,7 +31,7 @@ with different `proto` refuse each other at `hello` (`proto mismatch`).
## Client to server
```
{"t":"hello","proto":13,"secret":"","user":{"name":"","avatar":""},
{"t":"hello","proto":14,"secret":"","user":{"name":"","avatar":""},
"media":{"name":"","size":0,"duration":0,"url":null},"hostKey":"...",
"resume":"<token>"}
{"t":"ready","ready":true,"source":"manual"} source: manual | auto
@@ -275,6 +275,9 @@ swap media.relay = {} the swapper's machine serves the bytes
state media.relay = {"source":"m2","live":true} or {"fetch":true,"live":true}
media.relay = {"fetch":true,"live":true,"tracks":[{"kind":"video"},{"kind":"audio"}]}
media.relay = {"source":"m2","live":true,"rate":4100000,"need":2300000}
media.relay = {"source":"m2","live":true,"queueId":"q3"} started in Up next (proto 14)
queue[0].media.relay = {"source":"m2","live":true,"tracks":[...]} its warm relay
queue[0].media.relay = {"fetch":true,"live":true}
```
Tracks: a relayed media is one file, or tracks that viewers play as one
@@ -313,37 +316,57 @@ anything else means one file.
its segments in so far, after a minute of them). Both two significant
digits, updated once a second. `rate` under `need` means the source
cannot keep up; clients tell the source once per media.
- Warm relay (proto 14): while Up next's first item is relayed (`how`
`server`, `through` or `file`, or `jellyfin` when someone present has
no access to that server and `state.relayNext` is false), the server
holds a small relay for it and shows it as that item's
`media.relay`: `source` (the adder; for `jellyfin` empty until the
first member with `jf` true for that server dials in), or `fetch`, and
`live` with `tracks` once bytes can flow. Its URLs use the item's
queue id where a media id would be. Not for the host's own file, nor
for an adder alone in the server. A swap with `queueId` of that item,
from its source (or, `fetch`, from anyone) with the same `tracks`,
makes it the media's relay: `media.relay.queueId` is the item's id,
`media.name` the item's `media.name`, the buffer and source carry on
and the queue-id URLs keep working. Any other swap of the item, or the
item no longer first, drops it (its URLs 404, its source is closed
with 4001).
- `welcome.mediaUrl` for relayed media without `jellyfin`: this member's
`/r/` URL; absent for the source itself (it plays its own copy) and for
a fetched link the server has not read yet (clients open it on `live`).
Clients build it after a swap like the `/m` one:
`http://<host>/r/default/<token>/<mediaId>/<name>` (name with `/` and
`\` made `_`, then URL-encoded; the server ignores it). With `tracks`,
each track's URL is `http://<host>/r/default/<token>/<mediaId>/<i>/<name>`
`http://<host>/r/default/<token>/<key>/<name>` (`<key>` the
`mediaId`, or `media.relay.queueId` when set; name with `/` and `\`
made `_`, then URL-encoded; the server ignores it). With `tracks`,
each track's URL is `http://<host>/r/default/<token>/<key>/<i>/<name>`
(a pieces track: `<name>.m3u8`); one track is opened as is, several as
an mpv EDL of them all, each framed by its byte length:
`edl://!no_clip;%<len>%<track 0 url>;!new_stream;!no_clip;%<len>%<track 1 url>`.
- `GET /r/<sid>/<token>/<mediaId>/<name>`: the media (one file), Range
table as for `/m`. `GET /r/<sid>/<token>/<mediaId>/<i>/<name>`: track
- `GET /r/<sid>/<token>/<key>/<name>`: the media (one file), Range
table as for `/m`; `<key>` a `mediaId`, or a queue id (the warm relay,
or the media's relay started from it). `GET /r/<sid>/<token>/<key>/<i>/<name>`: track
`i` (from 0) of a media with `tracks` (track 0 of a one-file media
too); for a pieces track (any name), its media playlist
(`application/vnd.apple.mpegurl`): `#EXT-X-PLAYLIST-TYPE:VOD`,
`EXT-X-MEDIA-SEQUENCE`, `EXTINF` durations, `EXT-X-DISCONTINUITY`,
`EXT-X-MAP` and `EXT-X-KEY:METHOD=AES-128` (with `IV` when given) as
upstream, `EXT-X-ENDLIST`; every URI is `p/<j>`. `GET
/r/<sid>/<token>/<mediaId>/<i>/p/<j>`: piece `j` of pieces track `i`,
/r/<sid>/<token>/<key>/<i>/p/<j>`: piece `j` of pieces track `i`,
whole, once it is in (a Range inside the piece answers 206). Errors: 401
bad token, 404 not the current relayed media (a player still on an
older `mediaId` gets 404, never the new bytes), no such track (the
bad token, 404 not the current relayed media nor the warm one (a player
still on an older `mediaId` gets 404, never the new bytes), no such track (the
one-file URL of a media with `tracks`) or piece (or `p/` of a byte
track), 410 unknown session, 503 + `retry-after` the source never
connected or the server could not read the link (60 s), or a piece did
not arrive within 60 s. A byte request whose bytes do not arrive within
60 s ends early. Bytes count toward `bytesServed`.
- `GET /r/<sid>/<token>/source` (WebSocket): the source's relay channel,
dialled by the source (works behind NAT). Only the current source's
token is accepted; anyone else, or the source after the next swap, is
closed with code 4001 (the source stops instead of reconnecting).
dialled by the source (works behind NAT); `?q=<queueId>` for the warm
relay of that item (or the media's relay started from it). Only that
relay's source token is accepted; anyone else, or the source once its
relay is dropped, is closed with code 4001 (the source stops instead
of reconnecting). A warm relay's tracks are those of its source's
first `info`.
Frames:
```
@@ -442,4 +465,5 @@ state "queue":[<item>,...], "queuePlay":"q3"
leave out `url`: the server uses the one it kept. A swap without
`queueId` leaves the queue as it is.
- `state.queue` (omitted when empty) and `state.queuePlay` (omitted when
none) ride in every state, `welcome.state` included.
none) ride in every state, `welcome.state` included. The first item's
`media.relay` shows its warm relay (see "Relay").
+14 -22
View File
@@ -56,25 +56,19 @@ off by deleting it in the same commit as the code. Wire changes also edit
over instead of the buffer running dry.
- "Bob can't open this link — relay it?": offer the relay to whoever put
a direct link up when a viewer's player fails to open it.
- Preload relayed Up next items (see ARCHITECTURE "Getting ready"):
today a link passed on "through me", another member's file, a
server-fetched link and the host's own file are opened cold by
everyone but their source when they go up (their relay or `/m`
starts with the swap), so the gate waits for the slowest open. Plan
(wire change): the server holds a second, small relay for Up next's
first item, keyed by its queue id. Its adder's app dials a second
source for it as soon as it is first (`/r/.../source` naming the
queue id); a server-fetched item starts its fetch then;
`state.queue[0].relay` (`live`, `tracks`) tells viewers when and
how to open `/r/<sid>/<token>/q/<queueId>/<name>` in their preload
player. The buffer keeps only the head and tail pins plus about
32 MiB ahead of the viewers until the swap, pulls nothing while
nobody preloads, and is dropped when the item stops being first. A
swap with `queueId` turns it into the media's relay (same buffer;
the `q/` URL keeps working for that media, so the preloads match
and are promoted). The host's file: the hosting app hands the
server the first item's path early and `/m` serves it under a
`next/` name until the swap.
- The host's own file as Up next's first item: it plays from `/m`
once up, so others open it cold (the warm relay covers every other
relayed item, see ARCHITECTURE "Relay"). The hosting app could hand
the server the item's path early and `/m` serve it under a `next/`
name until the swap.
- Research (before building anything): can stream URLs a member's
app resolved with yt-dlp (its cookies, its address) be fetched by
the server from another address? Per site: YouTube (its
`googlevideo` URLs carry the resolving IP), Bilibili, X, generic
HLS/DASH, others; and YouTube resolved by the server itself from a
VPS, with and without cookies. Decides whether a server-driven
fetch of member-resolved links (the member need not upload or stay)
is worth building.
8. **Jellyfin** (epic, below).
@@ -98,9 +92,7 @@ off by deleting it in the same commit as the code. Wire changes also edit
- HTTP: move the app's clients (Jellyfin, AniList, AnimeFlow, updater)
to dio from `fox_utils` when touching them, for retries/interceptors.
- TLS: document a reverse proxy in front of the server.
- Up next follow-ups (see ARCHITECTURE "Up next"): getting the first
item ready only resolves its link; fetching its first seconds early
through the relay would make relayed starts instant too. A member
- Up next follow-ups (see ARCHITECTURE "Up next"): a member
whose reconnect lands as a new member loses its "through me" links
and files in the queue (skipped as gone); the key could follow the
member. A whole season or a pick list at once ("Add the rest of the