Jellyfin refs on the wire + guest relay (proto 7)

- Media can be a Jellyfin ref {serverId, itemId, mediaSourceId, title,
  subtitle, runtimeSecs}; the server never stores or broadcasts a URL
  or token for it.
- Optional server-side Jellyfin credential (serve map / --jellyfin
  file) enables GET /j/<sid>/<token>/<itemId>: range-faithful proxy of
  the active item only, exact client header fields, upstream aborted on
  disconnect, bytes counted. welcome.relayUrl / state.relay advertise it.
- Members report jf access (own account vs relay).
- /m now sends the real Content-Type (Relic overrode it).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
foxyzandClaude Opus 5.5 committed 2026-10-07 01:57:04 +03:00
1 parent fb2927c094
commit 45f4c1bc84
18 files changed
+1363 -66

No files matched your search

+39 -4
View File
@@ -8,7 +8,7 @@ Plain overview first: [HOW-IT-WORKS.md](HOW-IT-WORKS.md). Wire format:
| Path | What | Rule |
|---|---|---|
| `packages/core` | protocol messages, sync engine (`SyncEngine`), constants, config, Range parsing, logging, Jellyfin client | pure Dart, no Flutter |
| `packages/server` | Relic HTTP + WebSocket server (`SyncServer`, `ServerHub`, `Session`, media endpoint), the `serve()` entrypoint, `bin/server.dart` (dedicated server) | pure Dart, no Flutter |
| `packages/server` | Relic HTTP + WebSocket server (`SyncServer`, `ServerHub`, `Session`, media endpoint, Jellyfin relay), the `serve()` entrypoint, `bin/server.dart` (dedicated server) | pure Dart, no Flutter |
| `apps/desktop` | Flutter app: UI, `SyncClient`, `SessionCoordinator`, `PlaybackController` (media_kit/mpv), `ServerIsolate`, updater | ships with `watch-together-server` |
| `tool` | harnesses: `range_logger`, `shaper`, `fake_viewer`, `e2e_check` | dev only |
| `deploy` | systemd unit for the dedicated server | |
@@ -40,7 +40,7 @@ all sockets and returns, and the isolate exits.
- **Dedicated server**: `bin/server.dart` compiled with `dart compile exe`
and shipped next to the app as `watch-together-server`. It parses flags
(`--port`, `--secret`, `--name`, `--media`, `--advertise-host`,
`--config`; default port 8080) into the same map and calls `serve`;
`--jellyfin <file>`, `--config`; default port 8080) into the same map and calls `serve`;
SIGINT and SIGTERM stop it. A Flutter binary cannot run without a
display, which is why the server is its own build.
@@ -137,7 +137,7 @@ Additive wire changes only; bump `proto` and PROTOCOL.md in the same commit.
## Media
Two sources, same sync.
Three sources, same sync.
- **Link**: the media field holds an http(s) URL. It travels as `media.url`
in `swap` and `state`, and as `mediaUrl` in `welcome`. Each app plays it
@@ -153,6 +153,12 @@ Two sources, same sync.
(`bytesServed`); nothing is stored. The host plays through loopback like
everyone else.
- **Jellyfin item**: the media is a ref (`{serverId, itemId, ...}`, wire in
[PROTOCOL.md](PROTOCOL.md) "Jellyfin media"); the server never stores or
sends a URL or token for it. Each app resolves it with its own account and
direct-plays the static stream; guests use the relay (see "Jellyfin
relay").
Range table (`resolveMediaRequest`, tested byte-exact):
| Request | Response |
@@ -278,12 +284,41 @@ Gotchas (Jellyfin 10.11):
failure).
- Jellyfin overwrites a token's stored device name and app version with the
header's. Only use tokens this app obtained itself; a relay using a
viewer's token must send that viewer's client fields.
viewer's token must send that viewer's client fields (the server relay
sends exactly its credential's fields).
- Static streams (`/Videos/{id}/stream?static=true`) serve the original file
with byte ranges. The share URL has no credentials; each side adds its own
`api_key` (`authorizeUrl`).
- Ticks: 10,000,000 per second.
## Jellyfin relay
For viewers without an account on the item's Jellyfin
(`packages/server/lib/src/jellyfin_relay.dart`). The server may hold one
`JellyfinCredential` (config key `jellyfin`: `baseUrl`, `serverId`, `token`,
`client`, `device`, `deviceId`, `version`; primitives, so it crosses the
isolate boundary in the `serve()` map). The hosting app fills it with its
own app-obtained token and that token's device fields; the dedicated server
reads it from `--jellyfin <file>` (YAML/JSON with those keys, keeps the
token off the command line) or a `jellyfin:` map in `--config`. An empty
`serverId` is looked up at start (`/System/Info/Public`); if that fails the
relay stays off. The token is never logged (`toString` and config parse
errors leave it out).
- Available (`state.relay`) only while the current media is a ref whose
`serverId` equals the credential's. Only that item is relayed, only to a
valid, unkicked viewer token: no open proxy.
- Each request opens `/Videos/{id}/stream?static=true&mediaSourceId=` with
`Authorization: MediaBrowser` built from exactly the configured fields
(Jellyfin rewrites a token's device fields from the header), forwards
`Range` untouched and `Accept-Encoding: identity`, and returns Jellyfin's
status and range headers. Bytes stream through (no buffering) and count
toward `bytesServed` as sent. A viewer disconnect cancels the upstream
response, which closes that connection (tested: `HttpClientRequest.abort`
does nothing once the response has arrived; cancelling the response
subscription does).
- No progress reporting in the server: watched state is per client.
## Desktop UI
One `HomeScreen` (`screens/home/`) with a single top bar (`top_bar.dart`):
+54 -9
View File
@@ -1,4 +1,4 @@
# Protocol (proto = 6)
# Protocol (proto = 7)
Wire format only. Behavior (gate, clock, timeouts) is in
[ARCHITECTURE.md](ARCHITECTURE.md). Source of truth:
@@ -21,19 +21,22 @@ with different `proto` refuse each other at `hello` (`proto mismatch`).
- `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 (see
"Jellyfin media" below).
## Client to server
```
{"t":"hello","proto":6,"secret":"","user":{"name":"","avatar":""},
{"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":"report","position":123.1,"buffered":6.2,"eventId":41,
"duration":1440.5,"mediaId":2}
"duration":1440.5,"mediaId":2,"jf":{"<serverId>":true}}
{"t":"ping","c":1767225600000}
{"t":"chat","text":"..."}
{"t":"kick","target":"<memberId>","reason":""}
@@ -58,23 +61,30 @@ Field notes:
- `report.duration` / `report.mediaId` (optional, omitted when 0): the open
media's length in seconds and the `mediaId` it belongs to. The server takes
the first one whose `mediaId` is current while its media duration is 0.
- `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
up to 64 characters; others are dropped.
- `ping.c`: the client's clock in epoch ms; answered with `pong`.
- `user.avatar`: base64 image, at most 256 KiB of characters. `name` is
trimmed, empty becomes `?`, cut to 32 characters.
- `chat.text` is cut to 500 characters.
- `swap.media.url` is the link; `name`, `size`, `duration` are informational.
`swap.media.jellyfin` makes it Jellyfin media (see below); then `url` is
dropped.
- Frames before `welcome` (other than `hello`) are ignored; unparsable or
unknown frames are ignored silently.
## Server to client
```
{"t":"welcome","token":"...","memberId":"m1","mediaUrl":"...","state":{<state>}}
{"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?}|absent,
"members":[{"id","name","avatar","ready","inSet","buffered","stale"}],
"seekBy":"m1","seekSeq":3}
"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>"}
@@ -86,7 +96,9 @@ Field notes:
media URL. `mediaUrl` is 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.state` has the same shape as `state`.
`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`).
- `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
@@ -94,7 +106,9 @@ Field notes:
`eventId` increases on seek, swap and transport changes.
`mediaId` increases on swap. `media.duration` is 0 until a client reports
it. `seekBy` (member id) and `seekSeq` are present only while the current
`eventId` came from a seek.
`eventId` came from a seek. `relay` (omitted when false): the server can
relay the current Jellyfin media. `members[].jf` is the member's last
`report.jf`, omitted when it never sent one.
- `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.
@@ -107,6 +121,37 @@ Field notes:
`feed` with `action:"chat"`. Render the `chat` frame 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`, `itemId` required; `mediaSourceId`, `subtitle`, `runtimeSecs`
optional (omitted when absent); `title` always sent (may be empty).
`runtimeSecs` is 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 `url` sent along is dropped). `serverId`, `itemId` and
`mediaSourceId` must match `[A-Za-z0-9-]{1,64}`, otherwise `bad swap media`.
`media.name` is the swap's name, else the ref's `title`; 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.relay` is true: `welcome.relayUrl`,
or after a swap built like the `/m` one 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. `Range` is forwarded
as sent; status, `Content-Range`, `Content-Length`, `Content-Type` and
`Accept-Ranges` come 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 toward `bytesServed`.
- Pasted Jellyfin links sent as plain `url` stay plain links.
## Join and reconnect
`hello` then `welcome`, then the app opens `mediaUrl`, seeks to the server