'use strict'; import type { Request, Response } from 'express'; import { Readable } from 'stream'; import { EMBY_SERVER_URL } from '../config'; import { Logger } from '../Logger'; /** * Low-level HTTP plumbing for talking to Emby and proxying its responses * back to the browser - not a "feature" service itself (no state, nothing * a client action changes), just the shared mechanics CatalogService's * routes are built on. See docs/architecture.md for why the server proxies * 100% of Emby access instead of the browser talking to Emby directly. */ export class EmbyClient { /** Fetches a path from Emby with the given user's token, forwarding * Range (needed for direct-play video seeking) when the browser sent * one, and If-None-Match/If-Modified-Since (needed for images to * revalidate as a cheap 304 instead of re-downloading the full body * every time - see proxyResponse's own comment on caching) when the * browser sent those. `method`/`body` are for PlaystateService's own * POST calls (see PlaystateService.ts) - every other existing call * site only ever passed `range`, so this stays a plain GET for them, * unaffected. `body`, when given, is JSON-encoded here so every * caller passes a plain object rather than remembering to stringify * it themselves. Every Emby call in this app goes through here - see * logErrorResponse below for what happens when Emby answers with * anything but success. */ static async fetch(pathAndQuery: string, token: string, options: { range?: string; ifNoneMatch?: string; ifModifiedSince?: string; method?: string; body?: unknown } = {}): Promise { const url = pathAndQuery.startsWith('http') ? pathAndQuery : `${EMBY_SERVER_URL}${pathAndQuery}`; const headers: Record = { 'X-Emby-Token': token }; if (options.range) headers.Range = options.range; if (options.ifNoneMatch) headers['If-None-Match'] = options.ifNoneMatch; if (options.ifModifiedSince) headers['If-Modified-Since'] = options.ifModifiedSince; let body: string | undefined; if (options.body !== undefined) { headers['Content-Type'] = 'application/json'; body = JSON.stringify(options.body); } const res = await fetch(url, { headers, method: options.method, body }); // A 304 (the whole point of the conditional headers above) is // Emby correctly saying "unchanged" - not a failure worth this // app's own error log, even though !res.ok is true for it. if (!res.ok && res.status !== 304) await EmbyClient.logErrorResponse(res, pathAndQuery); return res; } /** * Logs Emby's own status + response body whenever a call fails - added * after several rounds of chasing a real error message through browser * devtools (hls.js's own error reporting never surfaces the actual * response body, just the generic HTTP reason phrase) and Emby's own * live log view (the exception's headline message routinely scrolled * past what got copied) - see docs/architecture.md's player-track- * selection section. Emby often sends a real, detailed text body on a * failure (confirmed directly: a real 500 here carried 5190 bytes of * plain text, not an empty response) - this puts it straight into * `docker logs` instead, every time, going forward. * * Reads via `.clone()` specifically so this never consumes the body the * real caller still needs (`fetchJson`'s `.json()`, `proxyResponse`'s * `.text()`/pipe) - a Response's body stream can only be read once, and * every single Emby call (JSON API calls and the streaming/image proxy * alike) passes through this same `fetch`, so getting this wrong would * have broken all of them, not just the error path. * * `pathAndQuery` is logged with `api_key` redacted - this can be called * with Emby's own `TranscodingUrl` verbatim, which can carry one (same * concern as CatalogService's `logStreamRequest`). */ private static async logErrorResponse(res: globalThis.Response, pathAndQuery: string): Promise { const safePathAndQuery = pathAndQuery.replace(/([?&]api_key=)[^&]*/i, '$1REDACTED'); const body = await res.clone().text().catch(() => '(corps illisible)'); Logger.emby(`${res.status} ${safePathAndQuery} - ${body.slice(0, 2000)}`); } static isPlaylistPath(path: string): boolean { return /\.m3u8(\?|$)/i.test(path); } /** * Rewrites every URI line in an HLS playlist so it routes back through * our own proxy (`/api/catalog/party-stream/:routeKey/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. */ static rewritePlaylist(text: string, routeKey: string): string { return text .split('\n') .map((line) => { const trimmed = line.trim(); if (!trimmed || trimmed.startsWith('#')) return line; return `/api/catalog/party-stream/${routeKey}/raw?u=${encodeURIComponent(trimmed)}`; }) .join('\n'); } /** * Proxies an Emby response to an Express response: forwards status, * content-type, the headers that make byte-range seeking work * (content-range/accept-ranges), and the ones that make caching work * (cache-control/etag/last-modified - see below), rewriting HLS * playlists in transit and streaming everything else (images, video/ * audio segments) through untouched - no buffering, so this doesn't * hold a whole segment in memory. `routeKey` is only ever read back * out by `rewritePlaylist` above - a real itemId for `proxyImage`/ * `proxySubtitle` (which never actually reach that branch - wrong * content-type/extension - so it's inert for them either way) and * `party.id` for `PartyStreamService`, whose whole point is routing * every member back to the *same* shared stream regardless of which * item they think they're watching. * * Caching: added after a "the catalog is really slow" report traced * to this - confirmed directly against the real server that Emby * already answers an image request with `Cache-Control: public` and a * real `ETag`, but neither ever reached the browser: this method used * to forward only content-type/content-range/accept-ranges, silently * dropping both, so every tile in every library grid re-downloaded * its full image on every single visit with no way to even ask Emby * "has this changed?". Now forwards cache-control/etag/last-modified * too, and forwards the browser's own conditional headers (If-None- * Match/If-Modified-Since - see EmbyClient.fetch above) through to * Emby on the way there, so a cache hit round-trips as a real, * bodyless 304 instead of the full image every time. Harmless for the * other things this method proxies (playlists/segments/subtitles) - * this only ever forwards whatever Emby itself already decided, never * invents a policy, so a transcoded segment Emby marks non-cacheable * stays exactly that. */ static async proxyResponse(req: Request, res: Response, pathAndQuery: string, token: string, routeKey: string): Promise { let embyRes: globalThis.Response; try { embyRes = await EmbyClient.fetch(pathAndQuery, token, { range: req.headers.range, ifNoneMatch: req.headers['if-none-match'], ifModifiedSince: req.headers['if-modified-since'] }); } catch { 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', 'cache-control', 'etag', 'last-modified']) { const v = embyRes.headers.get(h); if (v) res.setHeader(h, v); } if (EmbyClient.isPlaylistPath(pathAndQuery) || contentType.includes('mpegurl')) { const text = await embyRes.text(); res.send(EmbyClient.rewritePlaylist(text, routeKey)); 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 as import('stream/web').ReadableStream).pipe(res); } }