Checked the app's Emby-API assumptions directly against Emby's own
real OpenAPI spec (swagger.emby.media, pointed at a real server) after
a "watch status per user seems broken" report.
PlaystateService.reportProgress was wrong: POST
/Users/{UserId}/PlayingItems/{Id}/Progress does exist at that exact
path, but the spec declares PositionTicks/IsPaused as *query string*
parameters, not body fields - and a third parameter, MediaSourceId, is
*required* and wasn't being sent at all. The request body schema this
endpoint does list (Api.OnPlaybackProgress) covers unrelated fields
(PlaylistIndex/Shuffle/SleepTimerMode/...), none of these. So the
original guess sent PositionTicks/IsPaused somewhere Emby never reads
them, while omitting a parameter required just to accept the call -
almost certainly meaning every progress report was failing outright,
silently (best-effort by design, so nothing broke loudly).
Fixed: reportProgress now sends MediaSourceId/PositionTicks/IsPaused
as query parameters, no body at all. MediaSourceId comes from
PartyStreamService's own cache - CatalogService.negotiateStreamTarget
already resolves a real one for every party's shared stream
negotiation, so PartyStreamService now caches it alongside the target
path (negotiateStreamTarget's return type changed from a bare string
to {targetPath, mediaSourceId}) and exposes it read-only via a new
getCachedMediaSourceId(partyId) - deliberately passive, never triggers
a negotiation of its own, so a party where no one has started
playback yet this session simply has nothing to report; index.ts's
sweep skips it rather than paying for an extra PlaybackInfo round trip
every tick just for this.
While checking this, the other two flagged Emby-API assumptions (the
ffmpeg direct-stream URL, the /Playlists/{Id}/Items endpoint) were
re-checked against the same real spec too - both already correct, no
missing required parameter, consistent with them having been tried
for real successfully before.
Updated the 5 negotiateStreamTarget call sites in catalogService.test
to read the new {targetPath, mediaSourceId} shape, the 7
negotiateStreamTarget mocks in partyStreamService.test to return it
(plus 2 new tests: cache exposes mediaSourceId, a failed response
drops it too), rewrote playstateService.test's request-shape
assertions for query params instead of a body, and re-pointed
embyClient.test's generic body-encoding test at an arbitrary example
instead of implying it's still PlaystateService's real shape.
Verified: full typecheck/test/build (92/92 tests). docs/architecture.md
and README.md updated - the "Sync watch-party progress back to Emby"
section now documents the confirmed-wrong-then-fixed shape instead of
a standing TODO(verify), and README's "Not verified" paragraph moves
this (and the other two, now-confirmed) assumptions to "Resolved".
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
88 lines
4.3 KiB
TypeScript
88 lines
4.3 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 { EmbyClient } from '../src/emby/EmbyClient';
|
|
|
|
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('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);
|
|
});
|
|
});
|