Real request logged after the last fix (MediaSourceId in the query
string) shipped: still a 400, now Emby's own "Value cannot be null.
(Parameter 'key')" - a bare .NET Dictionary null-key exception, not
the earlier problem (MediaSourceId was present and well-formed in the
logged request).
This exact message already burned this app once before, in
CatalogService.negotiateStreamTarget's hand-built fallback URL (see
its own long-standing TODO(verify) comment) - diagnosed there as Emby
indexing some internal state by a parameter the request hadn't
supplied. PlaySessionId is the standout remaining candidate: it's how
Emby/Jellyfin clients normally correlate a progress report with the
specific playback/transcode session it belongs to, and evidently
Emby's real Progress handler keys a lookup on it even though the
OpenAPI spec doesn't mark it required.
Fixed the same way MediaSourceId was: CatalogService
.negotiateStreamTarget already reads info.PlaySessionId from the same
PlaybackInfo response (it already used it in the hand-built fallback
URL) - its return type just needed to surface it too. PartyStreamService
now caches it alongside mediaSourceId and exposes both together from
one accessor, getCachedPlaybackIds(partyId), replacing the
round-1 getCachedMediaSourceId. PlaystateService.reportProgress and
index.ts's sweep both thread it through.
Not independently confirmed against a real server yet (no safe way to
fire a real, state-mutating test call without one) - flagged in
docs/architecture.md the same way every other real-server-dependent
guess in this app is, but grounded in this app's own prior confirmed
diagnosis of the identical error message rather than a fresh guess.
Updated catalogService.test's negotiateStreamTarget assertions to
cover playSessionId in both return branches, partyStreamService.test's
mocks/assertions for the new {mediaSourceId, playSessionId} cache
shape, and playstateService.test's request-shape and logging
assertions for the extra PlaySessionId param. Full typecheck/test/build
green (96/96 tests). docs/architecture.md and README.md updated.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
116 lines
6.1 KiB
TypeScript
116 lines
6.1 KiB
TypeScript
// PlaystateService.reportProgress - mocks EmbyClient.fetch (not the
|
|
// global fetch it wraps), same isolation style as catalogService.test.ts,
|
|
// since this is about what PlaystateService itself sends, not
|
|
// EmbyClient's own plumbing (see embyClient.test.ts for that).
|
|
import { describe, it } from 'node:test';
|
|
import assert from 'node:assert/strict';
|
|
import { PlaystateService } from '../src/services/PlaystateService';
|
|
import { EmbyClient } from '../src/emby/EmbyClient';
|
|
|
|
describe('PlaystateService.reportProgress', () => {
|
|
it('POSTs to this user\'s own PlayingItems/Progress with their own token, params in the query string not a body', async (t) => {
|
|
let capturedPath = '';
|
|
let capturedToken = '';
|
|
let capturedOptions: unknown;
|
|
t.mock.method(EmbyClient, 'fetch', async (path: string, token: string, options: unknown) => {
|
|
capturedPath = path;
|
|
capturedToken = token;
|
|
capturedOptions = options;
|
|
return new Response('{}', { status: 200 });
|
|
});
|
|
|
|
await PlaystateService.reportProgress('tok-alice', 'u-alice', 'item1', 'src1', 'sess1', 12_345_678, false);
|
|
|
|
// Query params, not a JSON body - confirmed against Emby's real
|
|
// OpenAPI spec (see PlaystateService.ts's own comment): the
|
|
// original body-based guess was missing a required MediaSourceId
|
|
// entirely and would have failed outright. PlaySessionId came next
|
|
// (round 2 - see the same comment): optional per the spec, but a
|
|
// real request without one still hit Emby's own "Value cannot be
|
|
// null (Parameter 'key')".
|
|
const [path, query] = capturedPath.split('?');
|
|
assert.equal(path, '/Users/u-alice/PlayingItems/item1/Progress');
|
|
assert.equal(new URLSearchParams(query).get('MediaSourceId'), 'src1');
|
|
assert.equal(new URLSearchParams(query).get('PlaySessionId'), 'sess1');
|
|
assert.equal(new URLSearchParams(query).get('PositionTicks'), '12345678');
|
|
assert.equal(new URLSearchParams(query).get('IsPaused'), 'false');
|
|
assert.equal(capturedToken, 'tok-alice');
|
|
assert.deepEqual(capturedOptions, { method: 'POST' }, 'no body at all - the real body schema (Api.OnPlaybackProgress) has none of these fields');
|
|
});
|
|
|
|
it('rounds a fractional positionTicks (Emby expects an integer)', async (t) => {
|
|
let capturedPath = '';
|
|
t.mock.method(EmbyClient, 'fetch', async (path: string) => {
|
|
capturedPath = path;
|
|
return new Response('{}', { status: 200 });
|
|
});
|
|
|
|
await PlaystateService.reportProgress('tok', 'u1', 'item1', 'src1', 'sess1', 100.7, true);
|
|
|
|
const query = capturedPath.split('?')[1];
|
|
assert.equal(new URLSearchParams(query).get('PositionTicks'), '101');
|
|
});
|
|
|
|
it('swallows a failure instead of throwing - this runs on a timer with nothing to catch it', async (t) => {
|
|
t.mock.method(EmbyClient, 'fetch', async () => { throw new Error('Emby unreachable'); });
|
|
|
|
await assert.doesNotReject(() => PlaystateService.reportProgress('tok', 'u1', 'item1', 'src1', 'sess1', 0, false));
|
|
});
|
|
|
|
// Logging: the whole point is being able to answer "what did this
|
|
// actually send, and did Emby accept it" from `docker logs` alone -
|
|
// see Logger.playstate's own comment.
|
|
it('logs the exact outgoing request, never the token itself', async (t) => {
|
|
const logged: string[] = [];
|
|
t.mock.method(console, 'log', (line: string) => { logged.push(line); });
|
|
t.mock.method(EmbyClient, 'fetch', async () => new Response('{}', { status: 200 }));
|
|
|
|
await PlaystateService.reportProgress('super-secret-token', 'u-alice', 'item1', 'src1', 'sess1', 12_345_678, false);
|
|
|
|
const requestLine = logged.find((l) => l.includes('[watchparty:playstate]') && l.includes('POST'));
|
|
assert.ok(requestLine, 'the request itself must be logged');
|
|
assert.ok(requestLine!.includes('/Users/u-alice/PlayingItems/item1/Progress'));
|
|
assert.ok(requestLine!.includes('MediaSourceId=src1'));
|
|
assert.ok(requestLine!.includes('PlaySessionId=sess1'));
|
|
assert.ok(requestLine!.includes('PositionTicks=12345678'));
|
|
assert.ok(requestLine!.includes('IsPaused=false'));
|
|
assert.ok(!requestLine!.includes('super-secret-token'), 'the token travels as a header, never logged into the request line');
|
|
});
|
|
|
|
it('logs a successful response as ok, with its real HTTP status', async (t) => {
|
|
const logged: string[] = [];
|
|
t.mock.method(console, 'log', (line: string) => { logged.push(line); });
|
|
t.mock.method(EmbyClient, 'fetch', async () => new Response(null, { status: 204 })); // 204 is a null-body status - a non-null body here would throw
|
|
|
|
await PlaystateService.reportProgress('tok', 'u1', 'item1', 'src1', 'sess1', 0, false);
|
|
|
|
const outcomeLine = logged.find((l) => l.includes('[watchparty:playstate]') && l.includes('204'));
|
|
assert.ok(outcomeLine, 'the real status code must be logged');
|
|
assert.ok(outcomeLine!.includes('ok'));
|
|
assert.ok(!outcomeLine!.includes('FAILED'));
|
|
});
|
|
|
|
it('logs a non-ok response as FAILED, with its real HTTP status - the exact case both wrong request shapes hit for real', async (t) => {
|
|
const logged: string[] = [];
|
|
t.mock.method(console, 'log', (line: string) => { logged.push(line); });
|
|
t.mock.method(EmbyClient, 'fetch', async () => new Response("Value cannot be null. (Parameter 'key')", { status: 400 }));
|
|
|
|
await PlaystateService.reportProgress('tok', 'u1', 'item1', 'src1', 'sess1', 0, false);
|
|
|
|
const outcomeLine = logged.find((l) => l.includes('[watchparty:playstate]') && l.includes('400'));
|
|
assert.ok(outcomeLine, 'the real status code must be logged');
|
|
assert.ok(outcomeLine!.includes('FAILED'));
|
|
});
|
|
|
|
it('logs a thrown error (Emby unreachable) distinctly from a non-ok HTTP response', async (t) => {
|
|
const logged: string[] = [];
|
|
t.mock.method(console, 'log', (line: string) => { logged.push(line); });
|
|
t.mock.method(EmbyClient, 'fetch', async () => { throw new Error('Emby unreachable'); });
|
|
|
|
await PlaystateService.reportProgress('tok', 'u1', 'item1', 'src1', 'sess1', 0, false);
|
|
|
|
const outcomeLine = logged.find((l) => l.includes('[watchparty:playstate]') && l.includes('threw'));
|
|
assert.ok(outcomeLine, 'a thrown error must still be logged, not just swallowed silently');
|
|
assert.ok(outcomeLine!.includes('Emby unreachable'));
|
|
});
|
|
});
|