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>
117 lines
5.5 KiB
TypeScript
117 lines
5.5 KiB
TypeScript
// Exercises MusicQueueService directly against fake parties - no Express/
|
|
// session/Emby involved. Covers the intricate bits: auto-start on first
|
|
// add, server-authoritative auto-advance via a real timer, pause/resume
|
|
// elapsed-time math, majority vote-skip, and - given how strongly memory
|
|
// leaks have mattered throughout this project (see docs/architecture.md) -
|
|
// that the advance timer is ALWAYS cleared (never left dangling) on skip,
|
|
// on destroy, and is never double-scheduled.
|
|
import { describe, it, mock } from 'node:test';
|
|
import assert from 'node:assert/strict';
|
|
import { setTimeout as sleep } from 'node:timers/promises';
|
|
import { MusicQueueService } from '../src/services/MusicQueueService';
|
|
import type { MusicState, Party } from '../src/types';
|
|
|
|
function fakeParty(id: string): Party {
|
|
return {
|
|
id,
|
|
mediaType: 'music',
|
|
itemId: '',
|
|
itemName: '',
|
|
itemImageUrl: '',
|
|
queue: [],
|
|
queueIndex: 0,
|
|
hostUserId: 'u-alice',
|
|
createdUtc: Date.now(),
|
|
lastActivityUtc: Date.now(),
|
|
members: new Map(),
|
|
burnInSubtitleIndex: null,
|
|
burnInAudioIndex: null,
|
|
streamAudioIndex: null,
|
|
streamQuality: null
|
|
};
|
|
}
|
|
|
|
function music(party: Party): MusicState {
|
|
return party.music as MusicState;
|
|
}
|
|
|
|
describe('MusicQueueService', () => {
|
|
it('init() auto-starts the first track and mirrors it onto party.itemName', () => {
|
|
const party = fakeParty('p1');
|
|
MusicQueueService.init(party, { itemId: 't1', itemName: 'Track One', itemImageUrl: '/img/t1', durationTicks: 10_000_000 }, 'u-alice', 'Alice');
|
|
|
|
assert.equal(music(party).current?.track.itemId, 't1');
|
|
assert.equal(music(party).queue.length, 0);
|
|
assert.equal(party.itemName, 'Track One');
|
|
});
|
|
|
|
it('adding a track while one plays queues it instead of jumping the gun', () => {
|
|
const party = fakeParty('p2');
|
|
MusicQueueService.init(party, { itemId: 't1', itemName: 'Track One', itemImageUrl: '/img/t1', durationTicks: 10_000_000 }, 'u-alice', 'Alice');
|
|
MusicQueueService.addTrack(party, { itemId: 't2', itemName: 'Track Two', itemImageUrl: '/img/t2', durationTicks: 30_000_000 }, 'u-bob', 'Bob');
|
|
|
|
assert.equal(music(party).current?.track.itemId, 't1');
|
|
assert.equal(music(party).queue.length, 1);
|
|
});
|
|
|
|
it('voteSkip needs a strict majority of connected members, not any count', () => {
|
|
const party = fakeParty('p3');
|
|
MusicQueueService.init(party, { itemId: 't1', itemName: 'Track One', itemImageUrl: '/img/t1', durationTicks: 10_000_000 }, 'u-alice', 'Alice');
|
|
MusicQueueService.addTrack(party, { itemId: 't2', itemName: 'Track Two', itemImageUrl: '/img/t2', durationTicks: 30_000_000 }, 'u-bob', 'Bob');
|
|
|
|
assert.equal(MusicQueueService.voteSkip(party, 'u-alice', 'Alice', 2), false, 'one of two needed should not skip yet');
|
|
assert.equal(music(party).current?.track.itemId, 't1');
|
|
|
|
assert.equal(MusicQueueService.voteSkip(party, 'u-bob', 'Bob', 2), true, 'the second vote should reach the majority');
|
|
assert.equal(music(party).current?.track.itemId, 't2');
|
|
assert.equal(music(party).skipVotes.size, 0, 'skipping clears the tally for the new track');
|
|
});
|
|
|
|
it('pause freezes elapsed time; resume continues it from where it froze', async () => {
|
|
const party = fakeParty('p4');
|
|
MusicQueueService.init(party, { itemId: 't1', itemName: 'Track One', itemImageUrl: '/img/t1', durationTicks: 600_000_000 }, 'u-alice', 'Alice');
|
|
|
|
MusicQueueService.setPaused(party, true);
|
|
assert.equal(music(party).current?.isPaused, true);
|
|
const frozen = MusicQueueService.getElapsedSeconds(party);
|
|
await sleep(50);
|
|
assert.equal(MusicQueueService.getElapsedSeconds(party), frozen, 'time must not advance while paused');
|
|
|
|
MusicQueueService.setPaused(party, false);
|
|
assert.equal(music(party).current?.isPaused, false);
|
|
await sleep(50);
|
|
assert.ok(MusicQueueService.getElapsedSeconds(party) >= frozen, 'time should advance again after resuming');
|
|
|
|
MusicQueueService.destroy(party); // avoid leaking this test's own long-duration timer
|
|
});
|
|
|
|
it('a short track auto-advances on its own via a real timer, with no client reporting "ended"', async () => {
|
|
const party = fakeParty('p5');
|
|
MusicQueueService.init(party, { itemId: 't1', itemName: 'Track One', itemImageUrl: '/img/t1', durationTicks: 10_000_000 /* 1s */ }, 'u-alice', 'Alice');
|
|
|
|
assert.notEqual(music(party).advanceTimer, null, 'a timer should be scheduled for the auto-advance');
|
|
await sleep(1300); // a bit over the 1s duration
|
|
assert.equal(music(party).current, null, 'nothing queued behind it, so current should now be empty');
|
|
});
|
|
|
|
it('destroy() clears a pending timer - the actual leak-prevention path', (t) => {
|
|
const realClearTimeout = global.clearTimeout;
|
|
const cleared = mock.fn(realClearTimeout);
|
|
global.clearTimeout = cleared as typeof clearTimeout;
|
|
t.after(() => { global.clearTimeout = realClearTimeout; });
|
|
|
|
const party = fakeParty('p6');
|
|
MusicQueueService.init(party, { itemId: 't1', itemName: 'Track One', itemImageUrl: '/img/t1', durationTicks: 600_000_000 /* 60s - stays pending */ }, 'u-alice', 'Alice');
|
|
assert.notEqual(music(party).advanceTimer, null);
|
|
|
|
MusicQueueService.destroy(party);
|
|
assert.equal(cleared.mock.callCount(), 1, 'clearTimeout should have been called exactly once');
|
|
assert.equal(music(party).advanceTimer, null);
|
|
});
|
|
|
|
it('removeVote() is a harmless no-op for a party with no music state at all', () => {
|
|
const videoParty: Party = { ...fakeParty('p7'), mediaType: 'video' };
|
|
delete videoParty.music;
|
|
assert.doesNotThrow(() => MusicQueueService.removeVote(videoParty, 'u-anyone'));
|
|
});
|
|
});
|