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
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:
parent
64710fa779
commit
b6818655b1
15 changed files with 541 additions and 290 deletions
39
README.md
39
README.md
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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; }
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
|
|
|
|||
|
|
@ -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 searchItems(query) {
|
||||
if (!query || query.trim().length < 2) {
|
||||
return [];
|
||||
export async function logout() {
|
||||
await apiFetch('/api/auth/logout', { method: 'POST' }).catch(() => {});
|
||||
auth.userName = null;
|
||||
}
|
||||
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 searchItems(query) {
|
||||
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`;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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) => {
|
||||
|
|
|
|||
|
|
@ -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('');
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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">← 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;
|
||||
|
|
|
|||
|
|
@ -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.";
|
||||
}
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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'
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*
|
||||
* 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.
|
||||
*/
|
||||
async function resolveEmbyUser(token, userId) {
|
||||
if (!token || !userId || !EMBY_SERVER_URL) {
|
||||
return null;
|
||||
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"';
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*
|
||||
* Returns a new session id, or null on bad credentials / unreachable Emby.
|
||||
*/
|
||||
async function login(username, password) {
|
||||
if (!EMBY_SERVER_URL) return null;
|
||||
|
||||
let res;
|
||||
try {
|
||||
const res = await fetch(`${EMBY_SERVER_URL.replace(/\/+$/, '')}/Users/${encodeURIComponent(userId)}`, {
|
||||
headers: { 'X-Emby-Token': token }
|
||||
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;
|
||||
}
|
||||
|
||||
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
79
server/src/embyProxy.js
Normal 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 };
|
||||
|
|
@ -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);
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue