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
|
## 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,
|
||||||
|
|
|
||||||
|
|
@ -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; }
|
||||||
|
|
|
||||||
|
|
@ -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));
|
||||||
|
|
|
||||||
|
|
@ -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()}`;
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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) => {
|
||||||
|
|
|
||||||
|
|
@ -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('');
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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">← Retour</button>
|
<button class="linklike" id="back">← 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;
|
||||||
|
|
|
||||||
|
|
@ -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.)";
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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';
|
||||||
* Confirms an Emby access token really grants access to the given user id,
|
const SESSION_TTL_MS = 30 * 24 * 60 * 60 * 1000; // 30 days
|
||||||
* 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;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
// sessionId -> { embyToken, userId, userName, createdAt }
|
||||||
const res = await fetch(`${EMBY_SERVER_URL.replace(/\/+$/, '')}/Users/${encodeURIComponent(userId)}`, {
|
// In-memory, like party state - a restart signs everyone out, which is fine.
|
||||||
headers: { 'X-Emby-Token': token }
|
const sessions = new Map();
|
||||||
});
|
|
||||||
|
|
||||||
if (!res.ok) {
|
function authHeaderValue() {
|
||||||
return null;
|
return 'MediaBrowser Client="WatchParty", Device="Server", DeviceId="watchparty-server", Version="0.1.0"';
|
||||||
}
|
|
||||||
|
|
||||||
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;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
module.exports = { resolveEmbyUser, EMBY_SERVER_URL };
|
/**
|
||||||
|
* 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 {
|
||||||
|
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 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;
|
||||||
|
}
|
||||||
|
|
||||||
|
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 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) {
|
return;
|
||||||
const token = (req.get('X-Emby-Token') || '').trim();
|
}
|
||||||
const userId = (req.get('X-Emby-UserId') || '').trim();
|
req.embySession = session;
|
||||||
resolveEmbyUser(token, userId).then((user) => {
|
next();
|
||||||
if (!user) {
|
|
||||||
res.status(401).json({ error: 'Session Emby invalide ou expirée.' });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
req.embyUser = user;
|
|
||||||
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);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue