Fix playstate 400 "Value cannot be null": missing PlaySessionId
All checks were successful
Publier l'image Docker / test (push) Successful in 23s
Publier l'image Docker / publish (push) Successful in 15s

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>
This commit is contained in:
Nicolas 2026-09-14 22:56:54 +02:00
parent e6d71f2e37
commit 8718175bae
9 changed files with 156 additions and 82 deletions

View file

@ -108,11 +108,15 @@ attempt at a Blu-ray-style subtitle as the thing to double-check here.
unconfirmed (the ffmpeg direct-stream URL, the Playlists endpoint, the unconfirmed (the ffmpeg direct-stream URL, the Playlists endpoint, the
Emby-playstate sync endpoint) were all checked directly against Emby's Emby-playstate sync endpoint) were all checked directly against Emby's
own real OpenAPI spec. Two matched exactly. The third - playstate sync own real OpenAPI spec. Two matched exactly. The third - playstate sync
- didn't: `PositionTicks`/`IsPaused` were being sent as a JSON body, - took two rounds to actually fix: `PositionTicks`/`IsPaused` were
but Emby expects them as query parameters, alongside a required being sent as a JSON body when Emby expects query parameters, alongside
`MediaSourceId` that wasn't being sent at all - meaning every progress a required `MediaSourceId` that wasn't being sent at all (round 1); a
report was almost certainly failing outright. Fixed - see real request logged after that shipped still failed, this time with
docs/architecture.md's "Sync watch-party progress back to Emby" Emby's own "Value cannot be null (Parameter 'key')" - traced to a
missing `PlaySessionId` (round 2, from new logging added specifically
to catch exactly this). Both fixed, though round 2 hasn't yet been
confirmed against a real server the way round 1's own bad request was
- see docs/architecture.md's "Sync watch-party progress back to Emby"
section for the full story. The one-shared-stream-per-party work is a section for the full story. The one-shared-stream-per-party work is a
different kind of unverified: its own Emby API calls were already live, different kind of unverified: its own Emby API calls were already live,
confirmed code, just newly shared instead of per-viewer - what's untested is behavior confirmed code, just newly shared instead of per-viewer - what's untested is behavior

View file

@ -2023,26 +2023,53 @@ it" on its own, without cross-referencing `EmbyClient`'s own generic
`[watchparty:emby]` failure log (which still fires too, independently, `[watchparty:emby]` failure log (which still fires too, independently,
with the fuller error body). Safe to log the full path here, unlike with the fuller error body). Safe to log the full path here, unlike
`Logger.stream`'s own deliberately-partial logging - this query string `Logger.stream`'s own deliberately-partial logging - this query string
is only ever `MediaSourceId`/`PositionTicks`/`IsPaused`, never a token; is only ever `MediaSourceId`/`PlaySessionId`/`PositionTicks`/`IsPaused`,
the real `embyToken` travels as a header, never in the URL, and the never a token; the real `embyToken` travels as a header, never in the
tests confirm it never ends up in these lines either. URL, and the tests confirm it never ends up in these lines either.
**Round 2, caught immediately from that new logging**: a real request
logged after the fix above shipped still came back `400`, now `Value
cannot be null. (Parameter 'key')` - a bare .NET `Dictionary` null-key
exception, and *not* the earlier problem (the logged request's
`MediaSourceId` was present and well-formed). This exact message had
already burned this app once before, in `CatalogService
.negotiateStreamTarget`'s hand-built fallback URL (see its own
TODO(verify) comment) - diagnosed there as Emby indexing some internal
state by a parameter the request hadn't supplied. `PlaySessionId` was
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 something in Emby's real
handler keys a lookup on it even though the OpenAPI spec doesn't mark
it required. Now sent too, resolved the exact same way `MediaSourceId`
already was: `CatalogService.negotiateStreamTarget` already reads
`info.PlaySessionId` from the very same `PlaybackInfo` response (it
already used it in the hand-built fallback URL) - its return type
(`NegotiatedStreamTarget`) just needed to surface it, so
`PartyStreamService` could cache it alongside `mediaSourceId` and
expose both together from one accessor,
`getCachedPlaybackIds(partyId)`, replacing the round-1 `getCached
MediaSourceId`. Not independently confirmed against a real server yet
(no way to safely fire a real, state-mutating test call without one) -
flagged here 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.
Verified: full typecheck/test/build on both projects. Unit tests cover Verified: full typecheck/test/build on both projects. Unit tests cover
`getCurrentPlaybackState` (video reads the host, music reads the shared `getCurrentPlaybackState` (video reads the host, music reads the shared
queue, both branches, plus the empty-queue null case), queue, both branches, plus the empty-queue null case),
`setMemberEmbyToken`, `PlaystateService.reportProgress`'s corrected `setMemberEmbyToken`, `PlaystateService.reportProgress`'s corrected
query-string request shape (and that a thrown failure never query-string request shape including `PlaySessionId` (and that a thrown
propagates), its own request/outcome logging (success, a non-ok failure never propagates), its own request/outcome logging (success, a
status, and a thrown error each produce a distinct, greppable line, non-ok status, and a thrown error each produce a distinct, greppable
and the token itself never appears in one), and line, and the token itself never appears in one), and
`PartyStreamService.getCachedMediaSourceId`'s `PartyStreamService.getCachedPlaybackIds`'s
cache-hit/cache-miss/dropped-on-failure behavior. `EmbyClient.fetch`'s cache-hit/cache-miss/dropped-on-failure behavior for both ids together.
optional body-encoding capability stays generic plumbing (its own test `EmbyClient.fetch`'s optional body-encoding capability stays generic
no longer claims PlaystateService as the reason it exists) - no plumbing (its own test no longer claims PlaystateService as the reason
current call site needs it any more, but it costs nothing to keep for it exists) - no current call site needs it any more, but it costs
whatever future caller does. No client changes at all - this is a pure nothing to keep for whatever future caller does. No client changes at
server-side background process with nothing for the browser to render all - this is a pure server-side background process with nothing for
or react to. the browser to render or react to.
## One shared Emby stream per party (not one per viewer) ## One shared Emby stream per party (not one per viewer)

View file

@ -509,21 +509,21 @@ setInterval(() => PartyStreamService.reapIdleSessions(30 * 60 * 1000), 5 * 60 *
// (periodic Progress reports only, no explicit start/stop). Best-effort: // (periodic Progress reports only, no explicit start/stop). Best-effort:
// PlaystateService.reportProgress swallows its own failures, so one bad // PlaystateService.reportProgress swallows its own failures, so one bad
// token or a transient Emby hiccup never stops the rest of this sweep. // token or a transient Emby hiccup never stops the rest of this sweep.
// MediaSourceId comes from PartyStreamService's own cache, read-only // MediaSourceId/PlaySessionId come from PartyStreamService's own cache,
// (getCachedMediaSourceId never negotiates) - it's a required param on // read-only (getCachedPlaybackIds never negotiates) - both matter to the
// the real Progress endpoint (confirmed against Emby's OpenAPI spec, // real Progress endpoint in practice (see PlaystateService.ts), and
// see PlaystateService.ts), and skipping a party entirely when nothing's // skipping a party entirely when nothing's cached yet (no one has hit
// cached yet (no one has hit play this session) is simpler and cheaper // play this session) is simpler and cheaper than this sweep ever
// than this sweep ever negotiating a stream of its own just to get one. // negotiating a stream of its own just to get them.
setInterval(() => { setInterval(() => {
for (const party of PartyService.listActive()) { for (const party of PartyService.listActive()) {
const state = PartyService.getCurrentPlaybackState(party); const state = PartyService.getCurrentPlaybackState(party);
if (!state) continue; // nothing currently playing (e.g. an empty music queue) if (!state) continue; // nothing currently playing (e.g. an empty music queue)
const mediaSourceId = PartyStreamService.getCachedMediaSourceId(party.id); const playbackIds = PartyStreamService.getCachedPlaybackIds(party.id);
if (!mediaSourceId) continue; // nothing negotiated yet this session - Progress requires it if (!playbackIds) continue; // nothing negotiated yet this session - Progress needs it
for (const member of party.members.values()) { for (const member of party.members.values()) {
if (!member.connection || !member.embyToken) continue; if (!member.connection || !member.embyToken) continue;
PlaystateService.reportProgress(member.embyToken, member.userId, state.itemId, mediaSourceId, state.positionTicks, state.isPaused); PlaystateService.reportProgress(member.embyToken, member.userId, state.itemId, playbackIds.mediaSourceId, playbackIds.playSessionId, state.positionTicks, state.isPaused);
} }
} }
}, 20 * 1000); }, 20 * 1000);

View file

@ -17,6 +17,7 @@ const WATCH_PARTY_TYPES = new Set(['Movie', 'Episode', 'Video', 'MusicVideo', 'A
export interface NegotiatedStreamTarget { export interface NegotiatedStreamTarget {
targetPath: string; targetPath: string;
mediaSourceId: string; mediaSourceId: string;
playSessionId: string;
} }
// Emby's /Views can return all sorts of things beyond actual media // Emby's /Views can return all sorts of things beyond actual media
@ -309,13 +310,21 @@ class CatalogServiceClass {
* encoding option this app has no visibility into, sidesteps the * encoding option this app has no visibility into, sidesteps the
* entire class of failure. * entire class of failure.
* *
* Returns `mediaSourceId` alongside the target path, not just the * Returns `mediaSourceId`/`playSessionId` alongside the target path,
* path - PartyStreamService caches both, and PlaystateService's own * not just the path - PartyStreamService caches all three, and
* Progress report needs a real MediaSourceId too (a required query * PlaystateService's own Progress report needs both too: MediaSourceId
* parameter on that endpoint, confirmed against Emby's real OpenAPI * is a required query parameter there (confirmed against Emby's real
* spec - see PlaystateService.ts). Resolving it here, once per * OpenAPI spec), and PlaySessionId - though only optional per that
* negotiation, means the playstate sweep never has to make its own * same spec - turned out to matter in practice too: a real report sent
* extra PlaybackInfo round trip just to get it. * without one hit Emby's own "Value cannot be null (Parameter 'key')"
* (a bare .NET Dictionary null-key exception) - the exact error this
* app already ran into once before, in the stream-negotiation fallback
* URL above, for the same underlying reason (see that TODO(verify)
* comment) - so Emby's real Progress handler evidently keys some
* internal lookup (an active transcode/session entry) on it even
* though the spec doesn't mark it required. Resolving both here, once
* per negotiation, means the playstate sweep never has to make its own
* extra PlaybackInfo round trip just to get them.
*/ */
async negotiateStreamTarget(session: EmbySession, itemId: string, isAudio: boolean, audioStreamIndex: string, maxStreamingBitrate: string): Promise<NegotiatedStreamTarget> { async negotiateStreamTarget(session: EmbySession, itemId: string, isAudio: boolean, audioStreamIndex: string, maxStreamingBitrate: string): Promise<NegotiatedStreamTarget> {
// Applied to the negotiation itself, not just the final URL - a pick // Applied to the negotiation itself, not just the final URL - a pick
@ -346,7 +355,7 @@ class CatalogServiceClass {
const params = new URLSearchParams(urlQuery || ''); const params = new URLSearchParams(urlQuery || '');
this.applyPlaybackOverrides(params, audioStreamIndex, maxStreamingBitrate); this.applyPlaybackOverrides(params, audioStreamIndex, maxStreamingBitrate);
this.logStreamRequest(itemId, 'negotiated', params); this.logStreamRequest(itemId, 'negotiated', params);
return { targetPath: `${urlPath}?${params}`, mediaSourceId: source.Id || '' }; return { targetPath: `${urlPath}?${params}`, mediaSourceId: source.Id || '', playSessionId: info.PlaySessionId || '' };
} }
const params = new URLSearchParams({ const params = new URLSearchParams({
@ -358,7 +367,7 @@ class CatalogServiceClass {
}); });
this.applyPlaybackOverrides(params, audioStreamIndex, maxStreamingBitrate); this.applyPlaybackOverrides(params, audioStreamIndex, maxStreamingBitrate);
this.logStreamRequest(itemId, 'fallback', params); this.logStreamRequest(itemId, 'fallback', params);
return { targetPath: `/${isAudio ? 'Audio' : 'Videos'}/${itemId}/master.m3u8?${params}`, mediaSourceId: source.Id || '' }; return { targetPath: `/${isAudio ? 'Audio' : 'Videos'}/${itemId}/master.m3u8?${params}`, mediaSourceId: source.Id || '', playSessionId: info.PlaySessionId || '' };
} }
/** /**

View file

@ -13,11 +13,13 @@ interface StreamSession {
targetPath: string; targetPath: string;
/** Alongside targetPath, from the same negotiation - see /** Alongside targetPath, from the same negotiation - see
* CatalogService.negotiateStreamTarget's own comment. Surfaced * CatalogService.negotiateStreamTarget's own comment. Surfaced
* read-only via getCachedMediaSourceId() below for PlaystateService's * read-only, together, via getCachedPlaybackIds() below for
* own Progress report, which needs it too (a required query param on * PlaystateService's own Progress report, which needs both too (see
* that endpoint) but must never trigger a negotiation of its own just * that method's own comment for why) but must never trigger a
* to get it - see index.ts's periodic sweep. */ * negotiation of its own just to get them - see index.ts's periodic
* sweep. */
mediaSourceId: string; mediaSourceId: string;
playSessionId: string;
lastAccessUtc: number; lastAccessUtc: number;
} }
@ -65,23 +67,28 @@ class PartyStreamServiceClass {
return existing.targetPath; return existing.targetPath;
} }
const { targetPath, mediaSourceId } = await CatalogService.negotiateStreamTarget(session, itemId, isAudio, audioStreamIndex, maxStreamingBitrate); const { targetPath, mediaSourceId, playSessionId } = await CatalogService.negotiateStreamTarget(session, itemId, isAudio, audioStreamIndex, maxStreamingBitrate);
this.sessions.set(party.id, { itemId, audioStreamIndex, maxStreamingBitrate, targetPath, mediaSourceId, lastAccessUtc: Date.now() }); this.sessions.set(party.id, { itemId, audioStreamIndex, maxStreamingBitrate, targetPath, mediaSourceId, playSessionId, lastAccessUtc: Date.now() });
return targetPath; return targetPath;
} }
/** /**
* Read-only: the current MediaSourceId behind this party's cached * Read-only: the MediaSourceId/PlaySessionId behind this party's
* stream, or null if nothing has been negotiated yet this session * cached stream, or null if nothing has been negotiated yet this
* (e.g. the party just started and no one has hit play). Deliberately * session (e.g. the party just started and no one has hit play).
* does NOT call getOrNegotiate/touch lastAccessUtc - this is a passive * Deliberately does NOT call getOrNegotiate/touch lastAccessUtc - this
* lookup for index.ts's periodic playstate sweep, not real streaming * is a passive lookup for index.ts's periodic playstate sweep, not
* traffic, and must never itself keep an otherwise-idle session alive * real streaming traffic, and must never itself keep an otherwise-idle
* (see reapIdleSessions) or trigger a fresh Emby negotiation on its * session alive (see reapIdleSessions) or trigger a fresh Emby
* own timer tick. * negotiation on its own timer tick. Returns both together, from the
* same cache entry, rather than two separate lookups - they always
* come from the same negotiation, and PlaystateService needs both at
* once anyway (see its own comment for why PlaySessionId turned out to
* matter too, not just MediaSourceId).
*/ */
getCachedMediaSourceId(partyId: string): string | null { getCachedPlaybackIds(partyId: string): { mediaSourceId: string; playSessionId: string } | null {
return this.sessions.get(partyId)?.mediaSourceId ?? null; const session = this.sessions.get(partyId);
return session ? { mediaSourceId: session.mediaSourceId, playSessionId: session.playSessionId } : null;
} }
/** GET /api/catalog/party-stream/:partyId/master.m3u8 - the caller /** GET /api/catalog/party-stream/:partyId/master.m3u8 - the caller

View file

@ -26,6 +26,24 @@ import { Logger } from '../Logger';
* (PlaylistIndex/Shuffle/SleepTimerMode/...), none of them these. A * (PlaylistIndex/Shuffle/SleepTimerMode/...), none of them these. A
* missing required query parameter almost certainly meant every one of * missing required query parameter almost certainly meant every one of
* these calls was failing outright. * these calls was failing outright.
*
* Round 2, from a real request logged after that fix shipped: still a
* 400, now `Value cannot be null. (Parameter 'key')` - a bare .NET
* `Dictionary` null-key exception, and *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 TODO(verify) comment) - diagnosed there as Emby's server
* indexing some internal state by a param this app hadn't supplied.
* `PlaySessionId` is the standout candidate: it's how Emby/Jellyfin
* clients normally correlate a progress report with the specific
* playback/transcode session it belongs to, and evidently something on
* Emby's side keys a lookup on it even though the OpenAPI spec doesn't
* mark it required. Now sent too - see PartyStreamService
* .getCachedPlaybackIds, which resolves it the exact same way
* MediaSourceId already was (both come from the one PlaybackInfo
* negotiation CatalogService.negotiateStreamTarget already does for the
* party's shared stream).
*/ */
class PlaystateServiceClass { class PlaystateServiceClass {
/** /**
@ -33,10 +51,10 @@ class PlaystateServiceClass {
* a 4xx/5xx) is caught and logged here, never thrown - this runs on a * 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 * 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 * transient Emby hiccup must never stop the rest of the sweep, let
* alone touch real playback. `mediaSourceId` comes from * alone touch real playback. `mediaSourceId`/`playSessionId` come from
* PartyStreamService's own cache (see index.ts's sweep) - resolving a * PartyStreamService's own cache (see index.ts's sweep) - resolving
* fresh one here would mean an extra PlaybackInfo round trip on every * fresh ones here would mean an extra PlaybackInfo round trip on every
* tick, for a value the party's already-negotiated stream already * tick, for values the party's already-negotiated stream already
* produced for free. * produced for free.
* *
* Every call logs the exact outgoing request (method, path, query - * Every call logs the exact outgoing request (method, path, query -
@ -46,9 +64,10 @@ class PlaystateServiceClass {
* to answer "is this actually reaching Emby, and with what" without * to answer "is this actually reaching Emby, and with what" without
* digging through EmbyClient's own generic failure log. * digging through EmbyClient's own generic failure log.
*/ */
async reportProgress(embyToken: string, userId: string, itemId: string, mediaSourceId: string, positionTicks: number, isPaused: boolean): Promise<void> { async reportProgress(embyToken: string, userId: string, itemId: string, mediaSourceId: string, playSessionId: string, positionTicks: number, isPaused: boolean): Promise<void> {
const params = new URLSearchParams({ const params = new URLSearchParams({
MediaSourceId: mediaSourceId, MediaSourceId: mediaSourceId,
PlaySessionId: playSessionId,
PositionTicks: String(Math.round(positionTicks)), PositionTicks: String(Math.round(positionTicks)),
IsPaused: String(isPaused) IsPaused: String(isPaused)
}); });

View file

@ -47,11 +47,12 @@ describe('CatalogService', () => {
PlaySessionId: 'sess1' PlaySessionId: 'sess1'
})); }));
const { targetPath, mediaSourceId } = await CatalogService.negotiateStreamTarget(session, 'item1', false, '2', ''); const { targetPath, mediaSourceId, playSessionId } = await CatalogService.negotiateStreamTarget(session, 'item1', false, '2', '');
assert.ok(targetPath.includes('AudioStreamIndex=2')); assert.ok(targetPath.includes('AudioStreamIndex=2'));
assert.ok(!targetPath.includes('Subtitle'), 'this method never touches subtitle params at all'); assert.ok(!targetPath.includes('Subtitle'), 'this method never touches subtitle params at all');
assert.equal(mediaSourceId, 'src1', 'PlaystateService\'s own Progress report needs this too - see PartyStreamService'); assert.equal(mediaSourceId, 'src1', 'PlaystateService\'s own Progress report needs this too - see PartyStreamService');
assert.equal(playSessionId, 'sess1', 'so does this - see PlaystateService\'s own "Value cannot be null" comment');
}); });
it('negotiateStreamTarget\'s hand-built fallback picks /Audio/ (and drops VideoCodec) when isAudio is true', async (t) => { it('negotiateStreamTarget\'s hand-built fallback picks /Audio/ (and drops VideoCodec) when isAudio is true', async (t) => {
@ -66,10 +67,12 @@ describe('CatalogService', () => {
it('negotiateStreamTarget\'s hand-built fallback picks /Videos/ (with VideoCodec) when isAudio is false', async (t) => { it('negotiateStreamTarget\'s hand-built fallback picks /Videos/ (with VideoCodec) when isAudio is false', async (t) => {
t.mock.method(EmbyClient, 'fetch', async () => jsonResponse({ MediaSources: [{ Id: 'src3' }], PlaySessionId: 'sess3' })); t.mock.method(EmbyClient, 'fetch', async () => jsonResponse({ MediaSources: [{ Id: 'src3' }], PlaySessionId: 'sess3' }));
const { targetPath } = await CatalogService.negotiateStreamTarget(session, 'movie1', false, '', ''); const { targetPath, mediaSourceId, playSessionId } = await CatalogService.negotiateStreamTarget(session, 'movie1', false, '', '');
assert.ok(targetPath.startsWith('/Videos/movie1/master.m3u8')); assert.ok(targetPath.startsWith('/Videos/movie1/master.m3u8'));
assert.ok(targetPath.includes('VideoCodec=h264')); assert.ok(targetPath.includes('VideoCodec=h264'));
assert.equal(mediaSourceId, 'src3');
assert.equal(playSessionId, 'sess3', 'the hand-built fallback branch must surface this too, not just the negotiated-TranscodingUrl one');
}); });
it('negotiateStreamTarget applies a quality (MaxStreamingBitrate) pick the same way as an audio pick', async (t) => { it('negotiateStreamTarget applies a quality (MaxStreamingBitrate) pick the same way as an audio pick', async (t) => {

View file

@ -40,7 +40,7 @@ describe('PartyStreamService.getOrNegotiate', () => {
let negotiateCount = 0; let negotiateCount = 0;
t.mock.method(CatalogService, 'negotiateStreamTarget', async () => { t.mock.method(CatalogService, 'negotiateStreamTarget', async () => {
negotiateCount += 1; negotiateCount += 1;
return { targetPath: '/Videos/movie1/master.m3u8?PlaySessionId=sess1', mediaSourceId: 'src1' }; return { targetPath: '/Videos/movie1/master.m3u8?PlaySessionId=sess1', mediaSourceId: 'src1', playSessionId: 'sess1' };
}); });
const party = PartyService.create('u-alice', 'Alice', 'movie1', 'Un Film', '/img/movie1', null, 'video'); const party = PartyService.create('u-alice', 'Alice', 'movie1', 'Un Film', '/img/movie1', null, 'video');
@ -49,7 +49,7 @@ describe('PartyStreamService.getOrNegotiate', () => {
assert.equal(negotiateCount, 1, 'the second call must reuse the cached target, not negotiate again'); assert.equal(negotiateCount, 1, 'the second call must reuse the cached target, not negotiate again');
assert.equal(first, second); assert.equal(first, second);
assert.equal(PartyStreamService.getCachedMediaSourceId(party.id), 'src1', 'cached alongside the target - see PlaystateService'); assert.deepEqual(PartyStreamService.getCachedPlaybackIds(party.id), { mediaSourceId: 'src1', playSessionId: 'sess1' }, 'cached alongside the target - see PlaystateService');
PartyStreamService.endSession(party.id); PartyStreamService.endSession(party.id);
}); });
@ -58,7 +58,7 @@ describe('PartyStreamService.getOrNegotiate', () => {
let negotiateCount = 0; let negotiateCount = 0;
t.mock.method(CatalogService, 'negotiateStreamTarget', async () => { t.mock.method(CatalogService, 'negotiateStreamTarget', async () => {
negotiateCount += 1; negotiateCount += 1;
return { targetPath: `/Videos/movie2/master.m3u8?call=${negotiateCount}`, mediaSourceId: `src${negotiateCount}` }; return { targetPath: `/Videos/movie2/master.m3u8?call=${negotiateCount}`, mediaSourceId: `src${negotiateCount}`, playSessionId: `sess${negotiateCount}` };
}); });
const party = PartyService.create('u-alice', 'Alice', 'movie2', 'Un Film 2', '/img/movie2', null, 'video'); const party = PartyService.create('u-alice', 'Alice', 'movie2', 'Un Film 2', '/img/movie2', null, 'video');
@ -77,7 +77,7 @@ describe('PartyStreamService.getOrNegotiate', () => {
let negotiateCount = 0; let negotiateCount = 0;
t.mock.method(CatalogService, 'negotiateStreamTarget', async () => { t.mock.method(CatalogService, 'negotiateStreamTarget', async () => {
negotiateCount += 1; negotiateCount += 1;
return { targetPath: `/Videos/x/master.m3u8?call=${negotiateCount}`, mediaSourceId: `src${negotiateCount}` }; return { targetPath: `/Videos/x/master.m3u8?call=${negotiateCount}`, mediaSourceId: `src${negotiateCount}`, playSessionId: `sess${negotiateCount}` };
}); });
const party = PartyService.create('u-alice', 'Alice', 'ep1', 'S01E01', '/img/ep1', [ const party = PartyService.create('u-alice', 'Alice', 'ep1', 'S01E01', '/img/ep1', [
@ -96,7 +96,7 @@ describe('PartyStreamService.getOrNegotiate', () => {
let capturedArgs: unknown[] = []; let capturedArgs: unknown[] = [];
t.mock.method(CatalogService, 'negotiateStreamTarget', async (...args: unknown[]) => { t.mock.method(CatalogService, 'negotiateStreamTarget', async (...args: unknown[]) => {
capturedArgs = args; capturedArgs = args;
return { targetPath: '/Audio/t1/master.m3u8', mediaSourceId: 'src-t1' }; return { targetPath: '/Audio/t1/master.m3u8', mediaSourceId: 'src-t1', playSessionId: 'sess-t1' };
}); });
const party = PartyService.create('u-alice', 'Alice', 't1', 'Track One', '/img/t1', null, 'music'); const party = PartyService.create('u-alice', 'Alice', 't1', 'Track One', '/img/t1', null, 'music');
@ -123,7 +123,7 @@ describe('PartyStreamService failure recovery', () => {
let negotiateCount = 0; let negotiateCount = 0;
t.mock.method(CatalogService, 'negotiateStreamTarget', async () => { t.mock.method(CatalogService, 'negotiateStreamTarget', async () => {
negotiateCount += 1; negotiateCount += 1;
return { targetPath: `/Videos/movie3/master.m3u8?call=${negotiateCount}`, mediaSourceId: `src${negotiateCount}` }; return { targetPath: `/Videos/movie3/master.m3u8?call=${negotiateCount}`, mediaSourceId: `src${negotiateCount}`, playSessionId: `sess${negotiateCount}` };
}); });
t.mock.method(EmbyClient, 'proxyResponse', async (_req: Request, res: Response) => { t.mock.method(EmbyClient, 'proxyResponse', async (_req: Request, res: Response) => {
(res as unknown as { statusCode: number }).statusCode = 502; // simulate Emby having ended this session on its own (res as unknown as { statusCode: number }).statusCode = 502; // simulate Emby having ended this session on its own
@ -131,7 +131,7 @@ describe('PartyStreamService failure recovery', () => {
const party = PartyService.create('u-alice', 'Alice', 'movie3', 'Un Film 3', '/img/movie3', null, 'video'); const party = PartyService.create('u-alice', 'Alice', 'movie3', 'Un Film 3', '/img/movie3', null, 'video');
await PartyStreamService.serveMasterPlaylist(fakeReq(), fakeRes(), party, FAKE_SESSION); await PartyStreamService.serveMasterPlaylist(fakeReq(), fakeRes(), party, FAKE_SESSION);
assert.equal(PartyStreamService.getCachedMediaSourceId(party.id), null, 'a failed response must drop the MediaSourceId too, not just the target path'); assert.equal(PartyStreamService.getCachedPlaybackIds(party.id), null, 'a failed response must drop the MediaSourceId/PlaySessionId too, not just the target path');
await PartyStreamService.serveMasterPlaylist(fakeReq(), fakeRes(), party, FAKE_SESSION); await PartyStreamService.serveMasterPlaylist(fakeReq(), fakeRes(), party, FAKE_SESSION);
assert.equal(negotiateCount, 2, 'a failed response must not be cached - the next request has to renegotiate'); assert.equal(negotiateCount, 2, 'a failed response must not be cached - the next request has to renegotiate');
@ -143,7 +143,7 @@ describe('PartyStreamService failure recovery', () => {
let negotiateCount = 0; let negotiateCount = 0;
t.mock.method(CatalogService, 'negotiateStreamTarget', async () => { t.mock.method(CatalogService, 'negotiateStreamTarget', async () => {
negotiateCount += 1; negotiateCount += 1;
return { targetPath: '/Videos/movie4/master.m3u8', mediaSourceId: 'src-movie4' }; return { targetPath: '/Videos/movie4/master.m3u8', mediaSourceId: 'src-movie4', playSessionId: 'sess-movie4' };
}); });
t.mock.method(EmbyClient, 'proxyResponse', async () => {}); // res.statusCode stays 200, the fakeRes() default t.mock.method(EmbyClient, 'proxyResponse', async () => {}); // res.statusCode stays 200, the fakeRes() default
@ -162,15 +162,15 @@ describe('PartyStreamService lifecycle', () => {
assert.doesNotThrow(() => PartyStreamService.endSession('no-such-party')); assert.doesNotThrow(() => PartyStreamService.endSession('no-such-party'));
}); });
it('getCachedMediaSourceId is null for a party that never negotiated a stream - never negotiates one itself', () => { it('getCachedPlaybackIds is null for a party that never negotiated a stream - never negotiates one itself', () => {
assert.equal(PartyStreamService.getCachedMediaSourceId('no-such-party'), null); assert.equal(PartyStreamService.getCachedPlaybackIds('no-such-party'), null);
}); });
it('reapIdleSessions ends sessions past the idle cutoff, leaves a fresh one alone', async (t) => { it('reapIdleSessions ends sessions past the idle cutoff, leaves a fresh one alone', async (t) => {
let negotiateCount = 0; let negotiateCount = 0;
t.mock.method(CatalogService, 'negotiateStreamTarget', async () => { t.mock.method(CatalogService, 'negotiateStreamTarget', async () => {
negotiateCount += 1; negotiateCount += 1;
return { targetPath: `/Videos/x/master.m3u8?call=${negotiateCount}`, mediaSourceId: `src${negotiateCount}` }; return { targetPath: `/Videos/x/master.m3u8?call=${negotiateCount}`, mediaSourceId: `src${negotiateCount}`, playSessionId: `sess${negotiateCount}` };
}); });
const party = PartyService.create('u-eve', 'Eve', 'movie5', 'Un Film 5', '/img/movie5', null, 'video'); const party = PartyService.create('u-eve', 'Eve', 'movie5', 'Un Film 5', '/img/movie5', null, 'video');

View file

@ -19,15 +19,19 @@ describe('PlaystateService.reportProgress', () => {
return new Response('{}', { status: 200 }); return new Response('{}', { status: 200 });
}); });
await PlaystateService.reportProgress('tok-alice', 'u-alice', 'item1', 'src1', 12_345_678, false); 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 // Query params, not a JSON body - confirmed against Emby's real
// OpenAPI spec (see PlaystateService.ts's own comment): the // OpenAPI spec (see PlaystateService.ts's own comment): the
// original body-based guess was missing a required MediaSourceId // original body-based guess was missing a required MediaSourceId
// entirely and would have failed outright. // 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('?'); const [path, query] = capturedPath.split('?');
assert.equal(path, '/Users/u-alice/PlayingItems/item1/Progress'); assert.equal(path, '/Users/u-alice/PlayingItems/item1/Progress');
assert.equal(new URLSearchParams(query).get('MediaSourceId'), 'src1'); 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('PositionTicks'), '12345678');
assert.equal(new URLSearchParams(query).get('IsPaused'), 'false'); assert.equal(new URLSearchParams(query).get('IsPaused'), 'false');
assert.equal(capturedToken, 'tok-alice'); assert.equal(capturedToken, 'tok-alice');
@ -41,7 +45,7 @@ describe('PlaystateService.reportProgress', () => {
return new Response('{}', { status: 200 }); return new Response('{}', { status: 200 });
}); });
await PlaystateService.reportProgress('tok', 'u1', 'item1', 'src1', 100.7, true); await PlaystateService.reportProgress('tok', 'u1', 'item1', 'src1', 'sess1', 100.7, true);
const query = capturedPath.split('?')[1]; const query = capturedPath.split('?')[1];
assert.equal(new URLSearchParams(query).get('PositionTicks'), '101'); assert.equal(new URLSearchParams(query).get('PositionTicks'), '101');
@ -50,7 +54,7 @@ describe('PlaystateService.reportProgress', () => {
it('swallows a failure instead of throwing - this runs on a timer with nothing to catch it', async (t) => { 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'); }); t.mock.method(EmbyClient, 'fetch', async () => { throw new Error('Emby unreachable'); });
await assert.doesNotReject(() => PlaystateService.reportProgress('tok', 'u1', 'item1', 'src1', 0, false)); await assert.doesNotReject(() => PlaystateService.reportProgress('tok', 'u1', 'item1', 'src1', 'sess1', 0, false));
}); });
// Logging: the whole point is being able to answer "what did this // Logging: the whole point is being able to answer "what did this
@ -61,12 +65,13 @@ describe('PlaystateService.reportProgress', () => {
t.mock.method(console, 'log', (line: string) => { logged.push(line); }); t.mock.method(console, 'log', (line: string) => { logged.push(line); });
t.mock.method(EmbyClient, 'fetch', async () => new Response('{}', { status: 200 })); t.mock.method(EmbyClient, 'fetch', async () => new Response('{}', { status: 200 }));
await PlaystateService.reportProgress('super-secret-token', 'u-alice', 'item1', 'src1', 12_345_678, false); 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')); const requestLine = logged.find((l) => l.includes('[watchparty:playstate]') && l.includes('POST'));
assert.ok(requestLine, 'the request itself must be logged'); assert.ok(requestLine, 'the request itself must be logged');
assert.ok(requestLine!.includes('/Users/u-alice/PlayingItems/item1/Progress')); assert.ok(requestLine!.includes('/Users/u-alice/PlayingItems/item1/Progress'));
assert.ok(requestLine!.includes('MediaSourceId=src1')); assert.ok(requestLine!.includes('MediaSourceId=src1'));
assert.ok(requestLine!.includes('PlaySessionId=sess1'));
assert.ok(requestLine!.includes('PositionTicks=12345678')); assert.ok(requestLine!.includes('PositionTicks=12345678'));
assert.ok(requestLine!.includes('IsPaused=false')); 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'); assert.ok(!requestLine!.includes('super-secret-token'), 'the token travels as a header, never logged into the request line');
@ -77,7 +82,7 @@ describe('PlaystateService.reportProgress', () => {
t.mock.method(console, 'log', (line: string) => { logged.push(line); }); 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 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', 0, false); await PlaystateService.reportProgress('tok', 'u1', 'item1', 'src1', 'sess1', 0, false);
const outcomeLine = logged.find((l) => l.includes('[watchparty:playstate]') && l.includes('204')); 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, 'the real status code must be logged');
@ -85,12 +90,12 @@ describe('PlaystateService.reportProgress', () => {
assert.ok(!outcomeLine!.includes('FAILED')); assert.ok(!outcomeLine!.includes('FAILED'));
}); });
it('logs a non-ok response as FAILED, with its real HTTP status - the exact case the original wrong request shape would have hit', async (t) => { 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[] = []; const logged: string[] = [];
t.mock.method(console, 'log', (line: string) => { logged.push(line); }); t.mock.method(console, 'log', (line: string) => { logged.push(line); });
t.mock.method(EmbyClient, 'fetch', async () => new Response('bad request', { status: 400 })); t.mock.method(EmbyClient, 'fetch', async () => new Response("Value cannot be null. (Parameter 'key')", { status: 400 }));
await PlaystateService.reportProgress('tok', 'u1', 'item1', 'src1', 0, false); await PlaystateService.reportProgress('tok', 'u1', 'item1', 'src1', 'sess1', 0, false);
const outcomeLine = logged.find((l) => l.includes('[watchparty:playstate]') && l.includes('400')); 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, 'the real status code must be logged');
@ -102,7 +107,7 @@ describe('PlaystateService.reportProgress', () => {
t.mock.method(console, 'log', (line: string) => { logged.push(line); }); t.mock.method(console, 'log', (line: string) => { logged.push(line); });
t.mock.method(EmbyClient, 'fetch', async () => { throw new Error('Emby unreachable'); }); t.mock.method(EmbyClient, 'fetch', async () => { throw new Error('Emby unreachable'); });
await PlaystateService.reportProgress('tok', 'u1', 'item1', 'src1', 0, false); await PlaystateService.reportProgress('tok', 'u1', 'item1', 'src1', 'sess1', 0, false);
const outcomeLine = logged.find((l) => l.includes('[watchparty:playstate]') && l.includes('threw')); 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, 'a thrown error must still be logged, not just swallowed silently');