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 ## 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. 2. Search your Emby catalog for a title.
3. Start a party (you become the host) or join one with a 6-character code. 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, 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. 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 ## Status
Freshly scaffolded and smoke-tested (server starts, routes render, error Login has been exercised end to end against the real Emby server (with
handling works — see git history) with a stubbed Emby server. **Not yet run intentionally-wrong credentials, to confirm the round trip without needing
against a real Emby server.** The biggest open question is CORS: the browser real ones) — see [docs/architecture.md](docs/architecture.md#assumptions-to-verify)
calls Emby directly from a different origin, which needs Emby (or its for exactly what that confirms and what's still unverified beyond it
reverse proxy) to answer that cross-origin requests are allowed — see (mainly: HLS playback once actually logged in).
[docs/architecture.md](docs/architecture.md#assumptions-to-verify).
## Project layout ## Project layout
``` ```
server/ Node.js backend: party/room coordination + WebSocket sync. server/ Node.js backend: proxies ALL Emby access (auth, catalog,
Never touches media bytes - only validates Emby tokens images, HLS streaming) and coordinates party/room state +
(by asking Emby itself) and relays playback state. realtime sync. The browser only ever talks to this.
src/index.js Express app + REST endpoints 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/wsHandler.js WebSocket protocol (see comments in the file)
src/partyManager.js in-memory room state 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). client/ Static frontend (plain JS, ES modules, no build step).
Talks directly to Emby for catalog/images/streaming, and Talks only to server/ - never to Emby directly.
to server/ only for party coordination. js/embyApi.js calls our own /api/* routes (auth, search, item, stream URL)
js/embyApi.js Emby REST calls (auth, search, images, stream URLs) js/party.js our own backend's party REST + WebSocket client
js/party.js our own backend's REST + WebSocket client
js/views/ one file per screen (login, browse, item, player) js/views/ one file per screen (login, browse, item, player)
src/WatchParty/ The earlier Emby-plugin attempt. Builds fine and installs, 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 { :root {
color-scheme: light dark; color-scheme: dark;
--bg: #111418; --bg: #151216; /* hsl(285, 10%, 7.84%) - Emby's dark theme background */
--panel: #1b1f26; --bg-header: #221d24; /* same hue, +5% lightness - Emby's header/footer background */
--text: #e8eaed; --panel: #262626; /* Emby's card/button/input background (achromatic, ~15-17% lightness) */
--muted: #9aa4b2; --text: #ffffff;
--accent: #5b8cff; --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; --danger: #ff6b6b;
--ok: #4cd97b;
} }
* { box-sizing: border-box; } * { box-sizing: border-box; }
@ -43,7 +48,7 @@ h2 { font-size: 16px; margin: 0 0 8px; }
} }
.panel { .panel {
background: var(--panel); background: var(--bg-header);
border-radius: 12px; border-radius: 12px;
padding: 20px; padding: 20px;
} }
@ -64,12 +69,17 @@ input {
margin-top: 4px; margin-top: 4px;
padding: 10px; padding: 10px;
border-radius: 8px; border-radius: 8px;
border: 1px solid #333a45; border: 1px solid #3a3a3a;
background: #0e1116; background: var(--panel);
color: var(--text); color: var(--text);
font-size: 14px; font-size: 14px;
} }
input:focus {
outline: none;
border-color: var(--accent);
}
input[type="search"] { margin-bottom: 20px; } input[type="search"] { margin-bottom: 20px; }
button { button {
@ -83,6 +93,8 @@ button {
cursor: pointer; cursor: pointer;
} }
button:hover { background: var(--accent-bright); }
button.linklike { button.linklike {
background: none; background: none;
color: var(--muted); color: var(--muted);
@ -92,8 +104,10 @@ button.linklike {
padding: 4px 0; 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; } .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 { .grid {
display: grid; display: grid;
@ -128,6 +142,7 @@ button.linklike {
width: 160px; width: 160px;
border-radius: 8px; border-radius: 8px;
flex-shrink: 0; flex-shrink: 0;
background: var(--panel);
} }
.detail button { width: 100%; margin-top: 8px; } .detail button { width: 100%; margin-top: 8px; }

View file

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

View file

@ -1,110 +1,63 @@
// Direct browser <-> Emby calls: catalog, images, streaming. Session/token // Talks ONLY to our own backend now - never directly to Emby. The backend
// lives in localStorage, scoped to this browser only. Nothing here ever // holds the real Emby session (server/src/embyAuth.js); this module just
// talks to our own backend - see party.js for that. // 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
// TODO(verify): these calls go cross-origin (this app's domain -> Emby's // browser to hold an Emby token at all).
// 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() { async function apiFetch(path, options = {}) {
let deviceId = localStorage.getItem('wp_deviceId'); const res = await fetch(path, {
if (!deviceId) { credentials: 'same-origin',
deviceId = 'watchparty-' + crypto.randomUUID(); ...options,
localStorage.setItem('wp_deviceId', deviceId); 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 = { export const auth = {
get serverUrl() { return localStorage.getItem('wp_serverUrl') || ''; }, userName: null,
get token() { return localStorage.getItem('wp_token') || ''; }, get isLoggedIn() { return !!this.userName; }
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 = {}) { /** Call once on app start to find out if our session cookie is still valid. */
const res = await fetch(session.serverUrl.replace(/\/+$/, '') + path, { export async function restoreSession() {
...options, try {
headers: { const data = await apiFetch('/api/auth/me');
'X-Emby-Authorization': authHeaderValue(), auth.userName = data.userName;
...(session.token ? { 'X-Emby-Token': session.token } : {}), return true;
...(options.headers || {}) } 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) { export async function login(username, password) {
const cleanUrl = serverUrl.trim().replace(/\/+$/, ''); const data = await apiFetch('/api/auth/login', {
const res = await fetch(cleanUrl + '/Users/AuthenticateByName', {
method: 'POST', method: 'POST',
headers: { body: JSON.stringify({ username, password })
'Content-Type': 'application/json',
'X-Emby-Authorization': authHeaderValue()
},
body: JSON.stringify({ Username: username, Pw: password })
}); });
if (!res.ok) { auth.userName = data.userName;
throw new Error('Identifiants refusés.');
}
const data = await res.json();
session.set(cleanUrl, data.AccessToken, data.User.Id, data.User.Name);
return data; return data;
} }
export async function logout() {
await apiFetch('/api/auth/logout', { method: 'POST' }).catch(() => {});
auth.userName = null;
}
export async function searchItems(query) { export async function searchItems(query) {
if (!query || query.trim().length < 2) { if (!query || query.trim().length < 2) return [];
return []; const data = await apiFetch(`/api/catalog/search?q=${encodeURIComponent(query)}`);
} return data.items || [];
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) { export async function getItem(itemId) {
return embyFetch(`/Users/${session.userId}/Items/${itemId}`); return apiFetch(`/api/catalog/item/${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()}`;
} }
export function streamUrl(itemId) { export function streamUrl(itemId) {
// Same deliberate exception as imageUrl: <video src>/hls.js can't attach return `/api/catalog/stream/${itemId}/master.m3u8`;
// 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()}`;
} }

View file

@ -1,17 +1,12 @@
// Everything that talks to OUR OWN backend: party REST endpoints, and the // Party REST + realtime WebSocket - same origin as everything else, auth is
// realtime WebSocket. Same origin as this page, so no CORS concerns here - // the session cookie (see embyApi.js), so no headers/tokens to pass around
// contrast with embyApi.js. // here at all.
import { session } from './embyApi.js';
async function apiFetch(path, options = {}) { async function apiFetch(path, options = {}) {
const res = await fetch(path, { const res = await fetch(path, {
credentials: 'same-origin',
...options, ...options,
headers: { headers: { 'Content-Type': 'application/json', ...(options.headers || {}) }
'Content-Type': 'application/json',
'X-Emby-Token': session.token,
'X-Emby-UserId': session.userId,
...(options.headers || {})
}
}); });
if (!res.ok) { if (!res.ok) {
const body = await res.json().catch(() => ({})); 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 * Opens the realtime connection for a party. Auth rides along on the
* FIRST WebSocket message, not a query string - tokens don't belong in URLs * WebSocket's HTTP upgrade handshake as the session cookie (browsers attach
* (they'd end up in server logs, browser history, etc.). * 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 } = {}) { export function connectParty(partyId, { onMembers, onPlaystate, onError, onClose } = {}) {
const proto = location.protocol === 'https:' ? 'wss://' : 'ws://'; const proto = location.protocol === 'https:' ? 'wss://' : 'ws://';
const ws = new WebSocket(proto + location.host + '/ws'); const ws = new WebSocket(proto + location.host + '/ws');
ws.addEventListener('open', () => { 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) => { 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'; import { navigate } from '../router.js';
export function renderBrowse(root) { export function renderBrowse(root) {
@ -6,14 +6,14 @@ export function renderBrowse(root) {
<main class="wrap wide"> <main class="wrap wide">
<div class="topbar"> <div class="topbar">
<h1>Watch Party</h1> <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> </div>
<input id="search" type="search" placeholder="Cherche un film, une série, un épisode..." autofocus /> <input id="search" type="search" placeholder="Cherche un film, une série, un épisode..." autofocus />
<div id="results" class="grid"></div> <div id="results" class="grid"></div>
</main>`; </main>`;
root.querySelector('#logout').addEventListener('click', () => { root.querySelector('#logout').addEventListener('click', async () => {
session.clear(); await logout();
navigate('/login'); navigate('/login');
}); });
@ -44,9 +44,9 @@ export function renderBrowse(root) {
return; return;
} }
results.innerHTML = items.map((item) => ` results.innerHTML = items.map((item) => `
<a class="card" href="#/item/${item.Id}"> <a class="card" href="#/item/${item.id}">
<img src="${imageUrl(item.Id)}" alt="" loading="lazy" /> <img src="${item.imageUrl}" alt="" loading="lazy" />
<span>${escapeHtml(item.Name)}${item.ProductionYear ? ' (' + item.ProductionYear + ')' : ''}</span> <span>${escapeHtml(item.name)}${item.year ? ' (' + item.year + ')' : ''}</span>
</a> </a>
`).join(''); `).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 { createParty, joinParty } from '../party.js';
import { navigate } from '../router.js'; import { navigate } from '../router.js';
@ -10,10 +10,10 @@ export function renderItemDetail(root, params) {
<main class="wrap"> <main class="wrap">
<button class="linklike" id="back">&larr; Retour</button> <button class="linklike" id="back">&larr; Retour</button>
<div class="detail"> <div class="detail">
<img src="${imageUrl(item.Id, 400)}" alt="" /> <img src="${item.imageUrl}" alt="" />
<div> <div>
<h1>${escapeHtml(item.Name)}</h1> <h1>${escapeHtml(item.name)}</h1>
<p>${escapeHtml(item.Overview || '')}</p> <p>${escapeHtml(item.overview || '')}</p>
<button id="createBtn">Démarrer une watch party</button> <button id="createBtn">Démarrer une watch party</button>
<div class="or">ou</div> <div class="or">ou</div>
@ -30,7 +30,7 @@ export function renderItemDetail(root, params) {
root.querySelector('#createBtn').addEventListener('click', async () => { root.querySelector('#createBtn').addEventListener('click', async () => {
try { try {
const party = await createParty(item.Id); const party = await createParty(item.id);
navigate(`/party/${party.partyId}`); navigate(`/party/${party.partyId}`);
} catch (err) { } catch (err) {
root.querySelector('#error').textContent = err.message; root.querySelector('#error').textContent = err.message;

View file

@ -7,11 +7,8 @@ export function renderLogin(root) {
<h1>Watch Party</h1> <h1>Watch Party</h1>
<form id="loginForm" class="panel"> <form id="loginForm" class="panel">
<p class="hint">Utilise ton compte Emby habituel.</p> <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 <label>Nom d'utilisateur
<input id="username" autocomplete="username" required /> <input id="username" autocomplete="username" required autofocus />
</label> </label>
<label>Mot de passe <label>Mot de passe
<input id="password" type="password" autocomplete="current-password" required /> <input id="password" type="password" autocomplete="current-password" required />
@ -22,25 +19,15 @@ export function renderLogin(root) {
</main>`; </main>`;
const form = root.querySelector('#loginForm'); const form = root.querySelector('#loginForm');
const serverUrlInput = form.querySelector('#serverUrl');
serverUrlInput.value = localStorage.getItem('wp_lastServerUrl') || '';
form.addEventListener('submit', async (e) => { form.addEventListener('submit', async (e) => {
e.preventDefault(); e.preventDefault();
const errorEl = form.querySelector('#error'); const errorEl = form.querySelector('#error');
errorEl.textContent = ''; 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 { try {
await login(serverUrl, username, password); await login(form.querySelector('#username').value, form.querySelector('#password').value);
navigate('/browse'); navigate('/browse');
} catch (err) { } catch (err) {
errorEl.textContent = errorEl.textContent = "Connexion impossible - vérifie ton nom d'utilisateur et ton mot de passe.";
"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.)";
} }
}); });
} }

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 { getParty, leaveParty, connectParty } from '../party.js';
import { navigate } from '../router.js'; import { navigate } from '../router.js';
@ -55,13 +55,13 @@ export function renderPartyPlayer(root, params) {
return; return;
} }
isHost = party.hostUserId === session.userId; isHost = party.youAreHost;
codeEl.textContent = party.joinCode; codeEl.textContent = party.joinCode;
video.controls = isHost; video.controls = isHost;
followerNotice.classList.toggle('hide', isHost); followerNotice.classList.toggle('hide', isHost);
const item = await getItem(party.itemId); const item = await getItem(party.itemId);
const src = streamUrl(item.Id); const src = streamUrl(item.id);
if (window.Hls && window.Hls.isSupported()) { if (window.Hls && window.Hls.isSupported()) {
const hls = new window.Hls(); const hls = new window.Hls();
hls.loadSource(src); 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. the WebSocket for realtime sync.
``` ```
### Auth ### Auth and Emby access: the server proxies everything
Emby-only, as decided. The browser calls Emby's own Revised after the first version: the browser used to call Emby directly
`POST /Users/AuthenticateByName` directly (same call the old plugin's (catalog, images, streaming) and only used `server/` for party coordination.
control panel used) and keeps the resulting `AccessToken`/`User.Id` in That hit real CORS problems in practice (Emby/its reverse proxy don't
`localStorage`. Our backend never sees a password — only ever a token the answer cross-origin preflights the way that needed), and separately wasn't
browser already obtained from Emby, sent as a header (`X-Emby-Token` / the wanted shape anyway — the browser holding a real Emby access token at
`X-Emby-UserId`) on REST calls, or as the first WebSocket message (not a all was more exposure than necessary. Current design:
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 - 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 This also means the client no longer needs to ask for a server URL at all —
the browser fetches them straight from Emby. `server/` only ever sees JSON one less field, one less way to get it wrong. `EMBY_SERVER_URL` lives only
party-coordination traffic and a handful of bytes per WebSocket message. The in the backend's environment.
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 ### Control model: host drives, everyone else follows
@ -118,31 +136,34 @@ activity for 4 hours.
## Assumptions to verify ## Assumptions to verify
Unlike the plugin (which got to compile against Emby's real SDK assemblies), **Confirmed against the real server** (not a guess — actually exercised,
none of the Emby REST API assumptions below have been exercised against a see session history): `POST /Users/AuthenticateByName` really is reachable
real server yet — only smoke-tested against a stubbed/fake Emby URL to from Node's server-side `fetch` with no CORS issue at all (server-to-server
confirm the *app's own* code paths (routing, error handling) don't crash has no CORS concept), returns a real 401 for bad credentials, and the whole
(see git history). Check these first when testing for real: 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. **Not yet exercised against the real server** — a successful login hasn't
If Emby (or the openresty reverse proxy in front of it) doesn't answer happened yet (no real credentials used in testing), so nothing past that
preflight requests permissively, every direct call (including login) point is confirmed:
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 - **`/Users/{userId}` for token validation** was dropped — the proxy
routing `embyApi.js`'s JSON calls through `server/` as a proxy instead of redesign no longer needs it at all (sessions are validated once at login
direct — video/image `src` URLs wouldn't need this (no CORS for those). and cached server-side, not re-checked against Emby per-request).
- **`/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 - **`/Videos/{id}/master.m3u8`** for HLS playback, with
`VideoCodec=h264&AudioCodec=aac` forcing a broadly-compatible transcode `VideoCodec=h264&AudioCodec=aac` forcing a broadly-compatible transcode
target. Works for basically anything Emby can transcode, but is never the target. Works for basically anything Emby can transcode, but is never the
most efficient path (no direct play, no bitrate/quality negotiation) — most efficient path (no direct play, no bitrate/quality negotiation) —
fine for v1, worth revisiting once the basic flow works. 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`, - **Response field names** (`AccessToken`, `User.Id`, `User.Name`,
`Items[].Id/Name/Overview/ProductionYear`) — long-standing Emby/Jellyfin `Items[].Id/Name/Overview/ProductionYear`) — long-standing Emby/Jellyfin
convention, same ones the old plugin's control panel used, not convention, not independently re-confirmed for this Emby version.
independently re-confirmed for this Emby version.
- **`X-Emby-Authorization` header format** — same caveat as above. - **`X-Emby-Authorization` header format** — same caveat as above.
## Known limitations ## Known limitations

View file

@ -1,46 +1,107 @@
'use strict'; '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) { if (!EMBY_SERVER_URL) {
console.warn( 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' '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, * Signs in against Emby *from the server*. The browser only ever sends its
* by asking the Emby server itself (GET /Users/{userId}). This backend never * username/password once, to us; we forward that to Emby, and from then on
* sees passwords, and never stores a token beyond the lifetime of a single * the browser only holds an opaque session id (cookie) - never Emby's own
* request - it only ever forwards a token the browser already obtained * access token. This is what makes "the server proxies everything" possible:
* directly from Emby's own /Users/AuthenticateByName. * 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 * Returns a new session id, or null on bad credentials / unreachable Emby.
* 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.
*/ */
async function resolveEmbyUser(token, userId) { async function login(username, password) {
if (!token || !userId || !EMBY_SERVER_URL) { 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; return null;
} }
try {
const res = await fetch(`${EMBY_SERVER_URL.replace(/\/+$/, '')}/Users/${encodeURIComponent(userId)}`, {
headers: { 'X-Emby-Token': token }
});
if (!res.ok) { if (!res.ok) {
return null; return null;
} }
const user = await res.json(); const data = await res.json();
return { id: user.Id, name: user.Name }; const sessionId = crypto.randomUUID();
} catch (err) { sessions.set(sessionId, {
console.error('[watchparty] Emby token validation failed:', err.message); embyToken: data.AccessToken,
return null; 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 express = require('express');
const { WebSocketServer } = require('ws'); 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 { PartyManager } = require('./partyManager');
const { attachWebSocketServer } = require('./wsHandler'); const { attachWebSocketServer } = require('./wsHandler');
const app = express(); const app = express();
app.set('trust proxy', 1); // behind openresty/nginx - needed for req.secure to reflect the real scheme
app.use(express.json()); app.use(express.json());
// The frontend is served from the same origin as this API, so none of these // Everything the browser needs - the app shell AND every Emby call - is
// routes need CORS. The browser's OWN calls to Emby itself (catalog, images, // same-origin from here on. See docs/architecture.md for why: the previous
// video) are a different story - see docs/architecture.md. // version had the browser call Emby directly and hit real CORS problems.
app.use(express.static(path.join(__dirname, '..', '..', 'client'))); app.use(express.static(path.join(__dirname, '..', '..', 'client')));
app.get('/api/config', (_req, res) => { function requireSession(req, res, next) {
res.json({ embyServerUrl: EMBY_SERVER_URL || null }); const session = sessionFromRequest(req);
}); if (!session) {
res.status(401).json({ error: 'Session expirée, reconnecte-toi.' });
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.' });
return; return;
} }
req.embyUser = user; req.embySession = session;
next(); 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; const itemId = req.body && req.body.itemId;
if (!itemId) { if (!itemId) {
res.status(400).json({ error: 'itemId is required.' }); res.status(400).json({ error: 'itemId is required.' });
return; return;
} }
const party = PartyManager.create(req.embyUser.id, req.embyUser.name, itemId); const party = PartyManager.create(req.embySession.userId, req.embySession.userName, itemId);
res.json(PartyManager.toPublicState(party)); 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); const party = PartyManager.getByJoinCode(req.params.joinCode);
if (!party) { if (!party) {
res.status(404).json({ error: 'Aucune party avec ce code (elle est peut-être terminée).' }); res.status(404).json({ error: 'Aucune party avec ce code (elle est peut-être terminée).' });
return; return;
} }
PartyManager.join(party, req.embyUser.id, req.embyUser.name); PartyManager.join(party, req.embySession.userId, req.embySession.userName);
res.json(PartyManager.toPublicState(party)); 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); const party = PartyManager.getById(req.params.partyId);
if (party) { if (party) {
PartyManager.leave(party, req.embyUser.id); PartyManager.leave(party, req.embySession.userId);
} }
res.json({ ok: true }); 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); const party = PartyManager.getById(req.params.partyId);
if (!party) { if (!party) {
res.status(404).json({ error: 'Party introuvable (elle est peut-être terminée).' }); res.status(404).json({ error: 'Party introuvable (elle est peut-être terminée).' });
return; return;
} }
res.json(PartyManager.toPublicState(party)); res.json(PartyManager.toPublicState(party, req.embySession.userId));
}); });
const server = http.createServer(app); const server = http.createServer(app);
const wss = new WebSocketServer({ server, path: '/ws' }); const wss = new WebSocketServer({ server, path: '/ws' });
attachWebSocketServer(wss); attachWebSocketServer(wss, { parseCookies, getSession });
setInterval(() => PartyManager.removeIdleParties(4 * 60 * 60 * 1000), 10 * 60 * 1000); 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 { return {
partyId: party.id, partyId: party.id,
joinCode: party.joinCode, joinCode: party.joinCode,
itemId: party.itemId, itemId: party.itemId,
hostUserId: party.hostUserId, youAreHost: viewerUserId != null && viewerUserId === party.hostUserId,
members: Array.from(party.members.values()).map((m) => ({ members: Array.from(party.members.values()).map((m) => ({
userId: m.userId, userId: m.userId,
userName: m.userName, userName: m.userName,

View file

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