Viewers who cannot reach the media get it through the server: a file on
any member's computer, a link passed on by a member ("Through me") or
fetched by the server ("Server fetches it", password-protected servers
only), and Jellyfin items for guests without an account, sourced by
whoever put them up with their own sign-in (or a pasted link's key),
which never leaves their machine. Fixes guests on a dedicated server
without a Jellyfin login being blocked.
The source dials /r/.../source (works behind NAT) and answers pulls with
an adaptive in-flight window (2 x rate x min RTT, 256 KiB..8 MiB). The
server keeps a memory-only buffer of 1 MiB slices sized like a viewer's
mpv buffer (pinned head/tail, 64 MiB back, 256 MiB ahead), fills it with
one sequential pull at a time (blocked viewers first, newest request
only), fetches nothing while nobody watches through it, and drops it on
the next swap or stop. Measured with mpv through shaped links
(tool/relay_e2e.dart): 400 Mbit/s seeks ~0.03 s; 40 Mbit/s no stalls,
3 viewers cost the source ~1x the bytes.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
11 KiB
Protocol (proto = 8)
Wire format only. Behavior (gate, clock, timeouts) is in
ARCHITECTURE.md. Source of truth:
packages/core/lib/src/protocol.dart and packages/server/lib/src/server_hub.dart.
Any wire change bumps RelayConstants.protoVersion and edits this file in
the same commit. Changes are additive where possible; a client and server
with different proto refuse each other at hello (proto mismatch).
Transport
- WebSocket at
/sync, JSON text frames, each an object with atfield. - The first client frame must be
hellowithin 10 s. Server pings every 5 s. - Match incoming frames by
t, never by order:welcomecan racestate. - Failures: server sends
{"t":"err","message":...}and closes. Messages:bad hello: ...,proto mismatch: got X, want Y,wrong secret,rejected: <reason>(e.g. avatar too large),bad message: ...(a frame with a non-finite number, e.g. NaN position),bad swap media,swap needs a url or the hosted file(empty swap). GET /healthreturns JSON (ok,name,proto,viewers,bytesServed,uptimeSecs; unauthenticated).GET /m/<sid>/<token>/<name>serves the host's file (Range table in ARCHITECTURE.md).GET /j/<sid>/<token>/<itemId>relays the current Jellyfin media (see "Jellyfin media" below)./r/<sid>/<token>/...is the relay of a member's media (see "Relay" below).
Client to server
{"t":"hello","proto":7,"secret":"","user":{"name":"","avatar":""},
"media":{"name":"","size":0,"duration":0,"url":null},"hostKey":"...",
"resume":"<token>"}
{"t":"ready","ready":true,"source":"manual"} source: manual | auto
{"t":"force","target":"all"|"<memberId>","ready":true}
{"t":"seek","position":123.4,"seq":3}
{"t":"swap","media":{"name":"","size":0,"duration":0,"url":"..."}}
{"t":"swap","media":{"name":"","size":0,"duration":0,"jellyfin":{<ref>}}}
{"t":"swap","media":{"name":"","size":123,"duration":0,"url":"...","relay":{}}}
{"t":"report","position":123.1,"buffered":6.2,"eventId":41,
"duration":1440.5,"mediaId":2,"jf":{"<serverId>":true}}
{"t":"ping","c":1767225600000}
{"t":"chat","text":"..."}
{"t":"kick","target":"<memberId>","reason":""}
Field notes:
hello.secret: the server password; a server with an empty secret is open.hello.mediais carried but not used by the server today.hello.hostKey(optional, omitted when empty): the hosting app generates a random key per hosting, gives it to its server and sends it from its own client. The member whose key equals the server's is the host and cannot be kicked. A server without a key (the dedicated server) has no host. Where the connection comes from does not matter.hello.resume(optional, omitted when empty): the token from this client's previouswelcome, sent on reconnect. If the server still has that member and it is not kicked, the connection takes it over:welcomereturns the sametokenandmemberId, the member keeps its flags, and there is nojoin/leavefeed. Otherwise it is ignored and a new member joins.seek.seq(optional, default 0): increasing per client; echoed instate.seekSeqso the sender recognises its own seek.report.duration/report.mediaId(optional, omitted when 0): the open media's length in seconds and themediaIdit belongs to. The server takes the first one whosemediaIdis current while its media duration is 0.report.jf(optional): Jellyfin access perServerId,true= this client plays the item with its own account,false= through the relay. Sticky: a report withoutjfkeeps the last one. At most 8 entries, keys up to 64 characters; others are dropped.ping.c: the client's clock in epoch ms; answered withpong.user.avatar: base64 image, at most 256 KiB of characters.nameis trimmed, empty becomes?, cut to 32 characters.chat.textis cut to 500 characters.swap.media.urlis the link;name,size,durationare informational.swap.media.jellyfinmakes it Jellyfin media (see below); thenurlis dropped.swap.media.relaymakes it relayed media (see "Relay").- Frames before
welcome(other thanhello) are ignored; unparsable or unknown frames are ignored silently.
Server to client
{"t":"welcome","token":"...","memberId":"m1","mediaUrl":"...","relayUrl":"...",
"state":{<state>}}
{"t":"state","eventId":7,"transport":"playing"|"held"|"finished","position":12.3,
"serverTime":"<ISO-8601 UTC>","mediaId":1,
"media":{name,size,duration,url?,jellyfin?}|absent,
"members":[{"id","name","avatar","ready","inSet","buffered","stale","jf"?}],
"seekBy":"m1","seekSeq":3,"relay":true}
{"t":"pong","c":1767225600000,"s":1767225600012}
{"t":"feed","actor":"","action":"","target":"","reason":"","at":"<ISO-8601>"}
{"t":"chat","text":"...","from":"<name>"}
{"t":"kicked","reason":""}
{"t":"err","message":""}
welcome.tokenis private to its owner and appears only in that member's media URL.mediaUrlis omitted when there is no media; it is a built/m/...URL for a host file (host part = what the client dialled), otherwise the shared link.welcome.statehas the same shape asstate.relayUrlis present only whenstate.relayis: this member's/j/...URL for the current Jellyfin media (private liketoken).stateis sent every second and on every change.positionis the value atserverTime(server clock); clients extrapolate whiletransportisplaying, on the server's clock (seepong).finished: the media reached its end (position= duration); it stays until a seek or swap.eventIdincreases on seek, swap and transport changes.mediaIdincreases on swap.media.relayis present for relayed media (see "Relay").media.durationis 0 until a client reports it.seekBy(member id) andseekSeqare present only while the currenteventIdcame from a seek.relay(omitted when false): the server can relay the current Jellyfin media.members[].jfis the member's lastreport.jf, omitted when it never sent one.pong.cechoes the ping'sc;pong.sis the server's clock in epoch ms (the clockserverTimeuses). Witht3the local receive time, the offset iss - (c + t3) / 2, accurate to half the round trip.feed.actionvalues:ready,unready,force-ready,force-unready,force-play,force-hold,kick,join,leave,stale,swap,seek,pause,resume,finished,chat.targetandreasonare optional;reasoncarriesauto/manualfor ready andsilent too longorno signalfor leave/stale.- Chat is delivered twice: a
chatframe with the text and a textlessfeedwithaction:"chat". Render thechatframe only; the reference client drops the textless feed entry.
Jellyfin media
<ref> = {"serverId":"<Jellyfin ServerId>","itemId":"<32 hex>",
"mediaSourceId":"...","title":"Pilot","subtitle":"Show - S01E01",
"runtimeSecs":1440.5}
serverId,itemIdrequired;mediaSourceId,subtitle,runtimeSecsoptional (omitted when absent);titlealways sent (may be empty).runtimeSecsis informational: the sync clock still uses the duration a client reports.- The server keeps only the ref: for Jellyfin media no URL or token is stored
or broadcast (a
urlsent along is dropped).serverId,itemIdandmediaSourceIdmust match[A-Za-z0-9-]{1,64}, otherwisebad swap media.media.nameis the swap's name, else the ref'stitle; names are sanitized like link names. - Each client resolves the ref with its own account on that server. A client
without one plays the relay URL when
state.relayis true:welcome.relayUrl, or after a swap built like the/mone from the host it dialled:http://<host>/j/default/<token>/<itemId>. GET /j/<sid>/<token>/<itemId>: the server fetches/Videos/<itemId>/stream?static=true&mediaSourceId=<id>(the original file) with its own credential and streams it through.Rangeis forwarded as sent; status,Content-Range,Content-Length,Content-TypeandAccept-Rangescome back as Jellyfin sent them. Errors: 401 bad token, 403 kicked or not the current item, 404 no relay for the current media, 410 unknown session, 502 Jellyfin unreachable or rejecting the server's credential. Bytes count towardbytesServed.- Pasted Jellyfin links sent as plain
urlstay plain links.
Relay
Media whose bytes go through the server's buffer: a file on a member's computer, a link passed on by a member, a link the server fetches itself, or a Jellyfin item for viewers without an account. Behavior (buffer, flow control, cases) is in ARCHITECTURE.md, "Relay".
swap media.relay = {} the swapper's machine serves the bytes
media.relay = {"fetch":true} the server reads media.url itself
state media.relay = {"source":"m2","live":true} or {"fetch":true,"live":true}
swap:relaywithjellyfinkeeps the ref (the swapper serves it to viewers without an account;fetchis ignored there).relaywithoutjellyfin: no URL is stored or broadcast;fetchneeds an http(s)url(elserelay fetch needs an http(s) url), which the server keeps to itself, and a server with a password (elseserver fetch needs a server password: an open server would fetch any address it can reach for anyone). Any later swap ends the relay.state.media.relay.source: member id of the source (absent withfetch).live(omitted when false): bytes can flow (the source's channel is connected, or the server could read the link).welcome.mediaUrlfor relayed media withoutjellyfin: this member's/r/URL; absent for the source itself (it plays its own copy). Clients build it after a swap like the/mone:http://<host>/r/default/<token>/<mediaId>/<name>(name URL-encoded).GET /r/<sid>/<token>/<mediaId>/<name>: the media, Range table as for/m. Errors: 401 bad token, 403 kicked, 404 not the current relayed media (a player still on an oldermediaIdgets 404, never the new bytes), 410 unknown session, 503 +retry-afterthe source never connected (60 s). A request whose bytes do not arrive within 60 s ends early. Bytes count towardbytesServed.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). Frames:
source -> server {"t":"info","size":<bytes>} first frame
binary: [u32 big-endian pull id][bytes] in order per pull
{"t":"end","id":1} {"t":"fail","id":1,"reason":"..."}
server -> source {"t":"pull","id":1,"start":0,"end":1048575} inclusive
{"t":"ack","id":1,"n":65536} bytes arrived
{"t":"cancel","id":1} stop that pull
A size different from the one before (a reconnect) drops the buffer.
Join and reconnect
hello then welcome, then the app opens mediaUrl, seeks to the server
position and buffers. A joining member is ready=false, inSet=false until
the first ready. A socket the server sees close loses its token; reconnect
is a fresh hello (with resume = the old token), after which the client
re-sends its prior ready flag. If the old member was still there (unclean
drop), resume takes it over and the token and media URL stay the same.
Reserved, not implemented
Server-held queue (queue-add, queue-skip) would auto-advance like a swap.