Proxy all Emby access through our own backend; drop server-url field; use Emby's real theme colors
All checks were successful
Publier l'image Docker / publish (push) Successful in 6s

Three changes requested:

1. Server proxies Emby, browser never talks to it directly. Login is
   now POST /api/auth/login {username,password} on our own backend;
   the backend calls Emby's AuthenticateByName server-side and mints
   its own opaque session (httpOnly cookie), storing
   {embyToken,userId,userName} in memory. The browser never sees or
   holds a real Emby access token. Every other call - search, item
   details, images, HLS streaming, party actions - goes to our own
   /api/* origin, authenticated by that cookie; server/src/embyProxy.js
   forwards to Emby server-side and streams the response back
   (including a playlist-rewrite pass for HLS .m3u8 so hls.js never
   gets handed a URL pointing at Emby directly). This also sidesteps
   the CORS problem the previous direct-to-Emby design had.

2. EMBY_SERVER_URL is backend-only config (.env.example); the login
   form no longer asks for a server address at all.

3. client/css/app.css now uses colors read directly off the live Emby
   server's own dark theme (--theme-primary-color-* etc, fetched via a
   JS probe against the running client, not guessed): accent green
   #52b54b/#6ccf65, near-black background #151216.

Tested for real: ran the server locally against the actual
EMBY_SERVER_URL and submitted intentionally-wrong credentials through
the UI - confirmed the full round trip (browser -> our backend -> real
Emby -> real 401 -> our error message) with no CORS issue, since it's
all server-to-server now. Login itself hasn't been exercised with real
credentials, so catalog/streaming remain unverified - see
docs/architecture.md.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Nicolas 2026-09-13 21:36:26 +02:00
parent 64710fa779
commit b6818655b1
15 changed files with 541 additions and 290 deletions

View file

@ -10,38 +10,41 @@ including why this replaced an earlier Emby-plugin attempt (still in
## How it works
1. Sign in with your Emby account.
1. Sign in with your Emby account (just username/password — the server
address is fixed server-side, see Deploying below).
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.
4. The host's play/pause/seek drives everyone else's player automatically.
Everything — catalog, images, video, sync — flows through this app's own
backend, which proxies whatever it needs from Emby server-side. The browser
never talks to Emby directly and never holds an Emby access token; it only
ever has a session cookie for this app.
## Status
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).
Login has been exercised end to end against the real Emby server (with
intentionally-wrong credentials, to confirm the round trip without needing
real ones) — see [docs/architecture.md](docs/architecture.md#assumptions-to-verify)
for exactly what that confirms and what's still unverified beyond it
(mainly: HLS playback once actually logged in).
## Project layout
```
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.
server/ Node.js backend: proxies ALL Emby access (auth, catalog,
images, HLS streaming) and coordinates party/room state +
realtime sync. The browser only ever talks to this.
src/index.js Express app + REST endpoints
src/embyAuth.js server-side login against Emby + session cookies
src/embyProxy.js generic Emby request/response proxying (incl. HLS)
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
Talks only to server/ - never to Emby directly.
js/embyApi.js calls our own /api/* routes (auth, search, item, stream URL)
js/party.js our own backend's party REST + WebSocket client
js/views/ one file per screen (login, browse, item, player)
src/WatchParty/ The earlier Emby-plugin attempt. Builds fine and installs,

View file

@ -1,12 +1,17 @@
/* Colors pulled live from this Emby server's own active theme (dark), via
its CSS custom properties (--theme-primary-color-*, --background-*, etc.)
- see docs/architecture.md for how these were read directly off the
running server rather than guessed. */
:root {
color-scheme: light dark;
--bg: #111418;
--panel: #1b1f26;
--text: #e8eaed;
--muted: #9aa4b2;
--accent: #5b8cff;
color-scheme: dark;
--bg: #151216; /* hsl(285, 10%, 7.84%) - Emby's dark theme background */
--bg-header: #221d24; /* same hue, +5% lightness - Emby's header/footer background */
--panel: #262626; /* Emby's card/button/input background (achromatic, ~15-17% lightness) */
--text: #ffffff;
--muted: rgba(255, 255, 255, .6);
--accent: #52b54b; /* Emby's theme-primary-color (hsl(116, 41.7%, 50.2%)) */
--accent-bright: #6ccf65; /* Emby's --theme-accent-text-color-darkbg */
--danger: #ff6b6b;
--ok: #4cd97b;
}
* { box-sizing: border-box; }
@ -43,7 +48,7 @@ h2 { font-size: 16px; margin: 0 0 8px; }
}
.panel {
background: var(--panel);
background: var(--bg-header);
border-radius: 12px;
padding: 20px;
}
@ -64,12 +69,17 @@ input {
margin-top: 4px;
padding: 10px;
border-radius: 8px;
border: 1px solid #333a45;
background: #0e1116;
border: 1px solid #3a3a3a;
background: var(--panel);
color: var(--text);
font-size: 14px;
}
input:focus {
outline: none;
border-color: var(--accent);
}
input[type="search"] { margin-bottom: 20px; }
button {
@ -83,6 +93,8 @@ button {
cursor: pointer;
}
button:hover { background: var(--accent-bright); }
button.linklike {
background: none;
color: var(--muted);
@ -92,8 +104,10 @@ button.linklike {
padding: 4px 0;
}
button.linklike:hover { background: none; color: var(--accent-bright); }
.or { text-align: center; color: var(--muted); font-size: 12px; margin: 16px 0; }
.code { color: var(--accent); letter-spacing: .1em; }
.code { color: var(--accent-bright); letter-spacing: .1em; }
.grid {
display: grid;
@ -128,6 +142,7 @@ button.linklike {
width: 160px;
border-radius: 8px;
flex-shrink: 0;
background: var(--panel);
}
.detail button { width: 100%; margin-top: 8px; }

View file

@ -1,13 +1,13 @@
import { route, start, navigate } from './router.js';
import { session } from './embyApi.js';
import { auth, restoreSession } 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) {
return async (root, params) => {
if (!auth.isLoggedIn && !(await restoreSession())) {
navigate('/login');
return;
}
@ -15,7 +15,7 @@ function requireAuth(renderFn) {
};
}
route('/', () => navigate(session.isLoggedIn ? '/browse' : '/login'));
route('/', () => navigate('/browse'));
route('/login', renderLogin);
route('/browse', requireAuth(renderBrowse));
route('/item/:id', requireAuth(renderItemDetail));

View file

@ -1,110 +1,63 @@
// 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.
// Talks ONLY to our own backend now - never directly to Emby. The backend
// holds the real Emby session (server/src/embyAuth.js); this module just
// wraps fetch calls to our own /api/* routes. See docs/architecture.md for
// why this changed from the first version (CORS, plus not wanting the
// browser to hold an Emby token at all).
function authHeaderValue() {
let deviceId = localStorage.getItem('wp_deviceId');
if (!deviceId) {
deviceId = 'watchparty-' + crypto.randomUUID();
localStorage.setItem('wp_deviceId', deviceId);
async function apiFetch(path, options = {}) {
const res = await fetch(path, {
credentials: 'same-origin',
...options,
headers: { 'Content-Type': 'application/json', ...(options.headers || {}) }
});
if (!res.ok) {
const body = await res.json().catch(() => ({}));
throw new Error(body.error || `Request failed: ${res.status}`);
}
return `MediaBrowser Client="WatchParty", Device="Browser", DeviceId="${deviceId}", Version="0.1.0"`;
return res.json();
}
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));
}
export const auth = {
userName: null,
get isLoggedIn() { return !!this.userName; }
};
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 || {})
/** Call once on app start to find out if our session cookie is still valid. */
export async function restoreSession() {
try {
const data = await apiFetch('/api/auth/me');
auth.userName = data.userName;
return true;
} catch (err) {
auth.userName = null;
return false;
}
});
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', {
export async function login(username, password) {
const data = await apiFetch('/api/auth/login', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-Emby-Authorization': authHeaderValue()
},
body: JSON.stringify({ Username: username, Pw: password })
body: JSON.stringify({ username, 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);
auth.userName = data.userName;
return data;
}
export async function logout() {
await apiFetch('/api/auth/logout', { method: 'POST' }).catch(() => {});
auth.userName = null;
}
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) || [];
if (!query || query.trim().length < 2) return [];
const data = await apiFetch(`/api/catalog/search?q=${encodeURIComponent(query)}`);
return data.items || [];
}
export async function getItem(itemId) {
return embyFetch(`/Users/${session.userId}/Items/${itemId}`);
}
export function imageUrl(itemId, maxWidth = 300) {
// <img> 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()}`;
return apiFetch(`/api/catalog/item/${itemId}`);
}
export function streamUrl(itemId) {
// Same deliberate exception as imageUrl: <video src>/hls.js can't attach
// headers either. VideoCodec/AudioCodec pinned to a broadly-compatible
// target so Emby transcodes whatever it needs to - good enough for v1,
// not tuned for quality/bandwidth yet.
const params = new URLSearchParams({
api_key: session.token,
VideoCodec: 'h264',
AudioCodec: 'aac'
});
return `${session.serverUrl}/Videos/${itemId}/master.m3u8?${params.toString()}`;
return `/api/catalog/stream/${itemId}/master.m3u8`;
}

View file

@ -1,17 +1,12 @@
// Everything that talks to OUR OWN backend: party REST endpoints, and the
// realtime WebSocket. Same origin as this page, so no CORS concerns here -
// contrast with embyApi.js.
import { session } from './embyApi.js';
// Party REST + realtime WebSocket - same origin as everything else, auth is
// the session cookie (see embyApi.js), so no headers/tokens to pass around
// here at all.
async function apiFetch(path, options = {}) {
const res = await fetch(path, {
credentials: 'same-origin',
...options,
headers: {
'Content-Type': 'application/json',
'X-Emby-Token': session.token,
'X-Emby-UserId': session.userId,
...(options.headers || {})
}
headers: { 'Content-Type': 'application/json', ...(options.headers || {}) }
});
if (!res.ok) {
const body = await res.json().catch(() => ({}));
@ -37,16 +32,17 @@ export function getParty(partyId) {
}
/**
* Opens the realtime connection for a party. The Emby token is sent as the
* FIRST WebSocket message, not a query string - tokens don't belong in URLs
* (they'd end up in server logs, browser history, etc.).
* Opens the realtime connection for a party. Auth rides along on the
* WebSocket's HTTP upgrade handshake as the session cookie (browsers attach
* cookies to that handshake automatically for a same-origin URL) - so all
* this needs to send is which party to join.
*/
export function connectParty(partyId, { onMembers, onPlaystate, onError, onClose } = {}) {
const proto = location.protocol === 'https:' ? 'wss://' : 'ws://';
const ws = new WebSocket(proto + location.host + '/ws');
ws.addEventListener('open', () => {
ws.send(JSON.stringify({ type: 'auth', token: session.token, userId: session.userId, partyId }));
ws.send(JSON.stringify({ type: 'join', partyId }));
});
ws.addEventListener('message', (evt) => {

View file

@ -1,4 +1,4 @@
import { searchItems, imageUrl, session } from '../embyApi.js';
import { searchItems, auth, logout } from '../embyApi.js';
import { navigate } from '../router.js';
export function renderBrowse(root) {
@ -6,14 +6,14 @@ export function renderBrowse(root) {
<main class="wrap wide">
<div class="topbar">
<h1>Watch Party</h1>
<button class="linklike" id="logout">Se déconnecter (${session.userName})</button>
<button class="linklike" id="logout">Se déconnecter (${escapeHtml(auth.userName || '')})</button>
</div>
<input id="search" type="search" placeholder="Cherche un film, une série, un épisode..." autofocus />
<div id="results" class="grid"></div>
</main>`;
root.querySelector('#logout').addEventListener('click', () => {
session.clear();
root.querySelector('#logout').addEventListener('click', async () => {
await logout();
navigate('/login');
});
@ -44,9 +44,9 @@ export function renderBrowse(root) {
return;
}
results.innerHTML = items.map((item) => `
<a class="card" href="#/item/${item.Id}">
<img src="${imageUrl(item.Id)}" alt="" loading="lazy" />
<span>${escapeHtml(item.Name)}${item.ProductionYear ? ' (' + item.ProductionYear + ')' : ''}</span>
<a class="card" href="#/item/${item.id}">
<img src="${item.imageUrl}" alt="" loading="lazy" />
<span>${escapeHtml(item.name)}${item.year ? ' (' + item.year + ')' : ''}</span>
</a>
`).join('');
}

View file

@ -1,4 +1,4 @@
import { getItem, imageUrl } from '../embyApi.js';
import { getItem } from '../embyApi.js';
import { createParty, joinParty } from '../party.js';
import { navigate } from '../router.js';
@ -10,10 +10,10 @@ export function renderItemDetail(root, params) {
<main class="wrap">
<button class="linklike" id="back">&larr; Retour</button>
<div class="detail">
<img src="${imageUrl(item.Id, 400)}" alt="" />
<img src="${item.imageUrl}" alt="" />
<div>
<h1>${escapeHtml(item.Name)}</h1>
<p>${escapeHtml(item.Overview || '')}</p>
<h1>${escapeHtml(item.name)}</h1>
<p>${escapeHtml(item.overview || '')}</p>
<button id="createBtn">Démarrer une watch party</button>
<div class="or">ou</div>
@ -30,7 +30,7 @@ export function renderItemDetail(root, params) {
root.querySelector('#createBtn').addEventListener('click', async () => {
try {
const party = await createParty(item.Id);
const party = await createParty(item.id);
navigate(`/party/${party.partyId}`);
} catch (err) {
root.querySelector('#error').textContent = err.message;

View file

@ -7,11 +7,8 @@ export function renderLogin(root) {
<h1>Watch Party</h1>
<form id="loginForm" class="panel">
<p class="hint">Utilise ton compte Emby habituel.</p>
<label>Adresse du serveur Emby
<input id="serverUrl" placeholder="https://theater.media.kyuno.fr" required />
</label>
<label>Nom d'utilisateur
<input id="username" autocomplete="username" required />
<input id="username" autocomplete="username" required autofocus />
</label>
<label>Mot de passe
<input id="password" type="password" autocomplete="current-password" required />
@ -22,25 +19,15 @@ export function renderLogin(root) {
</main>`;
const form = root.querySelector('#loginForm');
const serverUrlInput = form.querySelector('#serverUrl');
serverUrlInput.value = localStorage.getItem('wp_lastServerUrl') || '';
form.addEventListener('submit', async (e) => {
e.preventDefault();
const errorEl = form.querySelector('#error');
errorEl.textContent = '';
const serverUrl = serverUrlInput.value.trim();
const username = form.querySelector('#username').value;
const password = form.querySelector('#password').value;
localStorage.setItem('wp_lastServerUrl', serverUrl);
try {
await login(serverUrl, username, password);
await login(form.querySelector('#username').value, form.querySelector('#password').value);
navigate('/browse');
} catch (err) {
errorEl.textContent =
"Connexion impossible - vérifie l'adresse du serveur, le nom d'utilisateur et le mot de passe. " +
"(Si ça persiste, ouvre la console du navigateur : une erreur CORS s'affiche différemment d'un simple refus d'identifiants.)";
errorEl.textContent = "Connexion impossible - vérifie ton nom d'utilisateur et ton mot de passe.";
}
});
}

View file

@ -1,4 +1,4 @@
import { getItem, streamUrl, session } from '../embyApi.js';
import { getItem, streamUrl } from '../embyApi.js';
import { getParty, leaveParty, connectParty } from '../party.js';
import { navigate } from '../router.js';
@ -55,13 +55,13 @@ export function renderPartyPlayer(root, params) {
return;
}
isHost = party.hostUserId === session.userId;
isHost = party.youAreHost;
codeEl.textContent = party.joinCode;
video.controls = isHost;
followerNotice.classList.toggle('hide', isHost);
const item = await getItem(party.itemId);
const src = streamUrl(item.Id);
const src = streamUrl(item.id);
if (window.Hls && window.Hls.isSupported()) {
const hls = new window.Hls();
hls.loadSource(src);

View file

@ -64,26 +64,44 @@ client/ Static frontend, plain JS + ES modules, no build step (same bias
the WebSocket for realtime sync.
```
### Auth
### Auth and Emby access: the server proxies everything
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`.
Revised after the first version: the browser used to call Emby directly
(catalog, images, streaming) and only used `server/` for party coordination.
That hit real CORS problems in practice (Emby/its reverse proxy don't
answer cross-origin preflights the way that needed), and separately wasn't
the wanted shape anyway — the browser holding a real Emby access token at
all was more exposure than necessary. Current design:
### Media never flows through our backend
- The browser sends a username/password **once**, to
`POST /api/auth/login` on **our own backend** — never to Emby directly.
- Our backend (`server/src/embyAuth.js`) calls Emby's
`POST /Users/AuthenticateByName` itself, using `EMBY_SERVER_URL` from its
own environment (not something the user ever types — see `.env.example`).
On success it mints its own opaque session id, stores
`{embyToken, userId, userName}` server-side (in-memory, like party state),
and hands the browser only that session id, as an `httpOnly` cookie
(`wp_session`). The browser never sees or holds Emby's real access token.
- Every other request — catalog search, item details, images, HLS
streaming, party actions — goes to **our own origin only**
(`/api/...`), authenticated by that same cookie. `server/src/embyProxy.js`
is what actually forwards these to Emby server-side and streams the
response back, attaching the session's real Emby token itself.
- The `/api/catalog/stream/:itemId/master.m3u8` and `.../raw` routes proxy
HLS specifically: the master playlist is fetched from Emby server-side,
and every URI line in it is rewritten to point back through `.../raw`
(whether Emby wrote it as relative or absolute) — otherwise hls.js would
follow the playlist's own URLs straight to Emby the moment it tried to
fetch a segment, defeating the whole point. See `rewritePlaylist` in
`embyProxy.js`.
- Images (`<img src>`) and video (`hls.js`/`<video>`) still can't carry a
bearer header, but that's fine now: they're same-origin requests to our
own `/api/...` paths, so the session cookie rides along automatically —
no token in any URL anywhere, unlike the first version's `?api_key=...`.
`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.
This also means the client no longer needs to ask for a server URL at all —
one less field, one less way to get it wrong. `EMBY_SERVER_URL` lives only
in the backend's environment.
### Control model: host drives, everyone else follows
@ -118,31 +136,34 @@ 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:
**Confirmed against the real server** (not a guess — actually exercised,
see session history): `POST /Users/AuthenticateByName` really is reachable
from Node's server-side `fetch` with no CORS issue at all (server-to-server
has no CORS concept), returns a real 401 for bad credentials, and the whole
login round-trip (browser → our backend → real Emby → real rejection → our
error message) renders correctly end to end. The `--theme-primary-color-*`
etc. custom properties `client/css/app.css` is built from were read directly
off this exact server's live dark theme, not guessed.
- **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.
**Not yet exercised against the real server** — a successful login hasn't
happened yet (no real credentials used in testing), so nothing past that
point is confirmed:
- **`/Users/{userId}` for token validation** was dropped — the proxy
redesign no longer needs it at all (sessions are validated once at login
and cached server-side, not re-checked against Emby per-request).
- **`/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.
- **HLS playlist rewriting** (`rewritePlaylist` in `embyProxy.js`) — the
logic is straightforward (rewrite every non-`#` line) but has only been
reasoned about, never run against a playlist Emby actually generated. If
video loads the manifest but segments 404 or hang, start here.
- **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.
convention, not independently re-confirmed for this Emby version.
- **`X-Emby-Authorization` header format** — same caveat as above.
## Known limitations

View file

@ -1,46 +1,107 @@
'use strict';
const EMBY_SERVER_URL = process.env.EMBY_SERVER_URL || '';
const crypto = require('crypto');
const EMBY_SERVER_URL = (process.env.EMBY_SERVER_URL || '').replace(/\/+$/, '');
if (!EMBY_SERVER_URL) {
console.warn(
'[watchparty] EMBY_SERVER_URL is not set - Emby token validation will always fail. ' +
'[watchparty] EMBY_SERVER_URL is not set - every Emby call will fail. ' +
'Set it to your Emby server base URL, e.g. https://theater.media.kyuno.fr'
);
}
const SESSION_COOKIE = 'wp_session';
const SESSION_TTL_MS = 30 * 24 * 60 * 60 * 1000; // 30 days
// sessionId -> { embyToken, userId, userName, createdAt }
// In-memory, like party state - a restart signs everyone out, which is fine.
const sessions = new Map();
function authHeaderValue() {
return 'MediaBrowser Client="WatchParty", Device="Server", DeviceId="watchparty-server", Version="0.1.0"';
}
/**
* Confirms an Emby access token really grants access to the given user id,
* by asking the Emby server itself (GET /Users/{userId}). This backend never
* sees passwords, and never stores a token beyond the lifetime of a single
* request - it only ever forwards a token the browser already obtained
* directly from Emby's own /Users/AuthenticateByName.
* Signs in against Emby *from the server*. The browser only ever sends its
* username/password once, to us; we forward that to Emby, and from then on
* the browser only holds an opaque session id (cookie) - never Emby's own
* access token. This is what makes "the server proxies everything" possible:
* the browser has nothing it could use to talk to Emby even if it tried.
*
* TODO(verify): /Users/{id} is long-standing, core Emby API - a safer bet
* than assuming a possible /Users/Me shortcut exists on this server version.
* This hits Emby on every party create/join/ws-auth call; add caching here
* if that ever becomes a problem in practice.
* Returns a new session id, or null on bad credentials / unreachable Emby.
*/
async function resolveEmbyUser(token, userId) {
if (!token || !userId || !EMBY_SERVER_URL) {
async function login(username, password) {
if (!EMBY_SERVER_URL) return null;
let res;
try {
res = await fetch(`${EMBY_SERVER_URL}/Users/AuthenticateByName`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-Emby-Authorization': authHeaderValue()
},
body: JSON.stringify({ Username: username, Pw: password })
});
} catch (err) {
console.error('[watchparty] Emby login request failed:', err.message);
return null;
}
try {
const res = await fetch(`${EMBY_SERVER_URL.replace(/\/+$/, '')}/Users/${encodeURIComponent(userId)}`, {
headers: { 'X-Emby-Token': token }
});
if (!res.ok) {
return null;
}
const user = await res.json();
return { id: user.Id, name: user.Name };
} catch (err) {
console.error('[watchparty] Emby token validation failed:', err.message);
return null;
}
const data = await res.json();
const sessionId = crypto.randomUUID();
sessions.set(sessionId, {
embyToken: data.AccessToken,
userId: data.User.Id,
userName: data.User.Name,
createdAt: Date.now()
});
return sessionId;
}
module.exports = { resolveEmbyUser, EMBY_SERVER_URL };
function getSession(sessionId) {
const session = sessions.get(sessionId);
if (!session) return null;
if (Date.now() - session.createdAt > SESSION_TTL_MS) {
sessions.delete(sessionId);
return null;
}
return session;
}
function endSession(sessionId) {
sessions.delete(sessionId);
}
function parseCookies(cookieHeader) {
const out = {};
(cookieHeader || '').split(';').forEach((part) => {
const idx = part.indexOf('=');
if (idx === -1) return;
out[part.slice(0, idx).trim()] = decodeURIComponent(part.slice(idx + 1).trim());
});
return out;
}
/** Reads the caller's session from an Express request's cookie, or null. */
function sessionFromRequest(req) {
const cookies = parseCookies(req.headers.cookie);
const id = cookies[SESSION_COOKIE];
return id ? getSession(id) : null;
}
module.exports = {
EMBY_SERVER_URL,
SESSION_COOKIE,
SESSION_TTL_MS,
login,
getSession,
endSession,
parseCookies,
sessionFromRequest
};

79
server/src/embyProxy.js Normal file
View file

@ -0,0 +1,79 @@
'use strict';
const { Readable } = require('stream');
const { EMBY_SERVER_URL } = require('./embyAuth');
/** Fetches a path from Emby with the given user's token, forwarding Range
* (needed for direct-play video seeking) when the browser sent one. */
async function embyFetch(pathAndQuery, token, { range } = {}) {
const url = pathAndQuery.startsWith('http') ? pathAndQuery : `${EMBY_SERVER_URL}${pathAndQuery}`;
const headers = { 'X-Emby-Token': token };
if (range) headers.Range = range;
return fetch(url, { headers });
}
function isPlaylistPath(path) {
return /\.m3u8(\?|$)/i.test(path);
}
/**
* Rewrites every URI line in an HLS playlist so it routes back through our
* own proxy (`/api/catalog/stream/:itemId/raw?u=...`) instead of Emby
* directly - whether Emby wrote that line as a relative or an absolute URL.
* Without this, hls.js would follow the playlist's own URLs straight to
* Emby, defeating the point of proxying the master playlist in the first
* place. TODO(verify): only tested against synthetic input, not a real
* Emby-generated playlist - see docs/architecture.md.
*/
function rewritePlaylist(text, itemId) {
return text
.split('\n')
.map((line) => {
const trimmed = line.trim();
if (!trimmed || trimmed.startsWith('#')) return line;
return `/api/catalog/stream/${itemId}/raw?u=${encodeURIComponent(trimmed)}`;
})
.join('\n');
}
/**
* Proxies an Emby response to an Express response: forwards status,
* content-type, and the headers that make byte-range seeking work
* (content-range/accept-ranges), rewriting HLS playlists in transit and
* streaming everything else (images, video segments) through untouched -
* no buffering, so this doesn't hold a whole video segment in memory.
*/
async function proxyEmbyResponse(req, res, pathAndQuery, token, itemId) {
let embyRes;
try {
embyRes = await embyFetch(pathAndQuery, token, { range: req.headers.range });
} catch (err) {
res.status(502).json({ error: 'Serveur Emby injoignable.' });
return;
}
res.status(embyRes.status);
const contentType = embyRes.headers.get('content-type') || '';
if (contentType) res.setHeader('Content-Type', contentType);
for (const h of ['content-range', 'accept-ranges']) {
const v = embyRes.headers.get(h);
if (v) res.setHeader(h, v);
}
if (isPlaylistPath(pathAndQuery) || contentType.includes('mpegurl')) {
const text = await embyRes.text();
res.send(rewritePlaylist(text, itemId));
return;
}
const contentLength = embyRes.headers.get('content-length');
if (contentLength) res.setHeader('Content-Length', contentLength);
if (!embyRes.body) {
res.end();
return;
}
Readable.fromWeb(embyRes.body).pipe(res);
}
module.exports = { embyFetch, proxyEmbyResponse, isPlaylistPath };

View file

@ -5,75 +5,203 @@ const http = require('http');
const express = require('express');
const { WebSocketServer } = require('ws');
const { resolveEmbyUser, EMBY_SERVER_URL } = require('./embyAuth');
const {
EMBY_SERVER_URL,
SESSION_COOKIE,
SESSION_TTL_MS,
login,
getSession,
endSession,
parseCookies,
sessionFromRequest
} = require('./embyAuth');
const { embyFetch, proxyEmbyResponse } = require('./embyProxy');
const { PartyManager } = require('./partyManager');
const { attachWebSocketServer } = require('./wsHandler');
const app = express();
app.set('trust proxy', 1); // behind openresty/nginx - needed for req.secure to reflect the real scheme
app.use(express.json());
// The frontend is served from the same origin as this API, so none of these
// routes need CORS. The browser's OWN calls to Emby itself (catalog, images,
// video) are a different story - see docs/architecture.md.
// Everything the browser needs - the app shell AND every Emby call - is
// same-origin from here on. See docs/architecture.md for why: the previous
// version had the browser call Emby directly and hit real CORS problems.
app.use(express.static(path.join(__dirname, '..', '..', 'client')));
app.get('/api/config', (_req, res) => {
res.json({ embyServerUrl: EMBY_SERVER_URL || null });
});
function auth(req, res, next) {
const token = (req.get('X-Emby-Token') || '').trim();
const userId = (req.get('X-Emby-UserId') || '').trim();
resolveEmbyUser(token, userId).then((user) => {
if (!user) {
res.status(401).json({ error: 'Session Emby invalide ou expirée.' });
function requireSession(req, res, next) {
const session = sessionFromRequest(req);
if (!session) {
res.status(401).json({ error: 'Session expirée, reconnecte-toi.' });
return;
}
req.embyUser = user;
req.embySession = session;
next();
});
}
app.post('/api/parties', auth, (req, res) => {
// --- Auth ----------------------------------------------------------------
// The browser only ever sends a username/password once, to us. From then on
// it holds an opaque session cookie - never Emby's own access token.
app.post('/api/auth/login', async (req, res) => {
const { username, password } = req.body || {};
if (!username || !password) {
res.status(400).json({ error: "Nom d'utilisateur et mot de passe requis." });
return;
}
const sessionId = await login(username, password);
if (!sessionId) {
res.status(401).json({ error: 'Identifiants refusés.' });
return;
}
res.cookie(SESSION_COOKIE, sessionId, {
httpOnly: true,
sameSite: 'lax',
secure: req.secure,
maxAge: SESSION_TTL_MS
});
res.json({ userName: getSession(sessionId).userName });
});
app.post('/api/auth/logout', (req, res) => {
const cookies = parseCookies(req.headers.cookie);
if (cookies[SESSION_COOKIE]) {
endSession(cookies[SESSION_COOKIE]);
}
res.clearCookie(SESSION_COOKIE);
res.json({ ok: true });
});
app.get('/api/auth/me', requireSession, (req, res) => {
res.json({ userName: req.embySession.userName });
});
// --- Catalog (proxied - the browser never talks to Emby directly) --------
app.get('/api/catalog/search', requireSession, async (req, res) => {
const q = (req.query.q || '').trim();
if (q.length < 2) {
res.json({ items: [] });
return;
}
const params = new URLSearchParams({
searchTerm: q,
IncludeItemTypes: 'Movie,Episode,Series',
Recursive: 'true',
Fields: 'Overview,ProductionYear',
ImageTypeLimit: '1',
Limit: '24'
});
const embyRes = await embyFetch(`/Users/${req.embySession.userId}/Items?${params}`, req.embySession.embyToken);
if (!embyRes.ok) {
res.status(502).json({ error: 'Recherche impossible côté Emby.' });
return;
}
const data = await embyRes.json();
res.json({ items: (data.Items || []).map(toItemDto) });
});
app.get('/api/catalog/item/:itemId', requireSession, async (req, res) => {
const embyRes = await embyFetch(`/Users/${req.embySession.userId}/Items/${req.params.itemId}`, req.embySession.embyToken);
if (!embyRes.ok) {
res.status(embyRes.status).json({ error: 'Titre introuvable.' });
return;
}
res.json(toItemDto(await embyRes.json()));
});
// Images/video are requested by <img>/<video> elements, which send the
// session cookie (same-origin) but can't carry a bearer header - so these
// two routes check the cookie directly instead of using the requireSession
// middleware's JSON error response, which a browser media element ignores.
app.get('/api/catalog/image/:itemId', async (req, res) => {
const session = sessionFromRequest(req);
if (!session) { res.status(401).end(); return; }
await proxyEmbyResponse(req, res, `/Items/${req.params.itemId}/Images/Primary?maxWidth=400`, session.embyToken, req.params.itemId);
});
app.get('/api/catalog/stream/:itemId/master.m3u8', async (req, res) => {
const session = sessionFromRequest(req);
if (!session) { res.status(401).end(); return; }
const params = new URLSearchParams({ VideoCodec: 'h264', AudioCodec: 'aac' });
await proxyEmbyResponse(req, res, `/Videos/${req.params.itemId}/master.m3u8?${params}`, session.embyToken, req.params.itemId);
});
// Everything an HLS playlist references (variant playlists, segments) comes
// back through here - see embyProxy.rewritePlaylist.
app.get('/api/catalog/stream/:itemId/raw', async (req, res) => {
const session = sessionFromRequest(req);
if (!session) { res.status(401).end(); return; }
const target = req.query.u || '';
const itemId = req.params.itemId;
let pathAndQuery;
if (/^https?:\/\//i.test(target)) {
if (!target.startsWith(EMBY_SERVER_URL)) {
res.status(400).end(); // never proxy an arbitrary URL, only our own Emby server
return;
}
pathAndQuery = target;
} else {
pathAndQuery = `/Videos/${itemId}/${target}`;
}
await proxyEmbyResponse(req, res, pathAndQuery, session.embyToken, itemId);
});
function toItemDto(item) {
return {
id: item.Id,
name: item.Name,
overview: item.Overview,
year: item.ProductionYear,
imageUrl: `/api/catalog/image/${item.Id}`
};
}
// --- Parties ---------------------------------------------------------------
app.post('/api/parties', requireSession, (req, res) => {
const itemId = req.body && req.body.itemId;
if (!itemId) {
res.status(400).json({ error: 'itemId is required.' });
return;
}
const party = PartyManager.create(req.embyUser.id, req.embyUser.name, itemId);
res.json(PartyManager.toPublicState(party));
const party = PartyManager.create(req.embySession.userId, req.embySession.userName, itemId);
res.json(PartyManager.toPublicState(party, req.embySession.userId));
});
app.post('/api/parties/:joinCode/join', auth, (req, res) => {
app.post('/api/parties/:joinCode/join', requireSession, (req, res) => {
const party = PartyManager.getByJoinCode(req.params.joinCode);
if (!party) {
res.status(404).json({ error: 'Aucune party avec ce code (elle est peut-être terminée).' });
return;
}
PartyManager.join(party, req.embyUser.id, req.embyUser.name);
res.json(PartyManager.toPublicState(party));
PartyManager.join(party, req.embySession.userId, req.embySession.userName);
res.json(PartyManager.toPublicState(party, req.embySession.userId));
});
app.post('/api/parties/:partyId/leave', auth, (req, res) => {
app.post('/api/parties/:partyId/leave', requireSession, (req, res) => {
const party = PartyManager.getById(req.params.partyId);
if (party) {
PartyManager.leave(party, req.embyUser.id);
PartyManager.leave(party, req.embySession.userId);
}
res.json({ ok: true });
});
app.get('/api/parties/:partyId', auth, (req, res) => {
app.get('/api/parties/:partyId', requireSession, (req, res) => {
const party = PartyManager.getById(req.params.partyId);
if (!party) {
res.status(404).json({ error: 'Party introuvable (elle est peut-être terminée).' });
return;
}
res.json(PartyManager.toPublicState(party));
res.json(PartyManager.toPublicState(party, req.embySession.userId));
});
const server = http.createServer(app);
const wss = new WebSocketServer({ server, path: '/ws' });
attachWebSocketServer(wss);
attachWebSocketServer(wss, { parseCookies, getSession });
setInterval(() => PartyManager.removeIdleParties(4 * 60 * 60 * 1000), 10 * 60 * 1000);

View file

@ -89,12 +89,18 @@ class PartyManager {
}
}
toPublicState(party) {
/**
* `viewerUserId` lets the response say "youAreHost" instead of the client
* having to know its own Emby user id to figure that out itself - now that
* auth is a server-side session (see embyAuth.js), the client doesn't hold
* an id to compare with at all.
*/
toPublicState(party, viewerUserId) {
return {
partyId: party.id,
joinCode: party.joinCode,
itemId: party.itemId,
hostUserId: party.hostUserId,
youAreHost: viewerUserId != null && viewerUserId === party.hostUserId,
members: Array.from(party.members.values()).map((m) => ({
userId: m.userId,
userName: m.userName,

View file

@ -1,27 +1,38 @@
'use strict';
const { resolveEmbyUser } = require('./embyAuth');
const { PartyManager } = require('./partyManager');
/**
* Realtime side of a party: a client connects, authenticates with its Emby
* token (as the FIRST message, not a query string - see client/js/party.js),
* and from then on either drives playback (if it's the host) or receives it.
* Realtime side of a party. Auth comes from the session cookie sent during
* the WebSocket's HTTP upgrade request - browsers attach cookies to that
* handshake the same as any other same-origin request, so there's no
* separate "send me your token" step like the previous (pre-proxy) design
* needed: sessions are server-side now (see embyAuth.js), not a token the
* client held.
*
* Protocol (all messages are JSON):
* -> {type:"auth", token, userId, partyId}
* Protocol (JSON messages):
* -> {type:"join", partyId}
* <- {type:"members", members:[...]} (on any join/leave)
* -> {type:"playstate", isPaused, positionTicks} (host only - ignored otherwise)
* <- {type:"playstate", isPaused, positionTicks} (relayed to everyone but the host)
* -> {type:"leave"}
* <- {type:"error", message}
*/
function attachWebSocketServer(wss) {
wss.on('connection', (ws) => {
let party = null;
let userId = null;
function attachWebSocketServer(wss, { parseCookies, getSession }) {
wss.on('connection', (ws, req) => {
const cookies = parseCookies(req.headers.cookie);
const session = cookies.wp_session ? getSession(cookies.wp_session) : null;
ws.on('message', async (raw) => {
if (!session) {
send(ws, { type: 'error', message: 'Session expirée - reconnecte-toi.' });
ws.close();
return;
}
const userId = session.userId;
let party = null;
ws.on('message', (raw) => {
let msg;
try {
msg = JSON.parse(raw.toString());
@ -29,31 +40,22 @@ function attachWebSocketServer(wss) {
return;
}
if (msg.type === 'auth') {
const user = await resolveEmbyUser(msg.token, msg.userId);
if (!user) {
send(ws, { type: 'error', message: 'Session Emby invalide ou expirée.' });
ws.close();
return;
}
if (msg.type === 'join') {
const p = PartyManager.getById(msg.partyId);
const member = p && p.members.get(user.id);
const member = p && p.members.get(userId);
if (!p || !member) {
send(ws, { type: 'error', message: "Tu n'es pas membre de cette party." });
ws.close();
return;
}
party = p;
userId = user.id;
member.connection = ws;
broadcastMembers(party);
return;
}
if (!party || !userId) {
return; // ignore anything before a successful auth
if (!party) {
return; // ignore anything before a successful join
}
if (msg.type === 'playstate') {
@ -77,7 +79,7 @@ function attachWebSocketServer(wss) {
});
ws.on('close', () => {
if (party && userId) {
if (party) {
PartyManager.leave(party, userId);
broadcastMembers(party);
}