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>
193 lines
8.7 KiB
TypeScript
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);
|
|
});
|
|
});
|