watchparty/server/test/partyStreamService.test.ts
Nicolas f78b2f1408
All checks were successful
Publier l'image Docker / test (push) Successful in 21s
Publier l'image Docker / publish (push) Successful in 20s
One shared Emby stream per party, not one per viewer
Asked for directly: exactly one stream should ever be open between this
app and Emby per party, no matter how many members it has. Before this,
every viewer's own <video>/<audio> independently negotiated its own
PlaybackInfo and got its own PlaySessionId - a party of 5 meant Emby
transcoding the same title 5 separate times.

A bigger, higher-regression-risk change than most of this session's
other work: it replaces the core playback path every existing party
already relies on (CatalogService.proxyStream and its route are gone
entirely) rather than adding something alongside it - there's no other
consumer of the per-viewer path, and the ask was explicit that there
should only ever be one mechanism.

- server/src/services/CatalogService.ts: proxyStream's negotiate-then-
  build-a-target logic extracted into negotiateStreamTarget(session,
  itemId, isAudio, audioStreamIndex, maxStreamingBitrate) - identical
  logic, returns the target instead of proxying it directly so it can
  be cached. proxyStream/proxyRawStreamAsset removed.
- server/src/services/PartyStreamService.ts (new): one negotiated
  target per party.id, reused by every member; renegotiates when the
  desired item/audio/quality changes. Recovers from a dead upstream
  session with no new plumbing - EmbyClient.proxyResponse already sets
  res.statusCode before writing the body, so a failed proxied response
  is detected right after it resolves and the cache entry is dropped,
  so the next request (a manual retry, or the next segment fetch)
  renegotiates fresh instead of repeating the same dead target forever.
- server/src/emby/EmbyClient.ts: rewritePlaylist/proxyResponse's
  trailing itemId param genericized to routeKey - it's only ever read
  back to build the raw-asset URL, so it's party.id for the new stream
  routes and an inert real itemId for proxyImage/proxySubtitle (which
  never reach that branch anyway).
- server/src/index.ts: GET /api/catalog/party-stream/:partyId/
  master.m3u8 and .../raw replace /api/catalog/stream/:itemId/...
  entirely, membership-checked like the burn-in routes; a new idle-reap
  interval for PartyStreamService, same shape as TranscodeService's own.
- Forced consequence of "one stream", not a free design choice: audio
  track/quality become a host-only, party-wide choice for video -
  Party.streamAudioIndex/streamQuality, setStreamOptions/
  streamOptionsChanged, byte-for-byte the same shape
  setBurnInSubtitle/burnInChanged already established for burn-in
  subtitles. server/src/realtime/SocketGateway.ts gains the host-only,
  video-only handler; server/src/services/PartyService.ts resets
  streamAudioIndex (not streamQuality - a bandwidth preference isn't
  item-specific) on advanceQueue, same reasoning as burnIn* already
  gets; MusicQueueService.playNext ends the party's cached session on
  a track change.
- client/src/api/embyApi.ts: streamUrl/audioStreamUrl replaced by one
  partyStreamUrl(partyId) - no query params needed any more, the party
  itself is the source of truth for what it's streaming.
- client/src/api/party.ts: setStreamOptions/onStreamOptionsChanged.
- client/src/views/player/Player.ts: audio/quality <select>s disabled
  for a follower (applyStreamOptionsGating, mirroring
  renderSubtitleOptions' own gating), restore the party's current
  choice on load/broadcast, and a host's change calls setStreamOptions
  instead of reloading locally - every member (host included) only
  ever reacts to the resulting broadcast, same discipline the rest of
  this player already uses. Dead reloadWithCurrentOptions removed.
- client/src/musicBar/MusicBar.ts: loadTrack points at
  partyStreamUrl(activePartyId) - no picker to gate, MusicBar never had
  one.
- server/test/catalogService.test.ts: proxyStream's old cases moved to
  exercise negotiateStreamTarget instead, plus new quality-override and
  no-source-fails cases. server/test/partyStreamService.test.ts (new):
  cache hit/miss/param-change/item-change, the failure-invalidation
  path, endSession, idle reap. server/test/socketGateway.test.ts:
  setStreamOptions host-only gating + broadcast, mirroring the existing
  setBurnInSubtitle tests.
- docs/architecture.md, README.md: design, the forced audio/quality
  trade-off spelled out, and what's still unverified (a different kind
  than this session's other Emby-API guesses - the calls themselves
  were already live code, what's untested is real multi-viewer load).

Verified: full typecheck+test+build on both projects (85/85 server
tests, 12 new). A real-browser check (mocked-fetch + real bundle, this
session's standing harness) confirmed a follower's audio/quality
selects render disabled and a host's render enabled, from the same
party data with only youAreHost flipped.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-14 20:05:34 +02:00

193 lines
8.7 KiB
TypeScript

// Exercises PartyStreamService's own caching logic without ever calling
// real Emby - CatalogService.negotiateStreamTarget and EmbyClient
// .proxyResponse are both mocked, same "mock the one real dependency, run
// everything else for real" approach as transcodeService.test.ts (which
// mocks CatalogService.getMediaSource for the same reason).
import { describe, it } from 'node:test';
import assert from 'node:assert/strict';
import type { Request, Response } from 'express';
import { CatalogService } from '../src/services/CatalogService';
import { EmbyClient } from '../src/emby/EmbyClient';
import { MusicQueueService } from '../src/services/MusicQueueService';
import { PartyService } from '../src/services/PartyService';
import { PartyStreamService } from '../src/services/PartyStreamService';
import type { EmbySession } from '../src/types';
const FAKE_SESSION: EmbySession = {
embyToken: 'tok-alice',
userId: 'u-alice',
userName: 'Alice',
serverId: 'server1',
createdUtc: Date.now()
};
function fakeReq(): Request {
return { query: {} } as unknown as Request;
}
/** statusCode starts at 200, same default Express itself uses before
* anything calls .status() - proxyResponse (mocked in most tests here)
* is what would normally set it; a couple of tests set it directly to
* simulate a failed proxied response, matching how
* PartyStreamService.invalidateOnFailure actually reads it (right after
* EmbyClient.proxyResponse resolves, not via any other channel). */
function fakeRes(): Response {
return { statusCode: 200 } as unknown as Response;
}
describe('PartyStreamService.getOrNegotiate', () => {
it('negotiates once and reuses the cached target for a repeat call with the same desired options', async (t) => {
let negotiateCount = 0;
t.mock.method(CatalogService, 'negotiateStreamTarget', async () => {
negotiateCount += 1;
return '/Videos/movie1/master.m3u8?PlaySessionId=sess1';
});
const party = PartyService.create('u-alice', 'Alice', 'movie1', 'Un Film', '/img/movie1', null, 'video');
const first = await PartyStreamService.getOrNegotiate(party, FAKE_SESSION);
const second = await PartyStreamService.getOrNegotiate(party, FAKE_SESSION);
assert.equal(negotiateCount, 1, 'the second call must reuse the cached target, not negotiate again');
assert.equal(first, second);
PartyStreamService.endSession(party.id);
});
it('renegotiates when the host changes the audio/quality pick', async (t) => {
let negotiateCount = 0;
t.mock.method(CatalogService, 'negotiateStreamTarget', async () => {
negotiateCount += 1;
return `/Videos/movie2/master.m3u8?call=${negotiateCount}`;
});
const party = PartyService.create('u-alice', 'Alice', 'movie2', 'Un Film 2', '/img/movie2', null, 'video');
const first = await PartyStreamService.getOrNegotiate(party, FAKE_SESSION);
party.streamAudioIndex = '2';
const second = await PartyStreamService.getOrNegotiate(party, FAKE_SESSION);
assert.equal(negotiateCount, 2);
assert.notEqual(first, second);
PartyStreamService.endSession(party.id);
});
it('renegotiates when the item changes (e.g. advanceQueue)', async (t) => {
let negotiateCount = 0;
t.mock.method(CatalogService, 'negotiateStreamTarget', async () => {
negotiateCount += 1;
return `/Videos/x/master.m3u8?call=${negotiateCount}`;
});
const party = PartyService.create('u-alice', 'Alice', 'ep1', 'S01E01', '/img/ep1', [
{ itemId: 'ep1', itemName: 'S01E01', itemImageUrl: '/img/ep1' },
{ itemId: 'ep2', itemName: 'S01E02', itemImageUrl: '/img/ep2' }
], 'video');
await PartyStreamService.getOrNegotiate(party, FAKE_SESSION);
PartyService.advanceQueue(party); // also calls PartyStreamService.endSession itself - see PartyService.ts
await PartyStreamService.getOrNegotiate(party, FAKE_SESSION);
assert.equal(negotiateCount, 2, 'advancing to a new item must not reuse the old item\'s cached target');
});
it('music: reads the current queue track, never an audio/quality override (MusicBar has no such picker)', async (t) => {
let capturedArgs: unknown[] = [];
t.mock.method(CatalogService, 'negotiateStreamTarget', async (...args: unknown[]) => {
capturedArgs = args;
return '/Audio/t1/master.m3u8';
});
const party = PartyService.create('u-alice', 'Alice', 't1', 'Track One', '/img/t1', null, 'music');
MusicQueueService.init(party, { itemId: 't1', itemName: 'Track One', itemImageUrl: '/img/t1', durationTicks: 60_000_000 }, 'u-alice', 'Alice');
await PartyStreamService.getOrNegotiate(party, FAKE_SESSION);
assert.deepEqual(capturedArgs, [FAKE_SESSION, 't1', true, '', '']);
});
it('music: fails cleanly (409, not a crash) when the queue is empty - nothing to stream', async () => {
const party = PartyService.create('u-alice', 'Alice', 't1', 'Track One', '/img/t1', null, 'music');
MusicQueueService.init(party, null, 'u-alice', 'Alice');
await assert.rejects(
() => PartyStreamService.getOrNegotiate(party, FAKE_SESSION),
(err: Error & { status?: number }) => err.status === 409
);
});
});
describe('PartyStreamService failure recovery', () => {
it('serveMasterPlaylist drops the cached session when the proxied response fails, so the next call renegotiates', async (t) => {
let negotiateCount = 0;
t.mock.method(CatalogService, 'negotiateStreamTarget', async () => {
negotiateCount += 1;
return `/Videos/movie3/master.m3u8?call=${negotiateCount}`;
});
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
});
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(negotiateCount, 2, 'a failed response must not be cached - the next request has to renegotiate');
PartyStreamService.endSession(party.id);
});
it('a successful proxied response keeps the cache - the next call does not renegotiate', async (t) => {
let negotiateCount = 0;
t.mock.method(CatalogService, 'negotiateStreamTarget', async () => {
negotiateCount += 1;
return '/Videos/movie4/master.m3u8';
});
t.mock.method(EmbyClient, 'proxyResponse', async () => {}); // res.statusCode stays 200, the fakeRes() default
const party = PartyService.create('u-alice', 'Alice', 'movie4', 'Un Film 4', '/img/movie4', null, 'video');
await PartyStreamService.serveMasterPlaylist(fakeReq(), fakeRes(), party, FAKE_SESSION);
await PartyStreamService.serveMasterPlaylist(fakeReq(), fakeRes(), party, FAKE_SESSION);
assert.equal(negotiateCount, 1);
PartyStreamService.endSession(party.id);
});
});
describe('PartyStreamService lifecycle', () => {
it('endSession is a harmless no-op for a party that never negotiated a stream', () => {
assert.doesNotThrow(() => PartyStreamService.endSession('no-such-party'));
});
it('reapIdleSessions ends sessions past the idle cutoff, leaves a fresh one alone', async (t) => {
let negotiateCount = 0;
t.mock.method(CatalogService, 'negotiateStreamTarget', async () => {
negotiateCount += 1;
return `/Videos/x/master.m3u8?call=${negotiateCount}`;
});
const party = PartyService.create('u-eve', 'Eve', 'movie5', 'Un Film 5', '/img/movie5', null, 'video');
await PartyStreamService.getOrNegotiate(party, FAKE_SESSION);
// A generous timeout must not touch a session just negotiated.
PartyStreamService.reapIdleSessions(30 * 60 * 1000);
await PartyStreamService.getOrNegotiate(party, FAKE_SESSION);
assert.equal(negotiateCount, 1, 'a fresh session must not be reaped');
// A *negative* timeout pushes the cutoff strictly into the future,
// so it reaps regardless of exactly when lastAccessUtc was set -
// unlike 0, which computes cutoff = Date.now() at reap time and can
// tie with a lastAccessUtc set within the same millisecond (this
// failed intermittently with 0 - Date.now()'s own resolution isn't
// fine-grained enough to guarantee cutoff is strictly later). The
// simplest way to exercise the sweep itself without a lastAccessUtc
// setter (getOrNegotiate only ever returns the target string, not
// the session object, unlike TranscodeService's own equivalent method).
PartyStreamService.reapIdleSessions(-1);
await PartyStreamService.getOrNegotiate(party, FAKE_SESSION);
assert.equal(negotiateCount, 2, 'a past-the-future cutoff must reap even a just-touched session');
PartyStreamService.endSession(party.id);
});
});