From 6ff9aa29e291255448834b0cda779b87d5ed8691 Mon Sep 17 00:00:00 2001 From: Nicolas Date: Sun, 13 Sep 2026 20:48:55 +0200 Subject: [PATCH] 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 --- .claude/launch.json | 11 + .gitignore | 4 +- README.md | 129 +++-- client/css/app.css | 176 +++++++ client/index.html | 14 + client/js/app.js | 24 + client/js/embyApi.js | 110 +++++ client/js/party.js | 75 +++ client/js/router.js | 44 ++ client/js/views/browse.js | 59 +++ client/js/views/itemDetail.js | 58 +++ client/js/views/login.js | 46 ++ client/js/views/player.js | 102 ++++ docker-compose.yml | 10 + docs/architecture.md | 395 +++++----------- server/.dockerignore | 2 + server/.env.example | 8 + server/Dockerfile | 17 + server/package-lock.json | 869 ++++++++++++++++++++++++++++++++++ server/package.json | 18 + server/src/embyAuth.js | 46 ++ server/src/index.js | 83 ++++ server/src/partyManager.js | 121 +++++ server/src/wsHandler.js | 112 +++++ 24 files changed, 2200 insertions(+), 333 deletions(-) create mode 100644 .claude/launch.json create mode 100644 client/css/app.css create mode 100644 client/index.html create mode 100644 client/js/app.js create mode 100644 client/js/embyApi.js create mode 100644 client/js/party.js create mode 100644 client/js/router.js create mode 100644 client/js/views/browse.js create mode 100644 client/js/views/itemDetail.js create mode 100644 client/js/views/login.js create mode 100644 client/js/views/player.js create mode 100644 docker-compose.yml create mode 100644 server/.dockerignore create mode 100644 server/.env.example create mode 100644 server/Dockerfile create mode 100644 server/package-lock.json create mode 100644 server/package.json create mode 100644 server/src/embyAuth.js create mode 100644 server/src/index.js create mode 100644 server/src/partyManager.js create mode 100644 server/src/wsHandler.js diff --git a/.claude/launch.json b/.claude/launch.json new file mode 100644 index 0000000..28306ee --- /dev/null +++ b/.claude/launch.json @@ -0,0 +1,11 @@ +{ + "version": "0.0.1", + "configurations": [ + { + "name": "watchparty-server", + "runtimeExecutable": "node", + "runtimeArgs": ["server/src/index.js"], + "port": 3000 + } + ] +} diff --git a/.gitignore b/.gitignore index bc9b986..f559d1a 100644 --- a/.gitignore +++ b/.gitignore @@ -9,8 +9,10 @@ obj/ .idea/ *.swp -## Node (only relevant if control-panel tooling is ever added) +## Node (server/) node_modules/ +.env +.env.local ## OS cruft .DS_Store diff --git a/README.md b/README.md index a49bc06..934b2fa 100644 --- a/README.md +++ b/README.md @@ -1,97 +1,94 @@ # Watch Party for Emby -An Emby Server plugin that lets a group of users on the same server watch the -same title together. Nobody switches players: everyone keeps using their -normal Emby app (web, mobile, TV, whatever), and the plugin nudges playback -back in sync using the same remote-control commands Emby already uses for -casting / "play on another device". +A standalone web app for watching the same title, in sync, with other users +of the same Emby server. It reads your Emby catalog and streams your Emby +media, but it's its own app with its own player — not an Emby plugin. See [docs/architecture.md](docs/architecture.md) for the full design, -including which parts are solidly confirmed against Emby's SDK docs and which -still need verification against your exact server build. +including why this replaced an earlier Emby-plugin attempt (still in +[src/WatchParty/](src/WatchParty/) for reference — see below). -## How it's meant to work +## How it works -1. You start playing a title in your normal Emby app. -2. In Emby's web client (or the Electron desktop app), open **Watch Party** - from the menu and start a party. You get a 6-character join code. No - separate login — it's a real page inside Emby, using your existing session. -3. Friends open the same **Watch Party** menu item, start playing the *same* - title in their own Emby app, and enter the code to join. -4. The server treats one member (the host, by default) as the reference - position, and periodically nudges everyone else's session back in sync - (seek correction, and mirroring pause/unpause) — see - [docs/architecture.md](docs/architecture.md#sync-algorithm). - -The Watch Party page is a companion panel, not a video player — it never -touches the actual video stream, playback stays in your normal Emby app. - -**Known limitation**: the menu item shows up on Web and Electron clients. -Official mobile/TV apps don't surface arbitrary plugin pages, so Watch Party -won't appear there — see [docs/architecture.md](docs/architecture.md#components). +1. Sign in with your Emby account. +2. Search your Emby catalog for a title. +3. Start a party (you become the host) or join one with a 6-character code. +4. The host's play/pause/seek drives everyone else's player automatically, + over a small backend that only ever coordinates timing — actual video + always streams directly from your Emby server to each viewer's browser. ## Status -Builds clean (0 errors, 0 warnings) against the real `MediaBrowser.Server.Core` -/ `MediaBrowser.Common` 4.9.1.90 assemblies from NuGet, and is installed on a -real Emby Server (Docker) — the plugin loads and its admin page shows up. -The actual feature (`partyPage.html`, reached via `EnableInUserMenu`) hasn't -been exercised live yet. See -[docs/architecture.md](docs/architecture.md#assumptions-to-verify) for -exactly what was wrong on the first guess, what was fixed, and what's still -open. - -## Requirements - -- Emby Server (developed against 4.9.5.0; should work on nearby 4.9.x/4.8.x - builds, but the plugin ABI does shift between versions) -- [.NET SDK](https://dotnet.microsoft.com/download) 8.0 to build (installed - per-user under `%USERPROFILE%\.dotnet` in this environment, since the - machine-wide winget install needed admin elevation that wasn't available — - see git history if you need to redo this on another machine) +Freshly scaffolded and smoke-tested (server starts, routes render, error +handling works — see git history) with a stubbed Emby server. **Not yet run +against a real Emby server.** The biggest open question is CORS: the browser +calls Emby directly from a different origin, which needs Emby (or its +reverse proxy) to answer that cross-origin requests are allowed — see +[docs/architecture.md](docs/architecture.md#assumptions-to-verify). ## Project layout ``` -src/WatchParty/ the plugin itself (C#, netstandard2.0) - Plugin.cs plugin registration + both plugin pages - Configuration/ plugin settings, the admin settings page, and - partyPage.html - the actual user-facing feature, - integrated into Emby's own menu (not a separate app) - Parties/ Party / PartyMember model + in-memory PartyManager - Sync/ the drift-correction background loop - Api/ REST endpoints, called from partyPage.html -docs/architecture.md full design doc +server/ Node.js backend: party/room coordination + WebSocket sync. + Never touches media bytes - only validates Emby tokens + (by asking Emby itself) and relays playback state. + src/index.js Express app + REST endpoints + src/wsHandler.js WebSocket protocol (see comments in the file) + src/partyManager.js in-memory room state + src/embyAuth.js token validation against your Emby server + +client/ Static frontend (plain JS, ES modules, no build step). + Talks directly to Emby for catalog/images/streaming, and + to server/ only for party coordination. + js/embyApi.js Emby REST calls (auth, search, images, stream URLs) + js/party.js our own backend's REST + WebSocket client + js/views/ one file per screen (login, browse, item, player) + +src/WatchParty/ The earlier Emby-plugin attempt. Builds fine and installs, + but Emby has no supported extension point for a player + button, and even menu integration required reverse- + engineering undocumented internals - see + docs/architecture.md for the full story. Kept for + reference; not the active direction. + +docs/architecture.md full design doc for the standalone app ``` -## Building +## Running locally ```bash -dotnet build src/WatchParty/WatchParty.csproj +cd server +npm install +EMBY_SERVER_URL=https://your-emby-server npm start ``` -To have a build drop the DLL straight into a local Emby Server's plugin -folder, set `EMBY_PLUGINS_DIR` before building, e.g.: +Then open `http://localhost:3000`. + +## Deploying + +Build the Docker image from the **repo root** (it needs both `server/` and +`client/`): ```bash -EMBY_PLUGINS_DIR="C:/ProgramData/Emby-Server/programdata/plugins" dotnet build src/WatchParty/WatchParty.csproj +docker build -f server/Dockerfile -t watchparty . +docker run -d --name watchparty -p 3000:3000 -e EMBY_SERVER_URL=https://theater.media.kyuno.fr watchparty ``` -Adjust the path for however your server is installed (Windows service vs. -portable install vs. Docker volume). +Then put it behind your existing reverse proxy like everything else +(`theater.media.kyuno.fr`, `git.dev.kyuno.fr`, ...), e.g. +`watchparty.kyuno.fr` → `http://:3000`. ## Roadmap -- [x] Design doc + scaffold -- [x] Build against the real SDK, fix whatever the `TODO(verify)` spots got wrong -- [x] Install on a real Emby Server (Docker) -- [x] Integrate into Emby's own UI instead of a standalone web app - (`EnableInUserMenu` — see [docs/architecture.md](docs/architecture.md#components)) +- [x] Design + scaffold the standalone app +- [x] Smoke-test locally (routing, error handling) without a real Emby server +- [ ] Verify against the real Emby server (CORS is the main risk - see above) - [ ] Try a full party end-to-end with two accounts -- [ ] Live (WebSocket) updates in the party page instead of polling -- [ ] Per-party override of sync settings instead of only server-wide defaults +- [ ] TV show navigation (seasons/episodes) beyond flat search +- [ ] Tune transcoding target / support direct play when possible -No chat/reactions — deliberately out of scope, not just deferred. +No chat, no OIDC — deliberately out of scope (Emby-only auth was the +explicit choice; see docs/architecture.md). ## License diff --git a/client/css/app.css b/client/css/app.css new file mode 100644 index 0000000..79bc1bb --- /dev/null +++ b/client/css/app.css @@ -0,0 +1,176 @@ +:root { + color-scheme: light dark; + --bg: #111418; + --panel: #1b1f26; + --text: #e8eaed; + --muted: #9aa4b2; + --accent: #5b8cff; + --danger: #ff6b6b; + --ok: #4cd97b; +} + +* { box-sizing: border-box; } + +html, body { + margin: 0; + height: 100%; +} + +body { + background: var(--bg); + color: var(--text); + font-family: system-ui, -apple-system, "Segoe UI", sans-serif; +} + +#app { min-height: 100%; } + +.wrap { + max-width: 480px; + margin: 0 auto; + padding: 32px 20px 80px; +} + +.wrap.wide { max-width: 900px; } + +h1 { font-size: 22px; margin: 0; } +h2 { font-size: 16px; margin: 0 0 8px; } + +.topbar { + display: flex; + align-items: baseline; + justify-content: space-between; + margin-bottom: 20px; +} + +.panel { + background: var(--panel); + border-radius: 12px; + padding: 20px; +} + +.hint { color: var(--muted); font-size: 13px; margin: 0 0 16px; } +.error { color: var(--danger); font-size: 13px; min-height: 18px; } + +label { + display: block; + font-size: 13px; + color: var(--muted); + margin-bottom: 12px; +} + +input { + display: block; + width: 100%; + margin-top: 4px; + padding: 10px; + border-radius: 8px; + border: 1px solid #333a45; + background: #0e1116; + color: var(--text); + font-size: 14px; +} + +input[type="search"] { margin-bottom: 20px; } + +button { + padding: 12px 16px; + border: none; + border-radius: 8px; + background: var(--accent); + color: white; + font-size: 14px; + font-weight: 600; + cursor: pointer; +} + +button.linklike { + background: none; + color: var(--muted); + font-size: 12px; + font-weight: 400; + text-decoration: underline; + padding: 4px 0; +} + +.or { text-align: center; color: var(--muted); font-size: 12px; margin: 16px 0; } +.code { color: var(--accent); letter-spacing: .1em; } + +.grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(140px, 1fr)); + gap: 16px; +} + +.card { + display: flex; + flex-direction: column; + gap: 6px; + color: var(--text); + text-decoration: none; + font-size: 13px; +} + +.card img { + width: 100%; + aspect-ratio: 2 / 3; + object-fit: cover; + border-radius: 8px; + background: var(--panel); +} + +.detail { + display: flex; + gap: 20px; + margin-top: 16px; +} + +.detail img { + width: 160px; + border-radius: 8px; + flex-shrink: 0; +} + +.detail button { width: 100%; margin-top: 8px; } + +.playerWrap { + position: relative; + width: 100%; + height: 100vh; + background: #000; +} + +.playerWrap video { + width: 100%; + height: 100%; + object-fit: contain; +} + +.overlay { + position: absolute; + top: 0; + left: 0; + right: 0; + display: flex; + align-items: center; + gap: 16px; + padding: 12px 16px; + background: linear-gradient(to bottom, rgba(0,0,0,.7), transparent); + color: white; + font-size: 13px; +} + +.overlay .members { color: var(--muted); flex: 1; } + +.followerNotice { + position: absolute; + bottom: 24px; + left: 50%; + transform: translateX(-50%); + background: rgba(0,0,0,.6); + padding: 8px 16px; + border-radius: 20px; + font-size: 13px; + color: var(--muted); +} + +.hide { display: none; } diff --git a/client/index.html b/client/index.html new file mode 100644 index 0000000..c31f550 --- /dev/null +++ b/client/index.html @@ -0,0 +1,14 @@ + + + + + + Watch Party + + + + +
+ + + diff --git a/client/js/app.js b/client/js/app.js new file mode 100644 index 0000000..17398ec --- /dev/null +++ b/client/js/app.js @@ -0,0 +1,24 @@ +import { route, start, navigate } from './router.js'; +import { session } from './embyApi.js'; +import { renderLogin } from './views/login.js'; +import { renderBrowse } from './views/browse.js'; +import { renderItemDetail } from './views/itemDetail.js'; +import { renderPartyPlayer } from './views/player.js'; + +function requireAuth(renderFn) { + return (root, params) => { + if (!session.isLoggedIn) { + navigate('/login'); + return; + } + renderFn(root, params); + }; +} + +route('/', () => navigate(session.isLoggedIn ? '/browse' : '/login')); +route('/login', renderLogin); +route('/browse', requireAuth(renderBrowse)); +route('/item/:id', requireAuth(renderItemDetail)); +route('/party/:id', requireAuth(renderPartyPlayer)); + +start(document.getElementById('app')); diff --git a/client/js/embyApi.js b/client/js/embyApi.js new file mode 100644 index 0000000..ba02f23 --- /dev/null +++ b/client/js/embyApi.js @@ -0,0 +1,110 @@ +// Direct browser <-> Emby calls: catalog, images, streaming. Session/token +// lives in localStorage, scoped to this browser only. Nothing here ever +// talks to our own backend - see party.js for that. +// +// TODO(verify): these calls go cross-origin (this app's domain -> Emby's +// domain), which needs Emby (or its reverse proxy) to answer CORS preflight +// requests. If login/search silently fail with a network error and the +// browser console shows a CORS complaint, this whole approach needs a proxy +// through our own backend instead - see docs/architecture.md. + +function authHeaderValue() { + let deviceId = localStorage.getItem('wp_deviceId'); + if (!deviceId) { + deviceId = 'watchparty-' + crypto.randomUUID(); + localStorage.setItem('wp_deviceId', deviceId); + } + return `MediaBrowser Client="WatchParty", Device="Browser", DeviceId="${deviceId}", Version="0.1.0"`; +} + +export const session = { + get serverUrl() { return localStorage.getItem('wp_serverUrl') || ''; }, + get token() { return localStorage.getItem('wp_token') || ''; }, + get userId() { return localStorage.getItem('wp_userId') || ''; }, + get userName() { return localStorage.getItem('wp_userName') || ''; }, + get isLoggedIn() { return !!(this.serverUrl && this.token && this.userId); }, + set(serverUrl, token, userId, userName) { + localStorage.setItem('wp_serverUrl', serverUrl); + localStorage.setItem('wp_token', token); + localStorage.setItem('wp_userId', userId); + localStorage.setItem('wp_userName', userName); + }, + clear() { + ['wp_serverUrl', 'wp_token', 'wp_userId', 'wp_userName'].forEach((k) => localStorage.removeItem(k)); + } +}; + +async function embyFetch(path, options = {}) { + const res = await fetch(session.serverUrl.replace(/\/+$/, '') + path, { + ...options, + headers: { + 'X-Emby-Authorization': authHeaderValue(), + ...(session.token ? { 'X-Emby-Token': session.token } : {}), + ...(options.headers || {}) + } + }); + if (!res.ok) { + throw new Error(`Emby request failed: ${res.status}`); + } + const text = await res.text(); + return text ? JSON.parse(text) : null; +} + +export async function login(serverUrl, username, password) { + const cleanUrl = serverUrl.trim().replace(/\/+$/, ''); + const res = await fetch(cleanUrl + '/Users/AuthenticateByName', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'X-Emby-Authorization': authHeaderValue() + }, + body: JSON.stringify({ Username: username, Pw: password }) + }); + if (!res.ok) { + throw new Error('Identifiants refusés.'); + } + const data = await res.json(); + session.set(cleanUrl, data.AccessToken, data.User.Id, data.User.Name); + return data; +} + +export async function searchItems(query) { + if (!query || query.trim().length < 2) { + return []; + } + const params = new URLSearchParams({ + searchTerm: query, + IncludeItemTypes: 'Movie,Episode,Series', + Recursive: 'true', + Fields: 'Overview,ProductionYear', + ImageTypeLimit: '1', + Limit: '24' + }); + const data = await embyFetch(`/Users/${session.userId}/Items?${params.toString()}`); + return (data && data.Items) || []; +} + +export async function getItem(itemId) { + return embyFetch(`/Users/${session.userId}/Items/${itemId}`); +} + +export function imageUrl(itemId, maxWidth = 300) { + // can't send custom headers, so the token has to ride along in the + // query string here - the one deliberate exception to keeping tokens out + // of URLs. This is the same thing Emby's own web client does for images. + const params = new URLSearchParams({ api_key: session.token, maxWidth: String(maxWidth) }); + return `${session.serverUrl}/Items/${itemId}/Images/Primary?${params.toString()}`; +} + +export function streamUrl(itemId) { + // Same deliberate exception as imageUrl: