- One server per app run (loopback, no password); Invite attaches an all-interfaces listener to the same server and Stop sharing detaches it (guests told with 4016); members, media and Up next never reset. No solo mode: you're home on your own server unless in a friend's. - Navigation: none / yours / friend; a join passes a version gate on /health first (clear "they/you need to update", update offer). - Invite as a value (host, port, name, password flag, version, fp reserved); addresses filtered (loopback, link-local, 198.18/15, bridges) and ranked; Sharing chip, Copy invite, Stop sharing. - Alone looks like a plain player (alone replaces solo). - AppGraph builds and disposes systems; widgets use AppScope; SessionState deleted; a test caps the remaining singletons. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
42 KiB
Protocol (proto = 16)
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. Versions are ranges: a server accepts
[protoMin, protoMax] (RelayConstants.protoMin / protoMax, both 16
today), a client sends its own range (hello.protoMin .. hello.proto),
and the connection uses the highest version both have (welcome.proto).
Later additions raise protoMax only, so they stay additive. Fields
marked reserved are parsed and documented but nothing produces them
yet; the restructure step named next to them does.
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. - A socket without any frame from its client for 15 s
(
RelayConstants.silenceSecs) is closed by the server (close code 4015) and its member isgone(see "Presence"). Clients send areportevery second and apingevery second anyway. - Failures:
err(see "Errors and close codes"); a refusedhellois followed by a close. GET /healthreturns JSON (unauthenticated, no member names):ok,name,proto(=protoMax),protoMin,protoMax,version(the server's app version, e.g.1.3.0),tls(reserved: always false; a server with TLS would serve the same endpoints overwss:///https://),viewers,bytesServed,uptimeSecs. The app reads it before joining and does not dial a server whose range does not meet its own (the version gate, see ARCHITECTURE "Server lifecycle").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 and Up next's first one (see "Jellyfin media" below)./r/<sid>/<token>/...is the relay of a member's media (see "Relay" below).GET /a/<sid>/<token>/<hash>serves a member's picture (see "Avatars" below),GET /i/<sid>/<token>/<hash>the current media's art (see "Media art" below).POSTandGET /l/<sid>/<token>send and read the session log (see "Diagnostics" below). A token works from the member'swelcomeuntil it leaves (through its grace, see "Presence").
Errors and close codes
{"t":"err","code":"version","min":16,"max":16,"server":"1.3.0","for":"hello",
"message":"proto mismatch: got 15, want 16..16"}
{"t":"err","code":"bad","for":"seek","message":"bad message: position must be finite"}
messageis plain text for people; a proto 15 app shows it as is (the version refusal starts withproto mismatch:for that reason).codeis for programs;fornames thetof the frame that failed, when there is one. Withcodeversion:min/maxthe server's range,serverits app version. This shape is stable from proto 16 on.- Codes:
version(no common version),secret(wrong secret),rejected(understood but refused:rejected: avatar too large,server fetch needs a server password,The server can't fetch this link: ...),bad(malformed:bad hello: ...,bad message: ...for a frame with a non-finite number or a bad queue item,bad swap media,swap needs a url or the hosted file,relay fetch needs an http(s) url),replaced(another hello of the same client took the member over; the client stops, "opened in another window"),stale(this hello is older than the member's current one),forbidden(presenceinfor someone else; anautoqueue item from someone else than the swapper),rate(reserved; nudges over the cap are still dropped silently),full(Up next is full (100 items)). - After
errthe socket stays open, except athelloand forreplaced, which are followed by a close. - Close codes (the server's; constants:
CloseCodesin core):
| Code | Socket | Meaning |
|---|---|---|
| 1000 | /sync |
the member left (bye, or its grace ran out) |
| 4001 | relay channel | not (or no longer) the source of this relay; the source stops |
| 4002 | relay channel | the source is back with another media than the relay's first info; the relay ends, the source stops |
| 4003 | relay channel | a newer channel of the same source replaced this one; this one stops |
| 4010 | /sync |
refused: err version |
| 4011 | /sync |
refused: err secret |
| 4012 | /sync |
refused: err bad or rejected at hello |
| 4013 | /sync |
err replaced: another connection of the same client holds the member |
| 4014 | /sync |
refused: err stale |
| 4015 | /sync |
silent for 15 s: the member is gone |
| 4016 | /sync |
the hosting app stopped sharing its server: the member has left (leave, reason closed); the client does not reconnect |
Client to server
{"t":"hello","protoMin":16,"proto":16,"secret":"","user":{"name":"","avatar":""},
"media":{},"hostKey":"...","client":"<install id>","seq":7,"app":"1.3.0",
"platform":"linux","caps":{"source":true,"resolve":true,"jellyfin":true},
"feedAfter":41}
{"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":"swap","media":{...},"queueId":"q3"} puts up an Up next item
{"t":"report","position":123.1,"buffered":6.2,"eventId":41,
"duration":1440.5,"mediaId":2,"jf":{"<serverId>":true},"error":"Not allowed (403)"}
{"t":"ping","c":1767225600000}
{"t":"chat","text":"..."}
{"t":"presence","target":"<memberId>","state":"away"|"in","reason":"manual"}
{"t":"bye"}
{"t":"nudge","target":"<memberId>"}
{"t":"profile","name":"...","avatar":"<base64>"}
{"t":"art","mediaId":2,"poster":"<base64>","banner":"<base64>"}
{"t":"queue","op":"add"|"move"|"remove"|"play"|"skip"|"cancel",...} see "Queue"
Field notes:
hello.proto/hello.protoMin: the client's version range (protoMinabsent:protoonly). The server reads them before anything else in the hello (a hello of another version may not parse) and refuses witherrversionwhen the ranges do not meet.hello.mediais ignored; a proto 16 client still sends{}so a proto 15 server, which requires it, answers with its ownproto mismatch.hello.secret: the server password; a server with an empty secret is open. The password may change while members are in (the hosting app sets it when it opens its server to friends).hello.hostKey(optional, omitted when empty): the hosting app generates a random key per run, gives it to its server and sends it from its own client. The member whose key equals the server's is the host (members[].host), and its hello needs nosecret. A server without a key (the dedicated server) has no host. Where the connection comes from does not matter.hello.client(optional): a random id per install, 16 to 64 characters of[A-Za-z0-9_-](the app uses 22 characters of base64url), elseerrbad. Private: never broadcast, never logged. It names the member across connections (see "Presence"); without it every hello is a new member. Reserved: deriving it per server (an HMAC of an install secret and the dialled host:port).hello.seq(default 0): increases with every connection this install makes. A hello whoseseqis lower than the one holding the member getserrstaleand is closed; equal or higher takes the member over.hello.app(the app version, cut to 32 characters): shown asmembers[].app.hello.platform(linux,windows,macos,android): logged only.hello.caps: what the app can do (source: serve a relay;resolve: run yt-dlp;jellyfin: play with its own account), shown asmembers[].caps; unknown entries are ignored.hello.feedAfter(optional): the lastfeed.seqthe client saw from this server;welcome.feedreplays the lines after it. A client should send it only for the samewelcome.instance(a restarted server counts from 1 again).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.bufferedis the current media's: a client sends 0 while its player does not hold it yet, and the server counts a report whosemediaIdnames an earlier media asbuffered0. Ajoiningmember's report ends its joining once it holds the current media at the server position (within 5 s) with the buffer the gate needs (see "Presence").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. The reference client lists every server it has a working account on astrue, then the current item's server as it plays it (so a queued Jellyfin item finds someone to put it up while other media plays).report.error(optional, omitted when empty): short plain text saying why this client's player cannot open the mediareport.mediaId(e.g.Not allowed (403)). The client sends it in every report while that lasts and drops it once the media opens (a reload included). The server keeps it per member (one line, cut to 120 characters) only for the currentmediaId: a report without it, or for anothermediaId, clears it, and so does a swap. An errored member is out of the ready gate: out of the set, areadyorforcedoes not put it back; when the error clears it is in the set again like a join (unlessplaying, then only if it is ready). It also ends ajoining.ping.c: the client's clock in epoch ms; answered withpong.user.avatar: base64 PNG, at most 256 KiB of characters (elserejected: ...).nameis trimmed, empty becomes?, cut to 32 characters. A takeover applies the hello's name and picture like aprofile.profile: changes this member's name and/or picture mid-session. A field that is absent (or null) stays as it is;avatar: ""removes the picture. Same rules ashello.user; a too large avatar getserrrejected: avatar too large(the socket stays open, nothing changes).art: pictures of the current media (see "Media art").chat.textis cut to 500 characters.presence:targeta member id (absent or empty: oneself).stateawaysitstargetout: anyone may, on anyone (the host included); agonetarget comes backaway.stateinbrings oneself back (throughjoining, see "Presence"); for anyone else it iserrforbidden.reason(manualdefault,idle,minimised,bye) goes into the feed line. Unknown targets and no-op changes are ignored.bye: the member leaves now (the Leave button, the app closing): feedleave(reasonbye), its token stops working, the socket closes (1000). Anything else that ends a socket keeps the member (gone), except the hosting app closing its server to friends: everyone but the host leaves at once (feedleave, reasonclosed) and the socket closes with 4016.nudge: the server sends{"t":"nudge","from":<memberId>,"name":<name>}totargetonly (no feed). At most 3 per second and 20 per minute per sender; the rest, nudges to oneself and to unknown targets are dropped silently.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").swap.media.refsays what it is (see "Media identity"). A swap keeps every member'sreadyand puts every member in the set (inSet; who counts is up to presence); clients that want to be unready for the new media sendreadythemselves (gate: ARCHITECTURE.md, "Ready gate").- Frames before
welcome(other thanhello) are ignored; unparsable or unknown frames are ignored silently. Frames from a socket another hello took over are ignored.
Server to client
{"t":"welcome","proto":16,"token":"...","memberId":"m1","resumed":false,
"instance":"<random per boot>","grace":600,"mediaUrl":"...","relayUrl":"...",
"state":{<state>},"feed":[<feed with "replayed":true>,...]}
{"t":"state","eventId":7,"transport":"playing"|"held"|"finished","position":12.3,
"serverTime":"<ISO-8601 UTC>","mediaId":1,
"media":{name,size,duration,url?,jellyfin?,relay?,ref?,swapBy?,art?}|absent,
"members":[{"id","name","avatarHash"?,"ready","inSet","buffered","presence",
"goneSince"?,"jf"?,"host"?,"error"?,"rate"?,"app"?,"caps"?}],
"seekBy":"m1","seekSeq":3,"relay":true,"relayNext":true,"queue":[<item>]?,
"queuePlay":"q3"?,"hold":{"reason":"joining","members":["m3"],"until":"<ISO>"}?,
"advance":{"id":"q3","at":"<ISO>","secs":10}?}
{"t":"pong","c":1767225600000,"s":1767225600012}
{"t":"feed","seq":12,"actor":"","actorId":"m2","action":"","target":"","targetId":"m3",
"reason":"","at":"<ISO-8601>"}
{"t":"chat","text":"...","from":"<name>","fromId":"m2"}
{"t":"nudge","from":"m2","name":"Alice"}
{"t":"err","code":"...","message":"...","for":"..."}
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).welcome.proto: the version of this connection.welcome.resumed: the hello took over this client's member (samememberIdandtoken, everything it had kept); false for a new member, also after a server restart.welcome.instance: random per server boot (a client that held a member of another instance knows the server restarted, not that its grace ran out).welcome.grace: seconds agonemember is kept.welcome.feed(omitted when empty): the feed lines afterhello.feedAfter, at most the last 50, oldest first, each withreplayed: true(log them, do not toast them).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.media.swapBy: member id of who swapped it in (omitted once that member left, and for the media the server started with).media.art: see "Media art".media.ref: see "Media identity".seekBy(member id) andseekSeqare present only while the currenteventIdcame from a seek.relay(omitted when false): the server can relay the current Jellyfin media.relayNext(omitted when false, proto 13): it can relay Up next's first item too (a Jellyfin ref on the same login), so viewers without access preload it from/j.members[].jfis the member's lastreport.jf, omitted when it never sent one.members[].avatarHash(omitted when the member has no picture) names the picture's bytes (see "Avatars").members[].host(omitted when false): the member sent the server'shostKey(seehello.hostKey).members[].presenceandgoneSince(ISO, only whilegone): see "Presence".members[].inSet: the member counts for the ready gate right now (in the set, no player error, and its presence counts).members[].error(omitted when empty): the member's player cannot open the current media (seereport.error).members[].rate(omitted when 0): bytes per second the server sent this member over the last 5 s (/m,/jand/rtogether), two significant digits, under 10 kB/s counts as 0. Members that fetch the media themselves read nothing from the server.members[].app/caps(omitted when empty): itshello.app/hello.caps.state.hold(present whileheldwith media up): why.reason, most pressing first:joining(a joiner within its bound holds the start;untilis when that bound ends, server clock),ready(someone in the set is not ready),buffer(someone's buffer is short ofbufferNeed),source(the same while the relay's source isgone),start(nobody counts for the gate: someone has to ready up).members: the member ids the gate waits for (empty forstart).state.advance(reserved, step 6): the shared countdown to Up next (see "Queue").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:seqincreases by one per line for the server's life (a restart counts from 1 again);actor/targetare display names as they were then,actorId/targetIdthe member ids (serverfor the server's own lines; omitted when none): match people by id, not by name.actionvalues:ready,unready,force-ready,force-unready,force-play,force-hold,join(a new member only),leave(for good:reasonbye,graceorclosed),gone(10 s after the member wentgone; a quicker return says nothing),return(back within the grace after agoneline),away(targetthe member set away, absent when the actor set itself;reasonidle/minimised/bye, empty for manual),in(back from away),swap,seek,pause,resume,finished,chat,rename,avatar,queue-add,queue-remove,queue-skip,advance-cancel(targetthe dropped continuation's title, if any),fetch-fail(the server could not fetch the current media,targetits name).reasoncarriesauto/manualfor ready.rename:actoris the new name,targetthe old one.avatar: the actor changed only the picture.queue-*:targetis the item's title,reason(skip) why it could not play (see "Queue").- Chat is delivered twice: a
chatframe with the text (fromIdthe sender's member id) and a textlessfeedwithaction:"chat". Render thechatframe only; the reference client drops the textless feed entry.
Presence
The member is lasting: one per client, kept across reconnects. Gate rules and timings in detail: ARCHITECTURE.md, "Sync model".
- The server maps
hello.clientto one member. A hello with a known client id (and aseqnot older than the member's) takes that member over:memberIdandtokenstay, so/m/r/jURLs keep working; the ready flag, presence, queue items, relay, picture all stay; an older socket still open getserrreplacedand closes (4013).welcome.resumedis true. - A socket close (or 15 s of silence) is not a leave: the member goes
gone(goneSince) and is kept for the grace, 10 min (welcome.grace), with everything it had; a relay it serves goes on (see "Relay"). After the grace it leaves (feedleave, reasongrace), its token stops working; a hello with its client id after that is a new member.byeleaves at once. members[].presence:joining(new, back fromgone, or backinfromaway, while media is up: it loads in the background until its report holds the current media at the server position withbufferNeedbuffered, or reports an error, or 60 s pass; thenin),in(watching),away(sat out: still in the server, never counted for the gate, the ready counter or watched tracking),gone(disconnected, in its grace). A memberawaywhen it dropped comes backaway. With no media up a joiner isinat once.- What each presence counts for is one table,
PresencePolicyin core (presence.dart), used by the server and the app: the gate countsin, andjoiningonly whileheldand for at most 15 s after it started joining (never whileplaying: a joiner never pauses anyone); the ready counter and preloads countjoiningandin; watched tracking and full social notifications countin(joiningtoo for the notifications).
Media identity
media.ref = {"kind":"episode","title":"Show","anilistId":1,"anilistEp":6,
"tvdbId":2,"season":1,"ep":6,"absolute":30}
- In
swap.media,state.mediaandqueue[].media; passed through as is (the server drops non-integer ids and cutstitleto 200 characters).kindepisode,movieorextra(else no ref); every other field optional;seasonabsent for absolute numbering. At most 2 KiB of JSON, elseerrbad. Reserved: nothing produces it yet (step 4).
Avatars
- The picture is sent as base64 in
hello.user.avatarorprofile.avatar;statecarries only its hash: the first 16 hex characters of the sha256 of the decoded bytes. An avatar that is not valid base64, or whose bytes are not a PNG, JPEG, WebP or GIF (by magic bytes), counts as none. GET /a/<sid>/<token>/<hash>: the bytes with their image content type,cache-control: private, max-age=31536000, immutable(a hash names fixed bytes; clients fetch each hash once, from the host they dialled). Errors: 401 bad token, 404 no current member has that picture, 410 unknown session. Pictures go with their member (leave, change, removal).
Media art
client {"t":"art","mediaId":2,"poster":"<base64>","banner":"<base64>"}
state media.art = {"poster":"<hash>","banner":"<hash>"}
- A portrait poster and a wide banner for the current media, so every
member (guests without Jellyfin included) can show them. Sent by the
member who made the current swap (
media.swapBy) or the host, for the currentmediaId; from anyone else, or for anothermediaId, it is ignored. Each field is optional; one that is not a picture (same check as avatars) or larger than its cap (poster 256 KiB, banner 768 KiB of decoded bytes) counts as none. A laterartreplaces the earlier one whole; any swap clears it. media.artcarries hashes likeavatarHash; a key is omitted when that picture is absent,artwhen both are. The same picture as poster and banner (one hash) is a web video's thumbnail.GET /i/<sid>/<token>/<hash>: as/a/(see "Avatars"); also the pictures of queued items (queue[].thumb). 404 when neither the current media nor a queued item has it.
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>: for the current item or, whenstate.relayNext, Up next's first one, 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 neither of those items, 404 no relay for either, 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 = {"tracks":[{"kind":"video"},{"kind":"audio"}]}
the same, as two tracks played together
media.relay = {"tracks":[{"kind":"video","as":"pieces"}]}
the same, as one segmented playlist
media.relay = {"fetch":true} the server reads media.url itself
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)
media.relay = {"source":"m2","live":true,"sourceGone":true,"sourceGoneSince":"<ISO>"}
its source is gone, in its grace
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
(a page's separate video and audio). tracks lists them in order, up to
4 entries of kind video or audio, each a Range-readable byte stream,
or with "as":"pieces" a segmented stream (an HLS media playlist or DASH
fragments) relayed piece by piece and served as a VOD media playlist. A
piece is one segment, init section (EXT-X-MAP) or AES-128 key, fetched
upstream whole with one GET. At least 2 entries, or 1 pieces entry (a
muxed playlist, kind video); absent, an unknown kind or as, or
anything else means one file.
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). A web pageurlis read through the server's yt-dlp; when the server cannot read the link, the swapper later getsThe server can't fetch this link: <reason>; when yt-dlp failed and the server has no cookies, the reason isit has no cookies (...)instead. Any later swap ends the relay.trackson a member's swap names the tracks its source will serve; the server ignores it withfetchand withjellyfin.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).tracks: as the swap named them, or, withfetch, set once the server has read the link (together withlive).sourceGone(omitted when false) andsourceGoneSince(ISO): the source member isgone(see "Presence"). The relay and its buffer stay through the source's grace; viewers play what is buffered. A source's leave (grace over,bye) ends the relay:livegoes,sourceGonestays, its URLs answer 404. A swap ends it as before. The warm relay'squeue[0].media.relaycarries the same fields.state.media.relay.rate(omitted while unknown): bytes per second the source (withfetch, the server's own fetch) delivers while the buffer is waiting on it, smoothed over about 4 s of that time and present once 2 s of it are measured; it holds while the buffer is full and the source idles.need(omitted while unknown): the media's average bytes per second, the tracks' sizes overmedia.duration(a pieces track: its segments in so far, after a minute of them). Both two significant digits, updated once a second.rateunderneedmeans the source cannot keep up; clients tell the source once per media.- Warm relay (proto 14): while Up next's first item is relayed (
howserver,throughorfile, orjellyfinwhen someone present has no access to that server andstate.relayNextis false), the server holds a small relay for it and shows it as that item'smedia.relay:source(the adder; forjellyfinempty until the first member withjftrue for that server dials in), orfetch, andlivewithtracksonce 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 withqueueIdof that item, from its source (or,fetch, from anyone) with the sametracks, makes it the media's relay:media.relay.queueIdis the item's id,media.namethe item'smedia.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.mediaUrlfor relayed media withoutjellyfin: 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 onlive). Clients build it after a swap like the/mone:http://<host>/r/default/<token>/<key>/<name>(<key>themediaId, ormedia.relay.queueIdwhen set; name with/and\made_, then URL-encoded; the server ignores it). Withtracks, each track's URL ishttp://<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>/<key>/<name>: the media (one file), Range table as for/m;<key>amediaId, or a queue id (the warm relay, or the media's relay started from it).GET /r/<sid>/<token>/<key>/<i>/<name>: tracki(from 0) of a media withtracks(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,EXTINFdurations,EXT-X-DISCONTINUITY,EXT-X-MAPandEXT-X-KEY:METHOD=AES-128(withIVwhen given) as upstream,EXT-X-ENDLIST; every URI isp/<j>.GET /r/<sid>/<token>/<key>/<i>/p/<j>: piecejof pieces tracki, whole, once it is in (a Range inside the piece answers 206). Errors: 401 bad token, 404 not the current relayed media nor the warm one (a player still on an oldermediaIdgets 404, never the new bytes), no such track (the one-file URL of a media withtracks) or piece (orp/of a byte track), 410 unknown session, 503 +retry-afterthe 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 towardbytesServed.GET /r/<sid>/<token>/source(WebSocket): the source's relay channel, 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 firstinfo. The source's token works through its grace, so a source back from a drop dials the channel again (before or after its ownhello) and sends itsinfoagain: the same as its first (track kinds, byte sizes, piece shapes, andname/durationwhere both give them) continues the relay: the buffer stays and the server pulls what it still lacks with new pull ids, from the first byte or piece not buffered (thepullframes below are that exchange; nothing else is needed). Anotherinfocloses the channel with 4002 and ends the relay. A second channel for the same relay replaces the first, which is closed with 4003. The source stops on 4001, 4002 and 4003 and reconnects (with backoff) on anything else. Frames:
source -> server {"t":"info","size":<bytes>,"name":"Ep 06.mkv","duration":1440.5}
first frame, one file
(name, duration optional)
{"t":"info","tracks":[{"kind":"video","size":<bytes>},
{"kind":"audio","size":<bytes>}]}
first frame, tracks
{"t":"info","tracks":[{"kind":"video","as":"pieces","seq":5,
"pieces":[{"init":true},{"key":true},
{"d":6.0,"map":0,"key":1,"iv":"0x..."},
{"d":4.5,"map":0,"disc":true}]}]}
a pieces track
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,"track":0,"start":0,"end":1048575}
inclusive; no track: 0
{"t":"pull","id":2,"track":1,"piece":17} piece 17, whole
{"t":"ack","id":1,"n":65536} bytes arrived
{"t":"cancel","id":1} stop that pull
Pull ids are unique across the tracks of a connection; track is sent
only for a media with tracks. A pieces track's pieces are its
pieces in order: {"init":true} an init section, {"key":true} an
AES-128 key, else a segment of d seconds whose map / key name the
init and key pieces it uses, iv an explicit IV (0x + hex), disc a
discontinuity before it; seq (default 0) the media sequence of the
first segment. A piece pull's bytes end with end like a range pull.
A first info whose tracks differ from the swap's tracks (count,
bytes or pieces), or a malformed piece list, closes the channel with
4001. A later info unlike the first closes it with 4002 (see above).
Join and reconnect
hello then welcome, then the app opens mediaUrl, seeks to the server
position and buffers. A new member is ready=false and joining (with
media up); it is in the set at once unless the server is playing,
then from its first ready (members present at a swap are put in the
set by it), and the gate counts it per "Presence". A reconnect is a
hello with the same client and a higher seq (and feedAfter):
within the grace it is the same member (welcome.resumed), its token and
media URL unchanged, its ready flag and presence kept; after a
non-resumed welcome (grace over, server restarted) the client sends
its intent again (ready, presence).
Queue (proto 12)
Up next: what plays after the current media, held by the server for
everyone, in memory (a restart loses it). Any member edits it; items stay
when their adder leaves. by is the adder's lasting member id: it
survives reconnects, and while the adder is gone its through/file
items wait for it (it is still in members) instead of being skipped;
after it left for good they cannot play. Behavior (who puts an item up, the countdown,
skipping) is in ARCHITECTURE.md, "Up next".
client {"t":"queue","op":"add","at":"end"|"next","item":{<item>},"thumb":"<base64>"}
{"t":"queue","op":"move","id":"q3","to":0}
{"t":"queue","op":"remove","id":"q3","why":"Bob left"}
{"t":"queue","op":"play","id":"q3"}
{"t":"queue","op":"skip"}
{"t":"queue","op":"cancel"}
{"t":"queue","op":"add","item":{<item>,"auto":true,"for":2}}
{"t":"swap","media":{...},"queueId":"q3"}
state "queue":[<item>,...], "queuePlay":"q3"
<item> = {"id":"q3","media":{<swap media>},"how":"direct"|"server"|"through"|"file"|"jellyfin",
"title":"...","sub":"...","duration":213,"thumb":"<hash>",
"by":"m2","byName":"Bob","key":"<adder's handle>","auto":true?,"for":2?}
state "advance":{"id":"q3","at":"<server time>","secs":10} reserved
add:itemwithoutid,by,byName,thumb(the server sets them:idq<n>, unique for the server's life; who sent it and their name then).atnextputs it first, else last.thumb(optional): base64 picture, checked and capped like a poster (256 KiB decoded);item.thumbis its hash, served at/i/(see "Media art") while the item is queued.titleandsubare cleaned like media names and cut to 200 characters (an empty title becomes the media's name);duration(seconds, omitted when unknown) is informational. At most 100 items; over that the sender getserrUp next is full (100 items).howand whatmediamust carry:direct: an http(s)url; everyone opens the link.server: an http(s)urlandrelay{"fetch":true}, on a server with a password (as for a fetch swap). The server keeps the URL to itself: the broadcastmediahas none.through(a link the adder passes on) /file(the adder's file):nameandsizeonly, andkey(8 to 64 of[A-Za-z0-9_-]): the adder's own handle for the link or path, which never leave its app. Aurlsent along is dropped.jellyfin: ajellyfinref, checked like a swap's (anymediawith a ref isjellyfin). Anything else:errbad message: ....
move:idto indexto(clamped).remove: dropsid; withwhy(cut to 120 characters) it is a skip (feedqueue-skipwith the reason; clients show it), else feedqueue-remove.addgives feedqueue-add. Unknown ids are ignored.play: movesidfirst and setsstate.queuePlayto it: a request for the member who can put it up to do so now.skipisplayof the first item.queuePlayends with any swap, when the item is removed, or after 30 s.auto(withfor, themediaIdit continues): the continuation (the next episode). Only for the current media (elseerrbad), and only from the member who put it up (media.swapBy) or, when that member is notin, from the first member who is (elseerrforbidden). At most one: a newer one replaces it; it stays after the other items (anaddgoes before it,nextputs an item first,movenever passes it, it does not move itself); any swap that does not put it up drops it.cancel: drops theautoitem and the countdown for everyone (feedadvance-cancel); nothing to drop, nothing happens.state.advance(reserved, step 6): the shared countdown when the media finishes and an item is first; atat+secsthe server would setqueuePlaytoid, holding while its performer or source isgone. The server never starts one yet;canceland any swap clear it.swap.queueId: the swap puts that item up and takes it off the queue in the same step. A swap naming an item that is no longer queued is ignored (someone put it up first). For aserveritem the swap may leave outurl: the server uses the one it kept. A swap withoutqueueIdleaves the queue as it is.state.queue(omitted when empty) andstate.queuePlay(omitted when none) ride in every state,welcome.stateincluded. The first item'smedia.relayshows its warm relay (see "Relay").
Diagnostics (proto 15)
Each app sends its log lines to the server, which merges them with its own into the session log (behavior, file and format: ARCHITECTURE.md, "Logging").
POST /l/<sid>/<token> content-type application/gzip, body = gzip of:
{"v":"Starsync 1.4.0 on linux","share":false,
"lines":[{"t":1767225600123,"l":"WARNING","n":"mpv","m":"..."}],
"dropped":12,"marks":[{"t":1767225600500,"note":"..."}]}
GET /l/<sid>/<token>?hours=6
POST: one batch. Every field is optional.v: the app and version, noted once per member (and when it changes).share:false(omitted when true): the member does not share; the server notes it once and drops anylines.lines[]:tepoch ms on the server's clock (the client converts with itspongoffset),llevel name,nlogger ([A-Za-z0-9_.-], up to 40, elseapp),mthe message, already redacted, an error and stack trace on the lines below (cut to 8192 characters).dropped: lines the client dropped since its last batch (its buffer was full).marks[]: "Mark a problem" attwith an optionalnote(one line, cut to 200 characters); it goes in even withshare:false.- Limits: a body of at most 256 KiB, unpacking to at most 1 MiB (else
413); not gzip of a JSON object: 400. Per member and minute at most 30
posts and 4 MiB of lines, else 429 with
retry-after: the client keeps the batch and sends it later. Unknown token: 401. Success: 204, also on a server that keeps no session log (the batch is dropped). GET: the session log of the lasthours(1 to 168, default 6; at most 8 MiB, the newest kept), lines the server still holds for ordering included, as gzip of UTF-8 text (application/gzip). Any member may read it (it is redacted and holds no chat). 401 for an unknown token, 404 on a server that keeps no session log.- Clients send every 10 s and when they leave; each batch carries at most 256 KiB of lines.