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>
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/seekedon 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_SECONDSinclient/js/views/player.js) — avoids fighting small timing noise with constant re-seeks.
- Event-driven: every
- 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 throughserver/as a proxy instead of direct — video/imagesrcURLs wouldn't need this (no CORS for those). /Users/{userId}for token validation — chosen deliberately over a guessed/Users/Meshortcut (not confirmed to exist on this Emby version), but not confirmed to behave as expected either./Videos/{id}/master.m3u8for HLS playback, withVideoCodec=h264&AudioCodec=aacforcing 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-Authorizationheader 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.