Bug report: watch party members still didn't have synced subtitle settings, and the audio/language selector showed an empty choice on first load. Text subtitles were the gap left by the previous "one shared stream per party" change: that made audio/quality/burn-in host-only and party-wide, but a plain text subtitle pick (the common case - burn-in only applies to bitmap PGS/VobSub/DVB tracks) was still each viewer's own local <track> choice. Once people actually watched together, "everyone but me changed the subtitle" read as broken sync. Fixed with a parallel mechanism, not a merge into burnInSubtitleIndex - new Party.textSubtitleIndex, new setTextSubtitle/textSubtitleChanged socket pair, mirroring setBurnInSubtitle/burnInChanged's host-only, request/broadcast/react shape exactly. Kept separate from burn-in (rather than reusing one field for both) to avoid touching the already-proven burn-in path - only one subtitle can show at once, so the two are mutually exclusive, enforced server-side in both directions: a real pick on either clears the other. Clearing one to null/"Aucun" is deliberately not treated as a pick for this purpose, so it doesn't clobber an unrelated choice in the other field. Mechanically the two still work nothing alike: burn-in changes the actual encoded stream (ffmpeg), so it reloads for everyone; a text pick only swaps which native <track> each member already renders locally - no reload, no transcode, unless it's the one clearing an active burn-in, which does still need the reload since the stream itself changes back to the normal shared one. Player.ts's subtitle <select> gating stopped being conditional on burn-in - it's simply host-gated now, matching audio/quality, and its value restores from whichever kind of pick (if any) the party currently has. Second, unrelated bug in the same report: audioSelect.value = streamAudioIndex || '' assigned an empty string whenever no audio track had been picked yet (the common default) - since a real audio-track <option> never has value="", this matched nothing and left the select at selectedIndex=-1, a genuinely empty-looking choice. Fixed at both call sites (loadTrackOptions, onStreamOptionsChanged) by only assigning .value when truthy. Server: SocketGateway tests cover setTextSubtitle's host-only gating, broadcast, music no-op, and mutual exclusivity in both directions; PartyService tests cover textSubtitleIndex through create/ advanceQueue/toPublicState. Full typecheck/test/build green on both projects (91/91 server tests). Real-browser check (mocked-fetch + real bundle) confirmed: a follower's subtitle select renders disabled regardless of burn-in state; a host's select restores the party's current text or burn-in pick on load; and the audio select now lands on a real first option instead of an empty one. Not addressed here (raised in the same report, need more diagnostic detail first): occasional sync losses between clients, and a suspicion that per-user watch-status (Emby PlaystateService sync) might be broken. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
118 lines
5.6 KiB
TypeScript
118 lines
5.6 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,
|
|
textSubtitleIndex: 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'));
|
|
});
|
|
});
|