diff --git a/README.md b/README.md index 934b2fa..5e8cb65 100644 --- a/README.md +++ b/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, diff --git a/client/css/app.css b/client/css/app.css index 79bc1bb..39d36b0 100644 --- a/client/css/app.css +++ b/client/css/app.css @@ -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; } diff --git a/client/js/app.js b/client/js/app.js index 17398ec..f14a636 100644 --- a/client/js/app.js +++ b/client/js/app.js @@ -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)); diff --git a/client/js/embyApi.js b/client/js/embyApi.js index ba02f23..6414014 100644 --- a/client/js/embyApi.js +++ b/client/js/embyApi.js @@ -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 || {}) - } - }); - if (!res.ok) { - throw new Error(`Emby request failed: ${res.status}`); +/** 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; } - const text = await res.text(); - return text ? JSON.parse(text) : null; } -export async function login(serverUrl, username, password) { - const cleanUrl = serverUrl.trim().replace(/\/+$/, ''); - const res = await fetch(cleanUrl + '/Users/AuthenticateByName', { +export async function login(username, password) { + const data = await apiFetch('/api/auth/login', { method: 'POST', - headers: { - 'Content-Type': 'application/json', - 'X-Emby-Authorization': authHeaderValue() - }, - body: JSON.stringify({ Username: username, Pw: password }) + body: JSON.stringify({ username, password }) }); - if (!res.ok) { - throw new Error('Identifiants refusés.'); - } - const data = await res.json(); - session.set(cleanUrl, data.AccessToken, data.User.Id, data.User.Name); + auth.userName = data.userName; return data; } +export async function logout() { + await apiFetch('/api/auth/logout', { method: 'POST' }).catch(() => {}); + auth.userName = null; +} + export async function searchItems(query) { - if (!query || query.trim().length < 2) { - return []; - } - const params = new URLSearchParams({ - searchTerm: query, - IncludeItemTypes: 'Movie,Episode,Series', - Recursive: 'true', - Fields: 'Overview,ProductionYear', - ImageTypeLimit: '1', - Limit: '24' - }); - const data = await embyFetch(`/Users/${session.userId}/Items?${params.toString()}`); - return (data && data.Items) || []; + if (!query || query.trim().length < 2) return []; + const data = await apiFetch(`/api/catalog/search?q=${encodeURIComponent(query)}`); + return data.items || []; } export async function getItem(itemId) { - return embyFetch(`/Users/${session.userId}/Items/${itemId}`); -} - -export function imageUrl(itemId, maxWidth = 300) { - // 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: