watchparty/server/test/partyService.test.ts
Nicolas 4efcd67fb3
All checks were successful
Publier l'image Docker / test (push) Successful in 23s
Publier l'image Docker / publish (push) Successful in 14s
Sync text subtitle choice too, and fix an empty audio option
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>
2026-09-14 20:41:02 +02:00

175 lines
8.1 KiB
TypeScript

import { describe, it } from 'node:test';
import assert from 'node:assert/strict';
import { PartyService } from '../src/services/PartyService';
import { MusicQueueService } from '../src/services/MusicQueueService';
describe('PartyService', () => {
it('create() sets mediaType and seeds the host as the first member', () => {
const party = PartyService.create('u-alice', 'Alice', 'ep1', 'S01E01 - Pilote', '/img/ep1', [
{ itemId: 'ep1', itemName: 'S01E01 - Pilote', itemImageUrl: '/img/ep1' },
{ itemId: 'ep2', itemName: 'S01E02 - Suite', itemImageUrl: '/img/ep2' }
], 'video');
assert.equal(party.mediaType, 'video');
assert.equal(party.members.size, 1);
assert.equal(party.hostUserId, 'u-alice');
assert.equal(party.burnInSubtitleIndex, null, 'no burn-in stream until the host picks one');
assert.equal(party.burnInAudioIndex, null);
assert.equal(party.textSubtitleIndex, null, 'no text subtitle pick until the host picks one');
});
it('advanceQueue moves through the fixed queue and stops at the end', () => {
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');
assert.equal(PartyService.advanceQueue(party), true);
assert.equal(party.itemId, 'ep2');
assert.equal(PartyService.advanceQueue(party), false, 'nothing left after the last item');
});
it('advanceQueue clears the party\'s burn-in/text-subtitle choice - a new item\'s stream indices mean nothing on the old one', () => {
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');
party.burnInSubtitleIndex = '4';
party.burnInAudioIndex = '2';
party.textSubtitleIndex = '3';
PartyService.advanceQueue(party);
assert.equal(party.burnInSubtitleIndex, null);
assert.equal(party.burnInAudioIndex, null);
assert.equal(party.textSubtitleIndex, null);
});
it('leave(): a non-host leaving neither destroys the party nor hands off', () => {
const party = PartyService.create('u-alice', 'Alice', 'm1', 'Un Film', '/img/m1', null, 'video');
PartyService.join(party, 'u-bob', 'Bob');
const result = PartyService.leave(party, 'u-bob');
assert.equal(result.destroyed, false);
assert.equal(result.hostChanged, false);
});
it('leave(): the host leaving with someone left hands off, and party.hostUserId actually updates', () => {
const party = PartyService.create('u-alice', 'Alice', 'm1', 'Un Film', '/img/m1', null, 'video');
PartyService.join(party, 'u-bob', 'Bob');
const result = PartyService.leave(party, 'u-alice');
assert.equal(result.hostChanged, true);
assert.equal(result.newHostUserId, 'u-bob');
assert.equal(party.hostUserId, 'u-bob');
});
it('leave(): the last member leaving destroys the party', () => {
const party = PartyService.create('u-alice', 'Alice', 'm1', 'Un Film', '/img/m1', null, 'video');
const result = PartyService.leave(party, 'u-alice');
assert.equal(result.destroyed, true);
assert.equal(PartyService.getById(party.id), null);
});
it('join() is idempotent: re-joining an existing member returns it as-is instead of resetting it', () => {
const party = PartyService.create('u-alice', 'Alice', 'm1', 'Un Film', '/img/m1', null, 'video');
const bob = PartyService.join(party, 'u-bob', 'Bob');
bob.isPaused = false;
bob.positionTicks = 12345;
bob.deviceId = 'device-1';
// The REST "Rejoindre" route (and a second device/tab's socket "join")
// call this every time regardless of whether the caller is already a
// member - this must not reset any of the live state just set above
// (see this method's own comment for the crash that used to cause).
const bobAgain = PartyService.join(party, 'u-bob', 'Bob');
assert.equal(bobAgain, bob, 'must be the exact same member object, not a fresh one');
assert.equal(bobAgain.isPaused, false, 'a re-join must not reset live state');
assert.equal(bobAgain.positionTicks, 12345);
assert.equal(bobAgain.deviceId, 'device-1');
assert.equal(party.members.size, 2, 'still exactly one entry for bob, not a duplicate');
});
it('removeIdleParties sweeps a party whose lastActivityUtc is past the cutoff', () => {
const party = PartyService.create('u-dave', 'Dave', 'm1', 'Un Film', '/img/m1', null, 'video');
party.lastActivityUtc = Date.now() - 5 * 60 * 60 * 1000;
PartyService.removeIdleParties(4 * 60 * 60 * 1000);
assert.equal(PartyService.getById(party.id), null);
});
it('toPublicState reports youAreHost for the creator, and music:null for a video party', () => {
const party = PartyService.create('u-eve', 'Eve', 'm2', 'Un Autre Film', '/img/m2', null, 'video');
const state = PartyService.toPublicState(party, 'u-eve');
assert.equal(state.youAreHost, true);
assert.equal(state.music, null);
});
it('toPublicState exposes the party\'s current burn-in choice - a late joiner\'s getParty must see it too', () => {
const party = PartyService.create('u-eve', 'Eve', 'm2', 'Un Autre Film', '/img/m2', null, 'video');
party.burnInSubtitleIndex = '4';
party.burnInAudioIndex = '2';
const state = PartyService.toPublicState(party, 'u-eve');
assert.equal(state.burnInSubtitleIndex, '4');
assert.equal(state.burnInAudioIndex, '2');
});
it('toPublicState exposes the party\'s current text subtitle choice - a late joiner\'s getParty must see it too', () => {
const party = PartyService.create('u-eve', 'Eve', 'm2', 'Un Autre Film', '/img/m2', null, 'video');
party.textSubtitleIndex = '3';
const state = PartyService.toPublicState(party, 'u-eve');
assert.equal(state.textSubtitleIndex, '3');
});
it('setMemberEmbyToken records a member\'s own token, and is a harmless no-op for a non-member', () => {
const party = PartyService.create('u-alice', 'Alice', 'm1', 'Un Film', '/img/m1', null, 'video');
PartyService.setMemberEmbyToken(party, 'u-alice', 'tok-alice');
assert.equal(party.members.get('u-alice')?.embyToken, 'tok-alice');
assert.doesNotThrow(() => PartyService.setMemberEmbyToken(party, 'u-nobody', 'tok-x'));
});
it('getCurrentPlaybackState (video) reads the host\'s own tracked position, not a follower\'s', () => {
const party = PartyService.create('u-alice', 'Alice', 'm1', 'Un Film', '/img/m1', null, 'video');
PartyService.join(party, 'u-bob', 'Bob');
const host = party.members.get('u-alice')!;
host.isPaused = false;
host.positionTicks = 5_000_000;
// A follower's own isPaused/positionTicks are never actually tracked
// today (see the method's own comment) - setting them here proves
// the result comes from the host, not whichever member happens to
// be iterated first.
const bob = party.members.get('u-bob')!;
bob.isPaused = true;
bob.positionTicks = 999;
const state = PartyService.getCurrentPlaybackState(party);
assert.deepEqual(state, { itemId: 'm1', positionTicks: 5_000_000, isPaused: false });
});
it('getCurrentPlaybackState (music) reads the shared queue position - the same for every member', () => {
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');
const state = PartyService.getCurrentPlaybackState(party);
assert.equal(state?.itemId, 't1');
assert.equal(state?.isPaused, false);
assert.equal(typeof state?.positionTicks, 'number');
});
it('getCurrentPlaybackState is null when there\'s genuinely nothing playing (an empty music queue)', () => {
const party = PartyService.create('u-alice', 'Alice', 't1', 'Track One', '/img/t1', null, 'music');
MusicQueueService.init(party, null, 'u-alice', 'Alice');
assert.equal(PartyService.getCurrentPlaybackState(party), null);
});
});