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

7.8 KiB

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/, 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.