watchparty/server/test/embyClient.test.ts
Nicolas 62b0abd572
All checks were successful
Publier l'image Docker / test (push) Successful in 20s
Publier l'image Docker / publish (push) Successful in 12s
Fix casting past the first segment, and the toolbar's real auto-hide bug
Two bugs reported together, both surfaced by actually trying this for
real.

Casting: "the .m3u8 needs to be passed instead of a raw [url]". The
master playlist itself was fine - the initial master.m3u8?castToken=...
request already carries the token. What broke immediately after: every
URI line inside that playlist gets rewritten (EmbyClient.rewritePlaylist)
to route back through this app's own party-stream/:partyId/raw?u=...
proxy, and that rewrite never carried the castToken forward. A browser
never noticed (it's still sending its cookie regardless of the URL), but
a Chromecast has no cookie to fall back on, so every segment/variant-
playlist fetch after the first one 401'd, breaking playback moments
after the receiver appeared to connect. Fixed by threading the same
castToken into every rewritten line (rewritePlaylist(text, routeKey,
castToken?), sourced from req.query.castToken right inside
proxyResponse). The self-hosted burn-in path had the identical gap for
a different reason - ffmpeg's own bare relative segment filenames drop
any query string when resolved against a URL that had one, per ordinary
URL-resolution rules - fixed the same way with a new pure
appendCastTokenToSegments(text, castToken), extracted specifically so
it's unit-testable without a real ffmpeg session.

Player toolbar: "still doesn't work, mobile and PC" - the auto-hide fix
from two commits ago hadn't actually landed for anyone. Real bug, not a
browser quirk this time: becomeHost's 3-second heartbeat got wired to
the same onDriverStateChanged function real discrete play/pause/seeked
events use, which also re-arms the auto-hide countdown - correct for a
genuine event, wrong for a heartbeat that fires unconditionally every
3s regardless of any real change. With the hide delay also at 3000ms,
every heartbeat tick cancelled and restarted the countdown on the exact
cadence it needed to run uninterrupted to ever complete - so for a
host, on any platform, the bar could never actually finish hiding.
Fixed by giving the heartbeat its own function, reportHeartbeat - same
state sync, without the scheduleControlsHide call.

Verified: full typecheck/test/build (122/122 server tests, including
new coverage for rewritePlaylist's token propagation - had none at all
before this - and appendCastTokenToSegments). Confirmed the heartbeat
fix directly: 3 simulated heartbeat ticks call the hide-timer's reset
zero times, one simulated real event still calls it once. Not verified:
a real Chromecast fetching real rewritten segment URLs - no device
reachable from this sandbox.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-15 20:18:29 +02:00

275 lines
13 KiB
TypeScript

// EmbyClient.fetch itself (not mocked away, unlike catalogService.test.ts,
// which replaces it wholesale to isolate CatalogService) - specifically
// the error-logging path added after several rounds of not being able to
// see what Emby actually said on a failure (see docs/architecture.md's
// player-track-selection section). Mocks the GLOBAL fetch, since that's
// 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> = {}, query: Record<string, string> = {}): Request {
return { headers, query } 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[] = [];
t.mock.method(console, 'log', (line: string) => { logged.push(line); });
t.mock.method(globalThis, 'fetch', async () => new Response('Server.MediaEncoding.Api boom', { status: 500 }));
const res = await EmbyClient.fetch('/Videos/91988/hls1/main/0.ts?PlaySessionId=abc', 'tok');
const embyLines = logged.filter((l) => l.includes('[watchparty:emby]'));
assert.equal(embyLines.length, 1);
assert.ok(embyLines[0]!.includes('500'));
assert.ok(embyLines[0]!.includes('Server.MediaEncoding.Api boom'), 'the real body text should make it into the log line');
assert.ok(embyLines[0]!.includes('/Videos/91988/hls1/main/0.ts'));
// The caller (proxyResponse/fetchJson) still needs to read this same
// response's body itself - logErrorResponse must read a *clone*, not
// the original, or this would come back empty/throw.
assert.equal(await res.text(), 'Server.MediaEncoding.Api boom');
});
it('redacts api_key from the logged path - Emby\'s own TranscodingUrl can carry one', async (t) => {
const logged: string[] = [];
t.mock.method(console, 'log', (line: string) => { logged.push(line); });
t.mock.method(globalThis, 'fetch', async () => new Response('nope', { status: 502 }));
await EmbyClient.fetch('/Videos/1/master.m3u8?api_key=super-secret&MediaSourceId=x', 'tok');
const line = logged.find((l) => l.includes('[watchparty:emby]'))!;
assert.ok(!line.includes('super-secret'), 'the real key must never reach the log');
assert.ok(line.includes('api_key=REDACTED'));
});
it('logs nothing extra on a successful response - only failures are interesting', async (t) => {
const logged: string[] = [];
t.mock.method(console, 'log', (line: string) => { logged.push(line); });
t.mock.method(globalThis, 'fetch', async () => new Response('{}', { status: 200 }));
await EmbyClient.fetch('/Users/u1/Views', 'tok');
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
// real OpenAPI spec, see PlaystateService.ts) - but `fetch` still
// supports an optional body generically, for whatever future caller
// does need one, so this exercises that capability on its own terms
// with an arbitrary example shape.
let capturedInit: RequestInit | undefined;
t.mock.method(globalThis, 'fetch', async (_url: string, init: RequestInit) => {
capturedInit = init;
return new Response('{}', { status: 200 });
});
await EmbyClient.fetch('/Some/Endpoint', 'tok', {
method: 'POST',
body: { Foo: 123, Bar: false }
});
assert.equal(capturedInit?.method, 'POST');
assert.equal((capturedInit?.headers as Record<string, string>)['Content-Type'], 'application/json');
assert.equal(capturedInit?.body, JSON.stringify({ Foo: 123, Bar: false }));
});
it('a plain GET (no body) never gets a Content-Type header - every existing call site relies on this staying untouched', 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('/Users/u1/Views', 'tok');
assert.equal(capturedInit?.method, undefined);
assert.equal((capturedInit?.headers as Record<string, string>)['Content-Type'], undefined);
});
});
// rewritePlaylist itself - had no test coverage at all before this, real
// or synthetic, despite its own doc comment flagging the "real Emby
// playlist" gap. Added alongside the castToken fix (a Chromecast has no
// cookie, so every rewritten URL needs the same token the master
// playlist request itself carried, or segment/variant fetches 401 - see
// its own comment).
describe('EmbyClient.rewritePlaylist', () => {
const playlist = [
'#EXTM3U',
'#EXT-X-STREAM-INF:BANDWIDTH=1280000',
'hls1/main/0.m3u8?PlaySessionId=abc',
'',
'https://emby.example.com/absolute/1.m3u8'
].join('\n');
it('rewrites every real URI line, leaves directives/comments/blank lines untouched, no castToken given', () => {
const out = EmbyClient.rewritePlaylist(playlist, 'party1').split('\n');
assert.equal(out[0], '#EXTM3U');
assert.equal(out[1], '#EXT-X-STREAM-INF:BANDWIDTH=1280000');
assert.equal(out[2], `/api/catalog/party-stream/party1/raw?u=${encodeURIComponent('hls1/main/0.m3u8?PlaySessionId=abc')}`);
assert.equal(out[3], '');
assert.equal(out[4], `/api/catalog/party-stream/party1/raw?u=${encodeURIComponent('https://emby.example.com/absolute/1.m3u8')}`);
});
it('appends the same castToken to every rewritten line when given - a Chromecast has no cookie for the follow-up requests', () => {
const out = EmbyClient.rewritePlaylist(playlist, 'party1', 'tok-abc').split('\n');
assert.ok(out[2]!.endsWith('&castToken=tok-abc'));
assert.ok(out[4]!.endsWith('&castToken=tok-abc'));
assert.equal(out[0], '#EXTM3U', 'directives never get a token appended - they were never rewritten at all');
});
});
// 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');
});
it('threads the request\'s own castToken into every rewritten line of a proxied playlist - see rewritePlaylist', async (t) => {
t.mock.method(globalThis, 'fetch', async () => new Response('#EXTM3U\nhls1/main/0.m3u8', {
status: 200,
headers: { 'Content-Type': 'application/vnd.apple.mpegurl' }
}));
const req = fakeReq({}, { castToken: 'tok-xyz' });
const res = fakeRes();
await EmbyClient.proxyResponse(req, res, '/Videos/1/master.m3u8', 'tok', 'party1');
const got = inspect(res);
const body = await got.body;
assert.ok(body.includes('&castToken=tok-xyz'), `expected the rewritten line to carry the castToken, got: ${body}`);
});
});