Initial commit: watch-together phases 0-4 + presence hardening, internet-tested
This commit is contained in:
commit
427d851a5b
101 files changed
+11326
No files matched your search
@@ -0,0 +1,77 @@
|
||||
# ARCHITECTURE
|
||||
|
||||
One binary, three modes. `packages/server` (Relic) is embedded, not a separate product.
|
||||
|
||||
- **Client**: GUI Join, sync WS client (`web_socket_channel` in `packages/core`) + mpv. No server code runs.
|
||||
- **Server+Client (Host)**: GUI Host starts `packages/server` in-process, binds port, self-connects via loopback. Host opens ports, runs traffic. Clients open nothing outbound-only.
|
||||
- **Headless**: `--headless`, same server package, no window/player init.
|
||||
|
||||
Server side: **Relic** (HTTP + built-in WS). Client side WS: **`web_socket_channel`**
|
||||
(Relic has no client; server WS handling stays in Relic).
|
||||
|
||||
Media order (locked): links first (shared http(s) URL, Syncplay-style sync),
|
||||
file relay after (server-proxied bytes for the whitelist case).
|
||||
|
||||
## Media paths (links vs file relay)
|
||||
|
||||
Two ways to watch, same sync engine, same Range endpoint:
|
||||
|
||||
1. **Links (primary, shipped):** host shares an http(s) media URL
|
||||
(e.g. a Jellyfin Download URL). Viewers stream it directly; the
|
||||
server is sync-authority only (`media.url`, no `mediaUrl`). This
|
||||
covers most sessions — proxying is usually unnecessary.
|
||||
2. **File relay (secondary, local-file half shipped):** host serves
|
||||
bytes from their own machine through the server's token-gated
|
||||
Range endpoint `GET /m/<sid>/<token>/<name>`. Viewers (and the
|
||||
host via loopback) play the server URL. This is the fallback for
|
||||
the whitelist case: viewers cannot reach the source, but the
|
||||
server/host can (e.g. Jellyfin allowlists the host, not the
|
||||
viewers). No server storage — bytes stream file→socket, config +
|
||||
logs only, never to disk.
|
||||
|
||||
Backends behind `MediaSource` (`packages/core/lib/src/media_source.dart`):
|
||||
`FileMediaSource` (local file — SHIPPED, wired into
|
||||
`mountSessionMediaRoute`); `HttpSource` (server fetches a remote URL
|
||||
and re-serves it — DEFERRED, Phase 8). `resolveMediaRequest` stays
|
||||
framework-agnostic so both ride the same tested Range table.
|
||||
|
||||
## Deferred: remote-host upload tunnel (NOT active protocol)
|
||||
|
||||
If a VPS tier ever needs a *remote* host to push bytes to a server
|
||||
it cannot serve from disk, the reserved design is the brief's WSS
|
||||
tunnel: host dials out, JSON control frames
|
||||
(`hello`/`welcome`+mediaUrl, `open`{id,start,end}, `ack`, `cancel`,
|
||||
`eof`/`err`) plus binary `[u32 BE reqId][payload]` chunks —
|
||||
chunk 256 KiB, credit window 8 (≈2 MiB in flight), max 16 concurrent
|
||||
`open`, relay acks only after socket flush (real backpressure).
|
||||
`RelayConstants.chunkBytes/creditWindowChunks/maxConcurrentOpen`
|
||||
are reserved for this. No code exists; direct-serve covers v1
|
||||
(host IS the server, opens ports itself).
|
||||
|
||||
Host playback goes through loopback like everyone else's
|
||||
(`http://127.0.0.1:<port>/…`) — same code path as remote viewers, so
|
||||
server bugs surface on the host screen first. Deployment ladder,
|
||||
cheapest first: host's own PC (LAN, or internet with port-forward) →
|
||||
same binary `--headless` on a friend's always-on box → cheap VPS
|
||||
(CGNAT escape; TLS/Caddy deferred with this tier).
|
||||
|
||||
Sync: heavily Syncplay-inspired + original readiness design, NOT 1:1
|
||||
(audit done; deviations: clock never moves from reports, no
|
||||
pause-on-leave, force-for-all, quorum gate — see HANDOFF §2).
|
||||
Additive namespaced extensions only, `proto` bump + `PROTOCOL.md` same-commit.
|
||||
|
||||
No server storage: config + logs only. Media never touches disk.
|
||||
|
||||
## Health reporting (standing rule)
|
||||
|
||||
Every failure must display. Nothing stalls silently.
|
||||
|
||||
- Server `/health` returns machine-readable status: ok, room, viewer
|
||||
count, uptime, media, bytes served, version. The app reads it.
|
||||
- App surfaces all failure states in UI with cause + what-to-do:
|
||||
connecting/reconnecting, wrong secret (401), kicked (403), session
|
||||
gone (410), host offline / buffering (503), starvation
|
||||
("waiting for X — N.N s buffered"). Backed by a structured status
|
||||
signal (severity + message), not bare strings.
|
||||
- Errors log via `debugPrint` + stack trace at the boundary, then
|
||||
convert to a displayable state. No swallowed exceptions.
|
||||
@@ -0,0 +1,115 @@
|
||||
# PROTOCOL — v4 (Phase 3: sync engine + readiness)
|
||||
|
||||
`proto = 4`. Bump history: v1 hello/state/event shapes; v2 `token` in
|
||||
welcome; v3 `kicked`/`kick`; v4 readiness intents, room fan-out, feed,
|
||||
public member ids (tokens never leave their owner — media URLs carry
|
||||
only the requester's own token).
|
||||
|
||||
## Transport vs readiness (the core split)
|
||||
|
||||
Room transport is PLAYING or HELD. Seeks move transport position only:
|
||||
while PLAYING anyone seeks freely, everyone follows, catch-up buffering
|
||||
is tolerated with no flag changes. Readiness gates only the
|
||||
HELD→PLAYING transition. Starvation pauses transport without touching
|
||||
flags; recovery auto-resumes on buffers, no Space needed.
|
||||
|
||||
## Message catalog
|
||||
|
||||
Client→server intents (all post-hello text frames):
|
||||
|
||||
```
|
||||
{"t":"ready","ready":true,"source":"manual|auto"} Space flip / idle timer
|
||||
{"t":"force","target":"all|<memberId>","ready":false}
|
||||
{"t":"seek","position":123.4} doSeek event, room jumps
|
||||
{"t":"swap","media":{"name":..,"size":..,"duration":..,"url":..}}
|
||||
{"t":"report","position":123.1,"buffered":6.2,"eventId":41}
|
||||
{"t":"chat","text":"…"}
|
||||
{"t":"kick","target":"<memberId>","reason":"…"} v3, now by member id
|
||||
```
|
||||
|
||||
Server→client:
|
||||
|
||||
```
|
||||
{"t":"room",{eventId,transport:"playing"|"held",position,serverTime,
|
||||
mediaId,media:{…}|null,
|
||||
members:[{id,name,avatar,ready,inSet,buffered,stale}]}}
|
||||
{"t":"feed",{actor,action,target?,reason?,at}} toasts + sheet log
|
||||
{"t":"welcome",{token,memberId,mediaUrl?,state}} v2+4, state now a room
|
||||
{"t":"kicked",{reason}} {"t":"err",{message}}
|
||||
```
|
||||
|
||||
`room` broadcasts at 1 Hz and on every change. Clients diff by
|
||||
`eventId`: position jump ⇒ seek, transport change ⇒ play/pause.
|
||||
`feed` actions: ready, unready, force-ready, force-unready, force-play,
|
||||
force-hold, kick, join, leave, stale, swap, seek, pause, resume, chat —
|
||||
every one carries actor + server timestamp.
|
||||
Force-play-all is `force` with `target:"all",ready:true`; anyone not
|
||||
ready Spaces back. Sounds key off feed actions.
|
||||
|
||||
## Membership set (kills join-cascade + ghost-quorum)
|
||||
|
||||
- Join: `inSet=false, ready=false`. Catch up in background (open media,
|
||||
seek to room edge, buffer). Flipping ready joins the set.
|
||||
- Unready while in set: stays in set, room re-evaluates → HELD.
|
||||
- Disconnect: removed from set, never counts toward quorum.
|
||||
- Swap: all sets cleared, all flags false.
|
||||
- Gate to PLAYING: set non-empty AND every in-set member
|
||||
ready + buffered ≥ 4 s + fresh (not stale) AND quorum: in-set
|
||||
size ≥ 2, unless no more than one member is fresh. A lone
|
||||
watcher can play alone; the moment a second watcher is present,
|
||||
one Space waits for the other. Empty set ⇒ HELD.
|
||||
|
||||
## Clock rules (Syncplay port)
|
||||
|
||||
Server owns the advancing clock:
|
||||
`positionAt(now) = position + (playing ? now-serverTime : 0)`.
|
||||
Plain reports are informational and can never move it. Only seek, swap,
|
||||
and gate transitions write position/serverTime/eventId. Reports tagged
|
||||
with `eventId < current` have their position ignored (no ping-pong);
|
||||
buffered is still accepted. Pause (HELD) freezes position immediately;
|
||||
resume sets serverTime=now — laggards seek forward, never drag back.
|
||||
|
||||
Tiered client correction on room state: |drift| > 5 s ⇒ seek;
|
||||
ahead > 1.5 s ⇒ 0.95× until level; inside deadzone ⇒ nothing.
|
||||
|
||||
## Join state machine
|
||||
|
||||
connect → hello → welcome → open media (server mediaUrl or shared
|
||||
url) → seek to room edge → buffer → not-ready catch-up → Space →
|
||||
in set → HELD→PLAYING when gate passes. Reconnect: re-hello (tokens
|
||||
die with the socket), prior flag restored, buffer gate holds playback
|
||||
until truly caught up.
|
||||
|
||||
## Swap state machine
|
||||
|
||||
anyone sends swap → HELD, mediaId++, clock reset (0/now), sets
|
||||
cleared, flags false → all clients re-run Join → auto-resume when the
|
||||
gate passes. No authority check in v1 (same trust basis as force).
|
||||
|
||||
## Join / auth (v2–v3, unchanged)
|
||||
|
||||
proto must equal server proto; secret is the room password; avatar
|
||||
≤64 KiB; failures get `err` + close. `sid="default"`, map keyed for
|
||||
later multi-session. mediaUrl only when the server holds bytes.
|
||||
|
||||
## Media endpoint (Phase 1, unchanged)
|
||||
|
||||
`GET /m/<sid>/<token>/<name>` Range table. 410/401/403/503 gates.
|
||||
|
||||
## Heartbeat (Phase 2 baseline, feeds the gate)
|
||||
|
||||
5 s WS pings; per-token lastSeen on every frame; silent >15 s ⇒ out
|
||||
of the gate (`stale` feed); silent >30 s ⇒ socket closed, member
|
||||
removed (`leave`), client reconnects via re-hello. No limbo sockets.
|
||||
Closed sockets are swept every second (`isClosed` poll — transport
|
||||
`onDone` is unreliable, so presence never depends on it).
|
||||
Client-side reconnect loop (fixed polite interval + manual escape)
|
||||
is app work.
|
||||
|
||||
## Playlist (deferred, space reserved)
|
||||
|
||||
Not v1. Shape when it lands: server-held `queue:[media…]` alongside
|
||||
the current media; anyone appends links (`queue-add`), anyone skips
|
||||
(`queue-skip`); reaching EOF auto-advances exactly like a swap
|
||||
(HELD, mediaId++, sets cleared, Join rerun, gate resumes). No code
|
||||
until then — `mediaId` + swap already carry it.
|
||||
@@ -0,0 +1,94 @@
|
||||
# ROADMAP — sync_relay_project (canonical phase tracker)
|
||||
|
||||
This is the durable source of truth for phases. Chat is ephemeral;
|
||||
this file + `HANDOFF.md` (root) + `PROTOCOL.md` / `ARCHITECTURE.md`
|
||||
carry state across sessions. Update this file when a phase changes
|
||||
state. Proto bumps still require PROTOCOL.md same-commit.
|
||||
|
||||
Model: direct-serve (host opens ports, clients open nothing). The
|
||||
brief's old WSS host-upload tunnel is obsolete unless a VPS tier
|
||||
needs remote-host upload. Server = Relic, client WS =
|
||||
web_socket_channel. Protocol still v4.
|
||||
|
||||
## Status legend
|
||||
|
||||
- DONE: exit criteria met, tests green at the time.
|
||||
- PARTIAL: shipped + tested, exits still open.
|
||||
- TODO: not started or explicitly deferred.
|
||||
|
||||
## Phase 0 — Scaffold (DONE)
|
||||
|
||||
Monorepo, path deps, CI (`dart analyze` + `dart test` on core/server),
|
||||
logging/config/errors, Flutter-free enforcement tests, relay binary
|
||||
binds/logs/exits, stub ARCHITECTURE.md.
|
||||
|
||||
## Phase 1 — mpv HTTP contract (DONE)
|
||||
|
||||
Relic `resolveMediaRequest` + `mountMediaRoute`, full Range table
|
||||
(200 / 206×3 shapes / 416 / 401 / 403 / 410 / 503), byte-exact tests,
|
||||
real mpv traces in `mpv-http-contract.md`, `tool/range_logger.dart`
|
||||
+ `tool/shaper.dart` (KB/s cap, latency, --kill-after).
|
||||
|
||||
## Phase 2 — Sessions + media endpoint (DONE)
|
||||
|
||||
Secret → 128-bit token, 401/403/410 gates, kick kills WS+media,
|
||||
byte counters, `tool/fake_viewer.dart` ALL PASS live. Proto v2→v3
|
||||
for `kicked`/`kick`. Media: local-file relay SHIPPED
|
||||
(`FileMediaSource` + token-gated Range route); links path SHIPPED
|
||||
(server distributes shared URL, viewers stream direct);
|
||||
`HttpSource` (server-side URL fetch) DEFERRED to Phase 8; WSS
|
||||
upload tunnel NOT BUILT (spec reserved in ARCHITECTURE.md).
|
||||
|
||||
## Phase 3 — Sync engine + readiness (DONE)
|
||||
|
||||
`RoomEngine` (injectable clock): server-owned clock, reports never
|
||||
move it, stale-eventId guard, free seeks, PLAYING/HELD gate, quorum
|
||||
(in-set≥2 unless ≤1 fresh), swap reset, force with actor feed.
|
||||
22+ engine tests, `tool/e2e_check.dart` 15/15 sim. Syncplay audit
|
||||
done (conscious deviations documented in HANDOFF §2). Proto v4.
|
||||
Follow-ups landed: NaN guards, name/chat caps, stale vs leave feed,
|
||||
1 Hz feed flush, silent-drop (15 s stale / 30 s drop + close).
|
||||
|
||||
## Phase 4 — Desktop app (PARTIAL)
|
||||
|
||||
Shipped: SyncClient framing, RoomDiff (eventId diff, 0.95× latch),
|
||||
media_kit wiring, Space=ready, avatars, toasts+sheet, health display,
|
||||
reconnect loop. Counts at last green: core 42, server 40, desktop 17.
|
||||
Open exits (manual, user-driven): remove bisect flags in
|
||||
`apps/desktop/lib/app.dart`; 2-client LAN soak; 1.4 GB real-file test
|
||||
(user resupplies URL, never paste api_key into files);
|
||||
cache-exhaustion long outage; internet test (forwarded port/Tailscale).
|
||||
|
||||
## Phase 5 — Resilience (PARTIAL)
|
||||
|
||||
Shipped: isClosed sweep, onError forget, stop() closes sockets,
|
||||
silence-timeout + reconnect loop. Never run: shaper chaos suite
|
||||
(host drop 5 s / 30 s, viewer stall, 20-seek storm), OOM / stuck-room
|
||||
/ drift-recovery assertions, CI wiring.
|
||||
|
||||
## Phase 6 — Identity & social (PARTIAL)
|
||||
|
||||
Shipped: names (32 cap), avatars (64 KiB, picker + crop), kick nudge
|
||||
+ reason, feed attribution. Open: real chat pane/history (schema
|
||||
only), sounds swap-in (wiring + NoopSfxService ready; pick
|
||||
flutter_soloud or equivalent, Kenney/ObsydianX/akx sources in
|
||||
HANDOFF §7).
|
||||
|
||||
## Phase 7 — Deployment & updates (TODO, deferred)
|
||||
|
||||
Thin `bin/server.dart` compiles; systemd unit exists. Missing:
|
||||
Windows zip, Linux tar/AppDir, TLS/Caddy doc, update manifest +
|
||||
verifier. Needs explicit user go + code-signing decision.
|
||||
|
||||
## Phase 8 — Optional quality (TODO, deferred)
|
||||
|
||||
Playlist (`queue-add`/`queue-skip`, shape reserved in PROTOCOL.md),
|
||||
sidecar subtitles, local-file fallback (viewer plays matching
|
||||
size+duration file locally, still synced — saves host uplink),
|
||||
`HttpSource` (host pastes a direct-stream URL, server fetches +
|
||||
re-serves — makes Jellyfin usable through the whitelist today, no
|
||||
native integration). No code until approved.
|
||||
|
||||
## Deferred backlog (needs explicit approval)
|
||||
|
||||
Jellyfin native, P2P/WebRTC, multi-session, transcode, Android.
|
||||
@@ -0,0 +1,70 @@
|
||||
# mpv HTTP contract (Phase 1, verified against Relic endpoint)
|
||||
|
||||
Spike: `tool/range_logger.dart` (Relic, `mountMediaRoute` + `onLog` hook).
|
||||
Player: stock mpv CLI headless (`--vo=null --ao=null`). Server logs
|
||||
`METHOD path range=<h> -> status bytes=<n>`.
|
||||
|
||||
## Real traces
|
||||
|
||||
mp4, 570692 B, full play (`--frames=300`):
|
||||
|
||||
```text
|
||||
get /m/file range=- -> 200 bytes=570692
|
||||
get /m/file range=bytes=0- -> 206 bytes=570692
|
||||
```
|
||||
|
||||
One no-Range open (mpv probes full length), then a single open-ended
|
||||
stream to EOF. Small file fits demuxer cache; one connection does it all.
|
||||
|
||||
mkv (ffmpeg remux of same file), 562197 B, play + `--start=8` seek:
|
||||
|
||||
```text
|
||||
get /m/file range=bytes=0- -> 206 bytes=562197
|
||||
get /m/file range=bytes=0- -> 206 bytes=562197
|
||||
get /m/file range=bytes=562093- -> 206 bytes=104
|
||||
get /m/file range=bytes=243694- -> 206 bytes=318503
|
||||
```
|
||||
|
||||
Two `bytes=0-` opens (demuxer probe + playback), a 104 B tail read at
|
||||
`size-104` (Cues-at-EOF probe — mpv expresses it open-ended, not as
|
||||
`bytes=-n`; the server answers both identically), then a mid-file open
|
||||
at a cluster boundary for the seek/start position. Seeks always arrive
|
||||
as a fresh `bytes=N-` connection, never as a mutation of the old one.
|
||||
|
||||
Outage: server SIGINT-killed 5 s mid-play (looping file, comma-form
|
||||
reconnect flags), restarted. mpv kept playing from cache, no error, no
|
||||
reconnect storm; no new Range request inside the post-restart window.
|
||||
Short drops are ridden out from cache — matches the north-star case.
|
||||
Cache-exhaustion reconnect (long drop) is a Phase 5 chaos assertion;
|
||||
`tool/shaper.dart` (KB/s cap + latency + `--kill-after`) exists for it.
|
||||
|
||||
## Server behavior table (all byte-exact tested, 16 server tests)
|
||||
|
||||
| Request | Response |
|
||||
|---|---|
|
||||
| no Range | 200, exact `Content-Length`, full bytes |
|
||||
| `bytes=a-b` | 206, `Content-Range: a-b/size`, exact length |
|
||||
| `bytes=a-` | 206 open-ended to EOF, exact length |
|
||||
| `bytes=-n` | 206 tail `size-n..EOF` (Cues probe) |
|
||||
| unsatisfiable start | 416 + `Content-Range: bytes */size` |
|
||||
| host offline | 503 + `Retry-After: 2` (mpv retries with reconnect flags) |
|
||||
| bad token / kicked / gone | 401 / 403 / 410 |
|
||||
|
||||
Always: `Accept-Ranges: bytes`; `Content-Type` from extension
|
||||
(mkv `video/x-matroska`, mp4 `video/mp4`, webm, avi, mov, ts, mka/mp3).
|
||||
|
||||
## Relic notes
|
||||
|
||||
- Range arrives as typed `RangeHeader` (`req.headers.range`), not a raw
|
||||
string. Multi-range takes the first entry; unit is preserved so
|
||||
non-`bytes` units still 416. `resolveMediaRequest` in
|
||||
`packages/server` stays framework-agnostic and is the tested truth.
|
||||
- Reconnect flag syntax is build-sensitive: this mpv rejected the
|
||||
colon-joined `--stream-lavf-o=a=1:b=1` form and accepted commas.
|
||||
The exact media_kit property set is re-verified in Phase 4 through
|
||||
mpv `set_property`, not CLI flags.
|
||||
|
||||
## Remaining manual (human + media_kit, Phase 4)
|
||||
|
||||
1.4 GB real file through the endpoint with the pinned buffer props;
|
||||
long-outage reconnect with cache exhaustion via shaper.
|
||||
Reference in new issue
Block a user