Fix slow catalog browsing: images were never cacheable
All checks were successful
Publier l'image Docker / test (push) Successful in 21s
Publier l'image Docker / publish (push) Successful in 14s

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>
This commit is contained in:
Nicolas 2026-09-14 23:18:58 +02:00
parent 8c9852d854
commit 9b3e051385
4 changed files with 245 additions and 22 deletions

View file

@ -279,6 +279,9 @@ Then put it behind your existing reverse proxy like everything else
(was still each viewer's own local choice until now); fixed the
audio picker briefly showing an empty option on first load — see
docs/architecture.md
- [x] Fixed catalog browsing feeling slow: image thumbnails were never
cacheable (Emby's own Cache-Control/ETag were silently dropped) —
see docs/architecture.md
- [ ] Try a full party end-to-end with two real accounts (still the main gap)
- [ ] Tune transcoding target / support direct play when possible

View file

@ -2286,6 +2286,56 @@ select restores the party's current text or burn-in pick correctly on
load; and the audio select now lands on a real first option instead of
an empty one when `streamAudioIndex` is `null`.
## Catalog browsing was slow: images were never cacheable
Reported directly ("l'api du contenu des catalogues est hyper lente").
`CatalogService`'s own listing methods (`getLibraryItems`, `getArtists`,
etc.) were already fine - each is exactly one Emby call, no per-item
follow-up requests, and timed directly against the real server a
300-item library listing (`getLibraryItems`'s own `Limit: 300`) came
back in well under half a second. The real cost was downstream of that:
every one of those up to 300 tiles needs its own thumbnail, each
proxied through `EmbyClient.proxyImage` → `proxyResponse`, and
`proxyResponse` 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, it had no choice but to re-download every single thumbnail
in full, from scratch, on every visit to every library, home-page
carousel, series/season/album page - the same images, over and over,
compounding across however many tiles a page shows.
**Fixed both directions of the same problem.** `EmbyClient.fetch`
already accepted `range` to forward; it now also accepts
`ifNoneMatch`/`ifModifiedSince`, sent as `If-None-Match`/
`If-Modified-Since` - and a `304` (the entire point of sending those)
is explicitly excluded from the failure-logging path (`!res.ok` is true
for a 304, but it's Emby correctly saying "unchanged", not an error
worth `[watchparty:emby]`). `proxyResponse` now 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, HLS
playlists, subtitles) - it only ever forwards whatever Emby itself
decided, never invents a policy, so something Emby doesn't mark
cacheable (a per-session transcode) simply never gets these headers set
at all, same as before.
Verified: full typecheck/test/build on both projects. New
`EmbyClient.proxyResponse` tests (this method had never been exercised
directly before - every existing test that touches it mocks it away
entirely to isolate whatever calls it) 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 (most proxied paths) doesn't choke or
invent one; `EmbyClient.fetch` tests cover a 304 not hitting the
failure log and the two new headers being sent only when given.
## Known limitations
- Playback always goes through `hls.js` against whatever Emby's

View file

@ -14,25 +14,34 @@ import { Logger } from '../Logger';
*/
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.
* `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; method?: string; body?: unknown } = {}): Promise<globalThis.Response> {
* 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 });
if (!res.ok) await EmbyClient.logErrorResponse(res, pathAndQuery);
// 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;
}
@ -92,21 +101,44 @@ export class EmbyClient {
/**
* 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/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.
* 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 });
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;
@ -115,7 +147,7 @@ export class EmbyClient {
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']) {
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);
}

View file

@ -6,8 +6,55 @@
// what EmbyClient.fetch itself calls.
import { describe, it } from 'node:test';
import assert from 'node:assert/strict';
import { PassThrough } from 'stream';
import type { Request, Response } from 'express';
import { EmbyClient } from '../src/emby/EmbyClient';
/** A real Writable (via PassThrough) so `.pipe(res)` in proxyResponse's
* streaming branch works exactly as it would against a real Express
* response, decorated with the handful of Express Response methods
* proxyResponse actually calls - same "just enough of the real
* interface, cast through `unknown`" approach as partyStreamService
* .test.ts's own fakeRes, but pipe-able too since, unlike that file,
* EmbyClient.proxyResponse itself (not a mock of it) runs for real
* here. Returned already cast to plain `Response` for passing into
* proxyResponse; callers read back `.statusCode`/`.headers`/`.body`
* through the same `unknown` cast the fake was built from. */
type FakeRes = { statusCode: number; headers: Record<string, string>; body: Promise<string> };
function fakeRes(): Response {
const stream = new PassThrough();
const chunks: Buffer[] = [];
stream.on('data', (c) => chunks.push(c));
const body = new Promise<string>((resolve) => stream.on('end', () => resolve(Buffer.concat(chunks).toString())));
const headers: Record<string, string> = {};
// Untyped (`any`) for the construction phase only - trying to keep this
// typed as the full `Response & FakeRes` intersection while assigning
// its methods one at a time confuses TS into thinking each assignment
// narrows the *whole* intersection, not just the one property. `inspect`
// and the `Response` return type below are the only two shapes anything
// outside this function ever sees it as.
const fake: any = stream;
fake.headers = headers;
fake.statusCode = 200;
fake.body = body;
fake.status = (code: number) => { fake.statusCode = code; return fake; };
fake.setHeader = (name: string, value: string) => { headers[name.toLowerCase()] = value; return fake; };
fake.send = (text: string) => { stream.end(text); return fake; };
return fake as Response;
}
/** Reads back the tracking fields fakeRes() attached, through the same
* `unknown` cast - `res` itself is typed as plain `Response` at every
* call site (matching what proxyResponse's own signature expects). */
function inspect(res: Response): FakeRes {
return res as unknown as FakeRes;
}
function fakeReq(headers: Record<string, string> = {}): Request {
return { headers } as unknown as Request;
}
describe('EmbyClient.fetch', () => {
it('logs Emby\'s status and response body on a failure, without consuming the body the real caller still needs', async (t) => {
const logged: string[] = [];
@ -50,6 +97,34 @@ describe('EmbyClient.fetch', () => {
assert.equal(logged.filter((l) => l.includes('[watchparty:emby]')).length, 0);
});
it('does not log a 304 as a failure - a cache hit is the whole point of sending If-None-Match, not an error', async (t) => {
const logged: string[] = [];
t.mock.method(console, 'log', (line: string) => { logged.push(line); });
t.mock.method(globalThis, 'fetch', async () => new Response(null, { status: 304 }));
await EmbyClient.fetch('/Items/1/Images/Primary', 'tok', { ifNoneMatch: '"abc"' });
assert.equal(logged.filter((l) => l.includes('[watchparty:emby]')).length, 0, 'a 304 is !res.ok but must not hit the failure log');
});
it('sends If-None-Match/If-Modified-Since only when given - a plain GET stays exactly as before', async (t) => {
let capturedInit: RequestInit | undefined;
t.mock.method(globalThis, 'fetch', async (_url: string, init: RequestInit) => {
capturedInit = init;
return new Response('{}', { status: 200 });
});
await EmbyClient.fetch('/Items/1/Images/Primary', 'tok', { ifNoneMatch: '"abc"', ifModifiedSince: 'Mon, 01 Jan 2024 00:00:00 GMT' });
let headers = capturedInit?.headers as Record<string, string>;
assert.equal(headers['If-None-Match'], '"abc"');
assert.equal(headers['If-Modified-Since'], 'Mon, 01 Jan 2024 00:00:00 GMT');
await EmbyClient.fetch('/Users/u1/Views', 'tok');
headers = capturedInit?.headers as Record<string, string>;
assert.equal(headers['If-None-Match'], undefined);
assert.equal(headers['If-Modified-Since'], undefined);
});
it('JSON-encodes a body and sets method/Content-Type when a caller passes one - generic plumbing', async (t) => {
// Not PlaystateService's own call any more - its real Progress
// endpoint takes query params, not a body (confirmed against Emby's
@ -86,3 +161,66 @@ describe('EmbyClient.fetch', () => {
assert.equal((capturedInit?.headers as Record<string, string>)['Content-Type'], undefined);
});
});
// proxyResponse itself, never exercised for real before (every other test
// file that touches it mocks it away entirely, to isolate whatever calls
// it) - specifically the caching path added after a real "the catalog is
// really slow" report traced to this method silently dropping Emby's own
// Cache-Control/ETag on every image it proxied. Mocks the GLOBAL fetch,
// same as EmbyClient.fetch's own tests above - proxyResponse is real code
// running on top of it, not itself replaced.
describe('EmbyClient.proxyResponse', () => {
it('forwards Cache-Control/ETag/Last-Modified from a real (bodied) response, alongside the existing content-range/accept-ranges', async (t) => {
t.mock.method(globalThis, 'fetch', async () => new Response('fake-jpeg-bytes', {
status: 200,
headers: {
'Content-Type': 'image/jpeg',
'Cache-Control': 'public',
'ETag': '"abc123"',
'Accept-Ranges': 'bytes'
}
}));
const res = fakeRes();
await EmbyClient.proxyResponse(fakeReq(), res, '/Items/1/Images/Primary', 'tok', '1');
const got = inspect(res);
assert.equal(got.statusCode, 200);
assert.equal(got.headers['cache-control'], 'public');
assert.equal(got.headers['etag'], '"abc123"');
assert.equal(got.headers['accept-ranges'], 'bytes');
assert.equal(await got.body, 'fake-jpeg-bytes', 'the body itself must still stream through untouched');
});
it('forwards the browser\'s own If-None-Match/If-Modified-Since to Emby, and relays a real 304 with no body', async (t) => {
let capturedInit: RequestInit | undefined;
t.mock.method(globalThis, 'fetch', async (_url: string, init: RequestInit) => {
capturedInit = init;
return new Response(null, { status: 304, headers: { ETag: '"abc123"' } });
});
const req = fakeReq({ 'if-none-match': '"abc123"', 'if-modified-since': 'Mon, 01 Jan 2024 00:00:00 GMT' });
const res = fakeRes();
await EmbyClient.proxyResponse(req, res, '/Items/1/Images/Primary', 'tok', '1');
const got = inspect(res);
const sentHeaders = capturedInit?.headers as Record<string, string>;
assert.equal(sentHeaders['If-None-Match'], '"abc123"', 'the browser\'s validator must reach Emby, or Emby can never actually answer 304');
assert.equal(sentHeaders['If-Modified-Since'], 'Mon, 01 Jan 2024 00:00:00 GMT');
assert.equal(got.statusCode, 304);
assert.equal(got.headers['etag'], '"abc123"');
});
it('does not choke on no Cache-Control/ETag at all - most proxied responses (video segments, subtitles) never had one', async (t) => {
t.mock.method(globalThis, 'fetch', async () => new Response('vtt content', { status: 200, headers: { 'Content-Type': 'text/vtt' } }));
const res = fakeRes();
await EmbyClient.proxyResponse(fakeReq(), res, '/Videos/1/x/Subtitles/2/Stream.vtt', 'tok', '1');
const got = inspect(res);
assert.equal(got.statusCode, 200);
assert.equal(got.headers['cache-control'], undefined);
assert.equal(got.headers['etag'], undefined);
assert.equal(await got.body, 'vtt content');
});
});