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>
156 lines
7.8 KiB
Markdown
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.
|