watchparty/docs/architecture.md
Nicolas 6ff9aa29e2 Pivot to a standalone app - Emby plugin hit a hard wall
No plugin API exists for adding a button to Emby's player or item
detail page (confirmed by an Emby developer on their own forum, and
by research since). The only way to reach that would be patching
Emby's shipped web client files directly - outside the plugin,
breaks on every image update, needs manual reapplication. Not worth
the ongoing cost, per discussion.

New direction: a standalone app with its own player. Reads Emby's
catalog/streams directly from the browser (no plugin, no menu
integration fights); a small Node backend only coordinates party
membership and relays playback state over WebSocket - it never
touches a media byte. Auth is Emby-only, as chosen: the browser signs
in against Emby directly and this backend validates whatever token it
gets handed by asking Emby itself (GET /Users/{userId}), never seeing
a password.

Control model changed too: now that we own the player, host controls
playback and everyone else follows (event-driven play/pause/seek +
a 3s heartbeat, drift-corrected on the follower side) - the old
plugin's "everyone keeps their own controls, shared reference
position" was specifically a concession to nudging pre-existing Emby
app sessions we didn't control, which no longer applies.

- server/: Express + ws backend (party lifecycle, WebSocket protocol,
  Emby token validation)
- client/: plain-JS/ES-modules frontend (login, search, item detail,
  party player) - no build step
- Smoke-tested locally end to end against a stubbed Emby URL: server
  starts, routing/auth-gating works, error handling degrades
  gracefully (network failure, invalid token) - see the session
  transcript for the actual browser test run
- docs/architecture.md rewritten around the new design, including why
  the plugin was abandoned and a fresh "assumptions to verify" list
  (CORS between this app's origin and Emby's is the biggest unknown)
- src/WatchParty/ (the Emby plugin) kept as-is for reference, no
  longer the active direction

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-13 20:48:55 +02:00

156 lines
7.8 KiB
Markdown

# Architecture
## Goals
- Watch the same title, in sync, with other users of the same Emby server.
- Use Emby as the media source (catalog + streaming) without requiring
changes to Emby itself or its official clients.
- Access gated to people with an Emby account on this server — no anonymous
use, no separate identity system.
## Non-goals
- Chat/reactions — deliberately out of scope.
- OIDC/SSO — an OIDC provider (LemonLDAP::NG) is available in principle, but
the explicit choice was Emby-only auth: simpler, no extra infra dependency,
and everyone already has an Emby account.
- Full library navigation (Shows → Seasons → Episodes) in v1 — search-based
catalog only, to keep the first version achievable. Revisit if browsing
turns out to matter more than searching in practice.
## Why this replaced an Emby plugin
The first attempt (still in [`src/WatchParty/`](../src/WatchParty/), kept for
reference) was a real Emby Server plugin: sync via `ISessionManager`'s
existing remote-control commands, a settings page via `IHasWebPages`, and —
after a fair amount of reverse-engineering — a real user-facing page
integrated into Emby's own web client menu (`PluginPageInfo.EnableInUserMenu`,
undocumented but confirmed working; required a `data-controller` AMD module
to satisfy Emby's modern `ViewManager`, found by reading the target server's
actual `viewmanager.js`).
That plugin builds and installs correctly. What it hit a hard wall on: **a
button inside the video player itself**, or on an item's detail page. An
Emby developer confirmed on their own forum that plugins have no API for
this ("It's currently not possible for a plugin to do this"), and nothing
found in research since suggests that's changed. The only way to get a
button into Emby's own player would be patching Emby's shipped web client
files directly inside the Docker container — outside the plugin entirely,
guaranteed to break on every Emby image update, and requiring manual
reapplication each time. That trade-off wasn't worth it.
Building a standalone app sidesteps the question entirely: it's our own
player, so there's no "how do I add a button to someone else's UI" problem
in the first place, and sync can be tighter than nudging Emby's
remote-control commands ever allowed (see below).
Full trace of the plugin investigation (real errors, real fixes, sources
consulted) is in the git history of this file and of `src/WatchParty/` —
worth reading if a future Emby SDK update ever makes in-player integration
possible and this gets revisited.
## Components
```
server/ Node.js + Express + ws. Owns party/room state and the realtime
sync channel. Never touches a media byte - see "Media never
flows through our backend" below.
client/ Static frontend, plain JS + ES modules, no build step (same bias
as the earlier plugin's control panel). Talks to two different
things and must not confuse them:
- Emby directly (embyApi.js): catalog, images, streaming.
- Our own backend (party.js): create/join/leave a party, and
the WebSocket for realtime sync.
```
### Auth
Emby-only, as decided. The browser calls Emby's own
`POST /Users/AuthenticateByName` directly (same call the old plugin's
control panel used) and keeps the resulting `AccessToken`/`User.Id` in
`localStorage`. Our backend never sees a password — only ever a token the
browser already obtained from Emby, sent as a header (`X-Emby-Token` /
`X-Emby-UserId`) on REST calls, or as the first WebSocket message (not a
query string — tokens don't belong in URLs). The backend validates a token
by asking Emby itself (`GET /Users/{userId}` with that token) rather than
trying to verify anything cryptographically — see `server/src/embyAuth.js`.
### Media never flows through our backend
`client/js/embyApi.js` builds Emby URLs (images, HLS stream) directly, and
the browser fetches them straight from Emby. `server/` only ever sees JSON
party-coordination traffic and a handful of bytes per WebSocket message. The
one deliberate exception: `<img>`/`<video>` elements can't send custom
headers, so their URLs carry the Emby token as an `api_key` query parameter
— unavoidable, and the same thing Emby's own web client does for images.
### Control model: host drives, everyone else follows
Now that we own the player, the classic watch-party model (Teleparty,
Amazon Watch Party, ...) fits better than the old plugin's "everyone keeps
their own controls, shared reference position" — that design was specifically
a concession to nudging *pre-existing, independently-running* Emby app
sessions we didn't control. Here, there's only one player implementation,
so:
- The party creator is the host. Their player has normal controls.
- Everyone else's `<video>` has controls hidden; a small notice says the
host is in charge.
- Host → server → everyone else, over the WebSocket
(`server/src/wsHandler.js`):
- Event-driven: every `play`/`pause`/`seeked` on the host's video sends
`{type:"playstate", isPaused, positionTicks}` immediately.
- Heartbeat: also sent every 3s regardless, so latecomers/drift get
corrected even without a discrete host action.
- Followers apply pause/unpause immediately, and only seek if drift
exceeds 1.5s (`DRIFT_TOLERANCE_SECONDS` in `client/js/views/player.js`)
— avoids fighting small timing noise with constant re-seeks.
- If the host leaves, the server promotes another member (same
fallback logic the old plugin used for its "reference" member).
### Party lifecycle
In-memory only on the backend (`server/src/partyManager.js`) — a restart
clears every party, which is fine: a party is meant to last one watch
session, not persist for weeks. `removeIdleParties` sweeps anything with no
activity for 4 hours.
## Assumptions to verify
Unlike the plugin (which got to compile against Emby's real SDK assemblies),
none of the Emby REST API assumptions below have been exercised against a
real server yet — only smoke-tested against a stubbed/fake Emby URL to
confirm the *app's own* code paths (routing, error handling) don't crash
(see git history). Check these first when testing for real:
- **CORS** — the browser calls Emby from a different origin than this app.
If Emby (or the openresty reverse proxy in front of it) doesn't answer
preflight requests permissively, every direct call (including login)
fails with a browser-level CORS error, not a normal HTTP error. Open the
browser console on first test; if you see "CORS" anywhere, the fix is
routing `embyApi.js`'s JSON calls through `server/` as a proxy instead of
direct — video/image `src` URLs wouldn't need this (no CORS for those).
- **`/Users/{userId}` for token validation** — chosen deliberately over a
guessed `/Users/Me` shortcut (not confirmed to exist on this Emby
version), but not confirmed to behave as expected either.
- **`/Videos/{id}/master.m3u8`** for HLS playback, with
`VideoCodec=h264&AudioCodec=aac` forcing a broadly-compatible transcode
target. Works for basically anything Emby can transcode, but is never the
most efficient path (no direct play, no bitrate/quality negotiation) —
fine for v1, worth revisiting once the basic flow works.
- **Response field names** (`AccessToken`, `User.Id`, `User.Name`,
`Items[].Id/Name/Overview/ProductionYear`) — long-standing Emby/Jellyfin
convention, same ones the old plugin's control panel used, not
independently re-confirmed for this Emby version.
- **`X-Emby-Authorization` header format** — same caveat as above.
## Known limitations
- Search-only catalog (see Non-goals) — no way to browse a show's episode
list yet.
- One item per party; no shared queue.
- A party member needs a browser tab open and connected for sync to apply —
there's no "catch up automatically when you come back" beyond whatever
the next heartbeat corrects.
- No persistence: server restart or long idle time ends every party.