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:
1 parent
fb2927c094
commit
45f4c1bc84
18 files changed
+1363
-66
No files matched your search
+39
-4
@@ -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
@@ -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
|
||||
|
||||
Reference in new issue
Block a user