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:
1 parent
e52a7633a0
commit
d8585fb78e
17 files changed
+821
-151
No files matched your search
+39
-3
@@ -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
@@ -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
@@ -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
@@ -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
|
||||
|
||||
Reference in new issue
Block a user