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>
56 lines
2.6 KiB
TypeScript
56 lines
2.6 KiB
TypeScript
'use strict';
|
|
|
|
import { EmbyClient } from '../emby/EmbyClient';
|
|
import { Logger } from '../Logger';
|
|
|
|
/**
|
|
* Reports a party member's own playback progress back to Emby, under
|
|
* *their own* account - the one piece of this app that ever writes
|
|
* anything to Emby rather than just reading/proxying it (see
|
|
* docs/architecture.md's "Sync watch-party progress back to Emby"
|
|
* section for the full design and its deliberate v1 scope). Called from
|
|
* index.ts's periodic sweep, never from a request/response cycle - see
|
|
* that file for who actually decides *when* and *with what* to call
|
|
* this.
|
|
*
|
|
* Emby's own "PlaystateService" (the controller name in both Emby's and
|
|
* Jellyfin's server source) is what exposes this route.
|
|
*
|
|
* Endpoint/param shape CONFIRMED against Emby's own real OpenAPI spec
|
|
* (swagger.emby.media, pointed at a real server) - the original guess
|
|
* here was wrong in a way that plausibly broke this entire feature:
|
|
* PositionTicks/IsPaused were sent as a JSON body, but Emby declares
|
|
* them (and a REQUIRED MediaSourceId this app wasn't sending at all) as
|
|
* *query string* parameters instead - the request body schema
|
|
* (Api.OnPlaybackProgress) covers a handful of unrelated fields
|
|
* (PlaylistIndex/Shuffle/SleepTimerMode/...), none of them these. A
|
|
* missing required query parameter almost certainly meant every one of
|
|
* these calls was failing outright.
|
|
*/
|
|
class PlaystateServiceClass {
|
|
/**
|
|
* Best-effort: any failure (Emby unreachable, a stale/expired token,
|
|
* a 4xx/5xx) is caught and logged here, never thrown - this runs on a
|
|
* timer with no request to fail, and one member's bad token or one
|
|
* transient Emby hiccup must never stop the rest of the sweep, let
|
|
* alone touch real playback. `mediaSourceId` comes from
|
|
* PartyStreamService's own cache (see index.ts's sweep) - resolving a
|
|
* fresh one here would mean an extra PlaybackInfo round trip on every
|
|
* tick, for a value the party's already-negotiated stream already
|
|
* produced for free.
|
|
*/
|
|
async reportProgress(embyToken: string, userId: string, itemId: string, mediaSourceId: string, positionTicks: number, isPaused: boolean): Promise<void> {
|
|
try {
|
|
const params = new URLSearchParams({
|
|
MediaSourceId: mediaSourceId,
|
|
PositionTicks: String(Math.round(positionTicks)),
|
|
IsPaused: String(isPaused)
|
|
});
|
|
await EmbyClient.fetch(`/Users/${userId}/PlayingItems/${itemId}/Progress?${params}`, embyToken, { method: 'POST' });
|
|
} catch (err) {
|
|
Logger.playstate(`progress report failed userId=${userId} itemId=${itemId}: ${(err as Error).message}`);
|
|
}
|
|
}
|
|
}
|
|
|
|
export const PlaystateService = new PlaystateServiceClass();
|