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>
55 lines
2.6 KiB
TypeScript
55 lines
2.6 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', 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.
|
|
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('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', 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', 0, false));
|
|
});
|
|
});
|