forked from Foxyz/sync_replay_project
Restructure step 1: lasting identity, presence and protocol 16
Server and core: version ranges on both sides with a coded refusal; a client id maps to the same member and token for the server's life; a drop is `gone` for a 10-minute grace keeping ready, presence, queue items, relay and avatar; `bye` leaves at once; ordered takeover (seq, replaced/stale); presence joining/in/away/gone with one PresencePolicy; joiners never pause playback and hold a held gate at most ~15 s; state.hold; feed ids, seq and replay; the relay lives through its source's grace and continues on re-dial; queue `by` lasting, auto items and cancel; MediaRef carried; client ids never logged. App: persisted client id and seq; Connection with unstable/lost/ replaced states, backoff with jitter, no lost/back pair per flap; a drop keeps the session (state, player, media, relay source); presence in the strip and members card; sit out = away intent; feed matched by id; auto sit-out (idle when not playing, minimised for a while; never when alone) replaces auto-unready; bye on close and Leave, not on update restart; relay source re-dials through its grace. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
51c1c5ab78
commit
9f2586b7ff
85 files changed
+5198
-1902
No files matched your search
+284
-88
@@ -1,41 +1,93 @@
|
||||
# Protocol (proto = 15)
|
||||
# Protocol (proto = 16)
|
||||
|
||||
Wire format only. Behavior (gate, clock, timeouts) is in
|
||||
[ARCHITECTURE.md](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`).
|
||||
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 a `t` field.
|
||||
- The first client frame must be `hello` within 10 s. Server pings every 5 s.
|
||||
- Match incoming frames by `t`, never by order: `welcome` can race `state`.
|
||||
- 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 /health` returns 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 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). `POST` and `GET
|
||||
/l/<sid>/<token>` send and read the session log (see "Diagnostics"
|
||||
below).
|
||||
- A socket without any frame from its client for 15 s
|
||||
(`RelayConstants.silenceSecs`) is closed by the server (close code
|
||||
4015) and its member is `gone` (see "Presence"). Clients send a
|
||||
`report` every second and a `ping` every second anyway.
|
||||
- Failures: `err` (see "Errors and close codes"); a refused `hello` is
|
||||
followed by a close.
|
||||
- `GET /health` returns 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 over `wss://` /
|
||||
`https://`), `viewers`, `bytesServed`, `uptimeSecs`.
|
||||
- `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). `POST` and `GET /l/<sid>/<token>` send and read
|
||||
the session log (see "Diagnostics" below). A token works from the
|
||||
member's `welcome` until 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"}
|
||||
```
|
||||
|
||||
- `message` is plain text for people; a proto 15 app shows it as is (the
|
||||
version refusal starts with `proto mismatch:` for that reason). `code`
|
||||
is for programs; `for` names the `t` of the frame that failed, when
|
||||
there is one. With `code` `version`: `min`/`max` the server's range,
|
||||
`server` its 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` (`presence` `in` for
|
||||
someone else; an `auto` queue 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 `err` the socket stays open, except at `hello` and for
|
||||
`replaced`, which are followed by a close.
|
||||
- Close codes (the server's; constants: `CloseCodes` in 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` |
|
||||
|
||||
## Client to server
|
||||
|
||||
```
|
||||
{"t":"hello","proto":15,"secret":"","user":{"name":"","avatar":""},
|
||||
"media":{"name":"","size":0,"duration":0,"url":null},"hostKey":"...",
|
||||
"resume":"<token>"}
|
||||
{"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}
|
||||
@@ -47,27 +99,45 @@ with different `proto` refuse each other at `hello` (`proto mismatch`).
|
||||
"duration":1440.5,"mediaId":2,"jf":{"<serverId>":true},"error":"Not allowed (403)"}
|
||||
{"t":"ping","c":1767225600000}
|
||||
{"t":"chat","text":"..."}
|
||||
{"t":"out","target":"<memberId>","out":true}
|
||||
{"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",...} see "Queue"
|
||||
{"t":"queue","op":"add"|"move"|"remove"|"play"|"skip"|"cancel",...} see "Queue"
|
||||
```
|
||||
|
||||
Field notes:
|
||||
- `hello.proto` / `hello.protoMin`: the client's version range
|
||||
(`protoMin` absent: `proto` only). The server reads them before
|
||||
anything else in the hello (a hello of another version may not parse)
|
||||
and refuses with `err` `version` when the ranges do not meet.
|
||||
`hello.media` is ignored; a proto 16 client still sends `{}` so a proto
|
||||
15 server, which requires it, answers with its own `proto mismatch`.
|
||||
- `hello.secret`: the server password; a server with an empty secret is open.
|
||||
`hello.media` is 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
|
||||
(`members[].host`). 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 previous `welcome`, sent on reconnect. If the server still has
|
||||
that member, the connection takes it over: `welcome`
|
||||
returns the same `token` and `memberId`, the member keeps its flags, and
|
||||
there is no `join`/`leave` feed. Otherwise it is ignored and a new member
|
||||
joins.
|
||||
- `hello.client` (optional): a random id per install, 16 to 64
|
||||
characters of `[A-Za-z0-9_-]` (the app uses 22 characters of
|
||||
base64url), else `err` `bad`. 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 whose `seq` is lower than the one holding the member
|
||||
gets `err` `stale` and is closed; equal or higher takes the member over.
|
||||
- `hello.app` (the app version, cut to 32 characters): shown as
|
||||
`members[].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 as `members[].caps`; unknown entries are ignored.
|
||||
- `hello.feedAfter` (optional): the last `feed.seq` the client saw from
|
||||
this server; `welcome.feed` replays the lines after it. A client should
|
||||
send it only for the same `welcome.instance` (a restarted server counts
|
||||
from 1 again).
|
||||
- `seek.seq` (optional, default 0): increasing per client; echoed in
|
||||
`state.seekSeq` so the sender recognises its own seek.
|
||||
- `report.duration` / `report.mediaId` (optional, omitted when 0): the open
|
||||
@@ -75,7 +145,9 @@ Field notes:
|
||||
the first one whose `mediaId` is current while its media duration is 0.
|
||||
`report.buffered` is the current media's: a client sends 0 while its
|
||||
player does not hold it yet, and the server counts a report whose
|
||||
`mediaId` names an earlier media as `buffered` 0.
|
||||
`mediaId` names an earlier media as `buffered` 0. A `joining` member'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 per `ServerId`, `true` = this
|
||||
client plays the item with its own account, `false` = through the relay.
|
||||
Sticky: a report without `jf` keeps the last one. At most 8 entries, keys
|
||||
@@ -92,27 +164,27 @@ Field notes:
|
||||
clears it, and so does a swap. An errored member is out of the ready
|
||||
gate: out of the set, a `ready` or `force` does not put it back; when
|
||||
the error clears it is in the set again like a join (unless `playing`,
|
||||
then from its `ready`). Optional on both sides: older servers ignore it,
|
||||
older clients ignore `members[].error`.
|
||||
then only if it is ready). It also ends a `joining`.
|
||||
- `ping.c`: the client's clock in epoch ms; answered with `pong`.
|
||||
- `user.avatar`: base64 PNG, at most 256 KiB of characters (else
|
||||
`rejected: ...`). `name` is trimmed, empty becomes `?`, cut to 32
|
||||
characters.
|
||||
characters. A takeover applies the hello's name and picture like a
|
||||
`profile`.
|
||||
- `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 as `hello.user`; a too large avatar gets `err`
|
||||
`rejected: avatar too large` (the socket stays open, nothing changes).
|
||||
- `art`: pictures of the current media (see "Media art").
|
||||
- `chat.text` is cut to 500 characters.
|
||||
- `out`: `out:true` sits the member `target` out (anyone, the host
|
||||
included); `out:false` brings the sender back and only works with its own
|
||||
member id as `target`. An out member is never in the set and never
|
||||
ready: `ready` from it and `force` on it are ignored, a swap leaves it
|
||||
out, the `buffered` of its reports counts as 0 (it keeps sending
|
||||
`report`: that keeps it fresh). Coming back puts it in the set like a
|
||||
join (unless `playing`), unready; it sends `ready` itself. Unknown
|
||||
targets and no-op changes are ignored. Feed: `out` (`target` = the name
|
||||
of who was sat out, absent when the actor sat itself out) and `back`.
|
||||
- `presence`: `target` a member id (absent or empty: oneself). `state`
|
||||
`away` sits `target` out: anyone may, on anyone (the host included); a
|
||||
`gone` target comes back `away`. `state` `in` brings oneself back (through
|
||||
`joining`, see "Presence"); for anyone else it is `err` `forbidden`.
|
||||
`reason` (`manual` default, `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): feed
|
||||
`leave` (reason `bye`), its token stops working, the socket closes
|
||||
(1000). Anything else that ends a socket keeps the member (`gone`).
|
||||
- `nudge`: the server sends `{"t":"nudge","from":<memberId>,"name":<name>}`
|
||||
to `target` only (no feed). At most 3 per second and 20 per minute per
|
||||
sender; the rest, nudges to oneself and to unknown targets are dropped
|
||||
@@ -120,29 +192,34 @@ Field notes:
|
||||
- `swap.media.url` is the link; `name`, `size`, `duration` are informational.
|
||||
`swap.media.jellyfin` makes it Jellyfin media (see below); then `url` is
|
||||
dropped. `swap.media.relay` makes it relayed media (see "Relay").
|
||||
A swap keeps every member's `ready` and puts every fresh member in the
|
||||
set (`inSet`); clients that want to be unready for the new media send
|
||||
`ready` themselves (gate: ARCHITECTURE.md, "Ready gate").
|
||||
`swap.media.ref` says what it is (see "Media identity"). A swap keeps
|
||||
every member's `ready` and puts every member in the set (`inSet`; who
|
||||
counts is up to presence); clients that want to be unready for the new
|
||||
media send `ready` themselves (gate: ARCHITECTURE.md, "Ready gate").
|
||||
- Frames before `welcome` (other than `hello`) are ignored; unparsable or
|
||||
unknown frames are ignored silently.
|
||||
unknown frames are ignored silently. Frames
|
||||
from a socket another hello took over are ignored.
|
||||
|
||||
## Server to client
|
||||
|
||||
```
|
||||
{"t":"welcome","token":"...","memberId":"m1","mediaUrl":"...","relayUrl":"...",
|
||||
"state":{<state>}}
|
||||
{"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?,swapBy?,art?}|absent,
|
||||
"members":[{"id","name","avatarHash"?,"ready","inSet","buffered","stale","jf"?,
|
||||
"host"?,"out"?,"error"?,"rate"?}],
|
||||
"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"?}
|
||||
"queuePlay":"q3"?,"hold":{"reason":"joining","members":["m3"],"until":"<ISO>"}?,
|
||||
"advance":{"id":"q3","at":"<ISO>","secs":10}?}
|
||||
{"t":"pong","c":1767225600000,"s":1767225600012}
|
||||
{"t":"feed","actor":"","action":"","target":"","reason":"","at":"<ISO-8601>"}
|
||||
{"t":"chat","text":"...","from":"<name>"}
|
||||
{"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","message":""}
|
||||
{"t":"err","code":"...","message":"...","for":"..."}
|
||||
```
|
||||
|
||||
- `welcome.token` is private to its owner and appears only in that member's
|
||||
@@ -151,7 +228,15 @@ Field notes:
|
||||
the shared link.
|
||||
`welcome.state` has the same shape as `state`. `relayUrl` is present only
|
||||
when `state.relay` is: this member's `/j/...` URL for the current
|
||||
Jellyfin media (private like `token`).
|
||||
Jellyfin media (private like `token`). `welcome.proto`: the version of
|
||||
this connection. `welcome.resumed`: the hello took over this client's
|
||||
member (same `memberId` and `token`, 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 a `gone` member is kept. `welcome.feed` (omitted when empty):
|
||||
the feed lines after `hello.feedAfter`, at most the last 50, oldest
|
||||
first, each with `replayed: true` (log them, do not toast them).
|
||||
- `state` is sent every second and on every change. `position` is the value at
|
||||
`serverTime` (server clock); clients extrapolate while `transport` is
|
||||
`playing`, on the server's clock (see `pong`). `finished`: the media
|
||||
@@ -161,7 +246,8 @@ Field notes:
|
||||
(see "Relay"). `media.duration` is 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". `seekBy` (member id) and `seekSeq` are present only while the current
|
||||
see "Media art". `media.ref`: see "Media identity". `seekBy` (member id)
|
||||
and `seekSeq` are present only while the current
|
||||
`eventId` came 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
|
||||
@@ -169,29 +255,98 @@ Field notes:
|
||||
`report.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's `hostKey` (see `hello.hostKey`). `members[].out` (omitted when
|
||||
false): the member sits out (see `out`). `members[].error` (omitted when
|
||||
server's `hostKey` (see `hello.hostKey`). `members[].presence` and
|
||||
`goneSince` (ISO, only while `gone`): 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 (see
|
||||
`report.error`). `members[].rate` (omitted when 0): bytes per second
|
||||
the server sent this member over the last 5 s (`/m`, `/j` and `/r`
|
||||
together), 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): its `hello.app` /
|
||||
`hello.caps`.
|
||||
- `state.hold` (present while `held` with media up): why. `reason`, most
|
||||
pressing first: `joining` (a joiner within its bound holds the start;
|
||||
`until` is when that bound ends, server clock), `ready` (someone in the
|
||||
set is not ready), `buffer` (someone's buffer is short of `bufferNeed`),
|
||||
`source` (the same while the relay's source is `gone`), `start` (nobody
|
||||
counts for the gate: someone has to ready up). `members`: the member
|
||||
ids the gate waits for (empty for `start`).
|
||||
- `state.advance` (**reserved**, step 6): the shared countdown to Up next
|
||||
(see "Queue").
|
||||
- `pong.c` echoes the ping's `c`; `pong.s` is the server's clock in epoch ms
|
||||
(the clock `serverTime` uses). With `t3` the local receive time, the offset
|
||||
is `s - (c + t3) / 2`, accurate to half the round trip.
|
||||
- `feed.action` values: `ready`, `unready`, `force-ready`, `force-unready`,
|
||||
`force-play`, `force-hold`, `out`, `back`, `join`, `leave`, `stale`, `swap`,
|
||||
`seek`, `pause`, `resume`, `finished`, `chat`, `rename`, `avatar`,
|
||||
`queue-add`, `queue-remove`, `queue-skip`, `fetch-fail` (proto 12: the
|
||||
server could not fetch the current media, `target` its name). `target`
|
||||
and `reason` are optional; `reason` carries `auto`/`manual` for ready and
|
||||
`silent too long` or `no signal` for leave/stale. `rename`: `actor` is the
|
||||
new name, `target` the old one. `avatar`: the actor changed only the
|
||||
picture. `queue-*`: `target` is the item's title, `reason` (skip) why it
|
||||
could not play (see "Queue").
|
||||
- Chat is delivered twice: a `chat` frame with the text and a textless
|
||||
`feed` with `action:"chat"`. Render the `chat` frame only; the reference
|
||||
client drops the textless feed entry.
|
||||
- `feed`: `seq` increases by one per line for the server's life (a
|
||||
restart counts from 1 again); `actor` / `target` are display names as
|
||||
they were then, `actorId` / `targetId` the member ids (`server` for the
|
||||
server's own lines; omitted when none): match people by id, not by
|
||||
name. `action` values: `ready`, `unready`, `force-ready`, `force-unready`,
|
||||
`force-play`, `force-hold`, `join` (a new member only), `leave` (for
|
||||
good: `reason` `bye` or `grace`), `gone` (10 s after the member went
|
||||
`gone`; a quicker return says nothing), `return` (back within the grace
|
||||
after a `gone` line), `away` (`target` the member set away, absent when
|
||||
the actor set itself; `reason` `idle`/`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` (`target` the dropped continuation's
|
||||
title, if any), `fetch-fail` (the server could not fetch the current
|
||||
media, `target` its name). `reason` carries `auto`/`manual` for ready.
|
||||
`rename`: `actor` is the new name, `target` the old one. `avatar`: the
|
||||
actor changed only the picture. `queue-*`: `target` is the item's
|
||||
title, `reason` (skip) why it could not play (see "Queue").
|
||||
- Chat is delivered twice: a `chat` frame with the text (`fromId` the
|
||||
sender's member id) and a textless `feed` with `action:"chat"`. Render
|
||||
the `chat` frame 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.client` to one member. A hello with a known
|
||||
client id (and a `seq` not older than the member's) takes that member
|
||||
over: `memberId` and `token` stay, so `/m` `/r` `/j` URLs keep working;
|
||||
the ready flag, presence, queue items, relay, picture all stay; an older
|
||||
socket still open gets `err` `replaced` and closes (4013).
|
||||
`welcome.resumed` is 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 (feed `leave`, reason
|
||||
`grace`), its token stops working; a hello with its client id after
|
||||
that is a new member. `bye` leaves at once.
|
||||
- `members[].presence`: `joining` (new, back from `gone`, or back `in`
|
||||
from `away`, while media is up: it loads in the background until its
|
||||
report holds the current media at the server position with
|
||||
`bufferNeed` buffered, or reports an error, or 60 s pass; then `in`),
|
||||
`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 member `away` when it dropped comes
|
||||
back `away`. With no media up a joiner is `in` at once.
|
||||
- What each presence counts for is one table, `PresencePolicy` in core
|
||||
(`presence.dart`), used by the server and the app: the gate counts
|
||||
`in`, and `joining` only while `held` and for at most 15 s after it
|
||||
started joining (never while `playing`: a joiner never pauses anyone);
|
||||
the ready counter and preloads count `joining` and `in`; watched
|
||||
tracking and full social notifications count `in` (`joining` too 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.media` and `queue[].media`; passed through as
|
||||
is (the server drops non-integer ids and cuts `title` to 200
|
||||
characters). `kind` `episode`, `movie` or `extra` (else no ref); every
|
||||
other field optional; `season` absent for absolute numbering. At most
|
||||
2 KiB of JSON, else `err` `bad`. **Reserved**: nothing produces it yet
|
||||
(step 4).
|
||||
|
||||
## Avatars
|
||||
|
||||
@@ -278,6 +433,8 @@ 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}
|
||||
```
|
||||
@@ -309,6 +466,12 @@ anything else means one file.
|
||||
channel is connected, or the server could read the link). `tracks`: as
|
||||
the swap named them, or, with `fetch`, set once the server has read the
|
||||
link (together with `live`).
|
||||
- `sourceGone` (omitted when false) and `sourceGoneSince` (ISO): the
|
||||
source member is `gone` (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: `live` goes, `sourceGone`
|
||||
stays, its URLs answer 404. A swap ends it as before. The warm relay's
|
||||
`queue[0].media.relay` carries the same fields.
|
||||
- `state.media.relay.rate` (omitted while unknown): bytes per second the
|
||||
source (with `fetch`, the server's own fetch) delivers while the buffer
|
||||
is waiting on it, smoothed over about 4 s of that time and present once
|
||||
@@ -368,11 +531,23 @@ anything else means one file.
|
||||
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`.
|
||||
first `info`. The source's token works through its grace, so a source
|
||||
back from a drop dials the channel again (before or after its own
|
||||
`hello`) and sends its `info` again: the same as its first (track
|
||||
kinds, byte sizes, piece shapes, and `name` / `duration` where 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 (the `pull` frames below are that exchange; nothing else is
|
||||
needed). Another `info` closes 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>} first frame, one file
|
||||
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
|
||||
@@ -397,26 +572,31 @@ server -> source {"t":"pull","id":1,"track":0,"start":0,"end":1048575}
|
||||
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.
|
||||
An `info` whose tracks differ from the swap's `tracks` (count, bytes or
|
||||
pieces), or a malformed piece list, closes the channel with 4001. A
|
||||
track `size` or piece count/roles different from the one before (a
|
||||
reconnect) drops that track's buffer.
|
||||
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 joining member is `ready=false`; it is `inSet`
|
||||
at once unless the server is `playing`, then from its first `ready`
|
||||
(members present at a swap are put in the set by it). 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.
|
||||
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. Behavior (who puts an item up, the countdown,
|
||||
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](ARCHITECTURE.md), "Up next".
|
||||
|
||||
```
|
||||
@@ -425,11 +605,14 @@ client {"t":"queue","op":"add","at":"end"|"next","item":{<item>},"thumb":"<base6
|
||||
{"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>"}
|
||||
"by":"m2","byName":"Bob","key":"<adder's handle>","auto":true?,"for":2?}
|
||||
state "advance":{"id":"q3","at":"<server time>","secs":10} reserved
|
||||
```
|
||||
|
||||
- `add`: `item` without `id`, `by`, `byName`, `thumb` (the server sets
|
||||
@@ -461,6 +644,19 @@ state "queue":[<item>,...], "queuePlay":"q3"
|
||||
the member who can put it up to do so now. `skip` is `play` of the
|
||||
first item. `queuePlay` ends with any swap, when the item is removed,
|
||||
or after 30 s.
|
||||
- `auto` (with `for`, the `mediaId` it continues): the continuation (the
|
||||
next episode). Only for the current media (else `err` `bad`), and only
|
||||
from the member who put it up (`media.swapBy`) or, when that member is
|
||||
not `in`, from the first member who is (else `err` `forbidden`). At most
|
||||
one: a newer one replaces it; it stays after the other items (an `add`
|
||||
goes before it, `next` puts an item first, `move` never passes it, it
|
||||
does not move itself); any swap that does not put it up drops it.
|
||||
- `cancel`: drops the `auto` item and the countdown for everyone (feed
|
||||
`advance-cancel`); nothing to drop, nothing happens.
|
||||
- `state.advance` (**reserved**, step 6): the shared countdown when the
|
||||
media finishes and an item is first; at `at` + `secs` the server would
|
||||
set `queuePlay` to `id`, holding while its performer or source is
|
||||
`gone`. The server never starts one yet; `cancel` and 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 a `server` item the swap may
|
||||
|
||||
Reference in new issue
Block a user