Reported directly ("l'api du contenu des catalogues est hyper lente").
Timed CatalogService's own listing calls directly against the real
server - a 300-item library listing came back in well under half a
second, so that wasn't it. Confirmed the real cost instead:
EmbyClient.proxyResponse (what every thumbnail goes through, via
CatalogService.proxyImage) was silently dropping every caching signal
Emby sent. Confirmed directly against the real server that an image
response already carries Cache-Control: public and a real ETag, but
this method only ever forwarded content-type/content-range/
accept-ranges. With no validator and no cache policy ever reaching the
browser, every one of potentially 300+ tiles in a library grid had to
be re-downloaded in full, from scratch, on every single visit.
Fixed both directions:
- EmbyClient.fetch now also accepts ifNoneMatch/ifModifiedSince, sent
as If-None-Match/If-Modified-Since - and a 304 (the whole point of
sending those) is explicitly excluded from the failure-logging path,
since !res.ok is true for it but it's Emby correctly saying
"unchanged", not an error.
- proxyResponse reads the browser's own If-None-Match/If-Modified-Since
request headers and forwards them to Emby, and forwards
cache-control/etag/last-modified back from Emby's response,
alongside the existing content-range/accept-ranges pair.
Confirmed end to end against the real server, not just reasoned about:
a plain image GET comes back 200 with Cache-Control: public + a real
ETag; the same request repeated with that ETag as If-None-Match comes
back a real 304 with a zero-byte body. Harmless for everything else
this method proxies (video/audio segments, playlists, subtitles) - it
only forwards whatever Emby itself decided, never invents a policy.
New EmbyClient.proxyResponse tests (never exercised directly before -
every existing test that touches it mocks it away entirely) cover
forwarding the cache headers on a real bodied response, forwarding the
browser's conditional headers to Emby and relaying a real 304 with no
body, and that a response with neither header doesn't choke or invent
one. New EmbyClient.fetch tests cover a 304 not hitting the failure
log and the two new headers being sent only when given. Full
typecheck/test/build green (101/101 tests). docs/architecture.md and
README.md updated.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
170 lines
8.5 KiB
TypeScript
170 lines
8.5 KiB
TypeScript
'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<globalThis.Response> {
|
|
const url = pathAndQuery.startsWith('http') ? pathAndQuery : `${EMBY_SERVER_URL}${pathAndQuery}`;
|
|
const headers: Record<string, string> = { '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<void> {
|
|
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<void> {
|
|
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<Uint8Array>).pipe(res);
|
|
}
|
|
}
|