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>
564 lines
28 KiB
TypeScript
564 lines
28 KiB
TypeScript
// End-to-end checks of the realtime protocol through the REAL
|
|
// SocketGateway + PartyService + MusicQueueService together (not just one
|
|
// service in isolation) - a fake io/socket pair standing in for Socket.IO
|
|
// itself, since a real client<->server round trip needs an actual HTTP
|
|
// upgrade.
|
|
import { describe, it } from 'node:test';
|
|
import assert from 'node:assert/strict';
|
|
import { PartyService } from '../src/services/PartyService';
|
|
import { MusicQueueService } from '../src/services/MusicQueueService';
|
|
import { SocketGateway } from '../src/realtime/SocketGateway';
|
|
import type { AppServer, AppSocket, LobbyPartyDto } from '../src/types';
|
|
|
|
type Handler = (payload?: unknown) => void;
|
|
|
|
function fakeSocket(cookie: string) {
|
|
const handlers = new Map<string, Handler[]>();
|
|
const emitted: [string, unknown][] = [];
|
|
return {
|
|
request: { headers: { cookie } },
|
|
emitted,
|
|
on(event: string, cb: Handler) {
|
|
const list = handlers.get(event) || [];
|
|
list.push(cb);
|
|
handlers.set(event, list);
|
|
},
|
|
emit(event: string, payload?: unknown) { emitted.push([event, payload]); },
|
|
to() { return { emit() {} }; },
|
|
join() {},
|
|
disconnect() {},
|
|
trigger(event: string, payload?: unknown) { (handlers.get(event) || []).forEach((cb) => cb(payload)); }
|
|
};
|
|
}
|
|
|
|
function fakeIo() {
|
|
const handlers = new Map<string, ((...args: unknown[]) => void)[]>();
|
|
const roomEmits: { room: string; event: string; payload: unknown }[] = [];
|
|
return {
|
|
roomEmits,
|
|
on(event: string, cb: (...args: unknown[]) => void) {
|
|
const list = handlers.get(event) || [];
|
|
list.push(cb);
|
|
handlers.set(event, list);
|
|
},
|
|
to(room: string) { return { emit: (event: string, payload: unknown) => roomEmits.push({ room, event, payload }) }; },
|
|
trigger(event: string, ...args: unknown[]) { (handlers.get(event) || []).forEach((cb) => cb(...args)); }
|
|
};
|
|
}
|
|
|
|
function attach(io: ReturnType<typeof fakeIo>) {
|
|
SocketGateway.attach(
|
|
io as unknown as AppServer,
|
|
(raw) => ({ wp_session: raw as string }),
|
|
(sessionId) => ({ userId: sessionId, userName: sessionId, embyToken: `tok-${sessionId}` })
|
|
);
|
|
MusicQueueService.attachIo(io as unknown as AppServer, () => {});
|
|
}
|
|
|
|
describe('SocketGateway', () => {
|
|
it('voteSkip/musicPlaystate are no-ops on a video party', () => {
|
|
const io = fakeIo();
|
|
attach(io);
|
|
const party = PartyService.create('u-host', 'Host', 'm1', 'Un Film', '/img/m1', null, 'video');
|
|
const socket = fakeSocket('u-host');
|
|
io.trigger('connection', socket as unknown as AppSocket);
|
|
socket.trigger('join', { partyId: party.id });
|
|
|
|
const before = io.roomEmits.length;
|
|
socket.trigger('voteSkip');
|
|
socket.trigger('musicPlaystate', { isPaused: true });
|
|
assert.equal(io.roomEmits.length, before, 'neither event should produce any broadcast on a video party');
|
|
});
|
|
|
|
it('a non-host member can pause and resume a music party directly (no host check)', () => {
|
|
const io = fakeIo();
|
|
attach(io);
|
|
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: 10_000_000 }, 'u-alice', 'Alice');
|
|
PartyService.join(party, 'u-bob', 'Bob');
|
|
|
|
const bobSocket = fakeSocket('u-bob');
|
|
io.trigger('connection', bobSocket as unknown as AppSocket);
|
|
bobSocket.trigger('join', { partyId: party.id });
|
|
|
|
assert.notEqual(party.hostUserId, 'u-bob');
|
|
bobSocket.trigger('musicPlaystate', { isPaused: true });
|
|
assert.equal(party.music?.current?.isPaused, true);
|
|
bobSocket.trigger('musicPlaystate', { isPaused: false });
|
|
assert.equal(party.music?.current?.isPaused, false);
|
|
});
|
|
|
|
it('voteSkip needs a majority of connected members and broadcasts queueUpdated on the party room', () => {
|
|
const io = fakeIo();
|
|
attach(io);
|
|
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: 10_000_000 }, 'u-alice', 'Alice');
|
|
PartyService.join(party, 'u-bob', 'Bob');
|
|
|
|
const aliceSocket = fakeSocket('u-alice');
|
|
io.trigger('connection', aliceSocket as unknown as AppSocket);
|
|
aliceSocket.trigger('join', { partyId: party.id });
|
|
const bobSocket = fakeSocket('u-bob');
|
|
io.trigger('connection', bobSocket as unknown as AppSocket);
|
|
bobSocket.trigger('join', { partyId: party.id });
|
|
|
|
bobSocket.trigger('voteSkip');
|
|
assert.equal(party.music?.current?.track.itemId, 't1', 'one of two connected should not skip yet');
|
|
|
|
io.roomEmits.length = 0;
|
|
aliceSocket.trigger('voteSkip');
|
|
assert.equal(party.music?.current, null, 'the second vote reaches the majority (no second track queued)');
|
|
assert.ok(io.roomEmits.some((e) => e.room === party.id && e.event === 'queueUpdated'));
|
|
});
|
|
|
|
it('leaving hands the host off live and refreshes the lobby', () => {
|
|
const io = fakeIo();
|
|
attach(io);
|
|
const party = PartyService.create('u-alice', 'Alice', 'm1', 'Un Film', '/img/m1', null, 'video');
|
|
PartyService.join(party, 'u-bob', 'Bob');
|
|
const bobSocket = fakeSocket('u-bob');
|
|
io.trigger('connection', bobSocket as unknown as AppSocket);
|
|
bobSocket.trigger('join', { partyId: party.id });
|
|
|
|
const aliceSocket = fakeSocket('u-alice');
|
|
io.trigger('connection', aliceSocket as unknown as AppSocket);
|
|
aliceSocket.trigger('join', { partyId: party.id });
|
|
aliceSocket.trigger('disconnect');
|
|
|
|
assert.equal(party.hostUserId, 'u-bob');
|
|
assert.ok(bobSocket.emitted.some(([event]) => event === 'hostChanged'), 'the new host should get a personal notification');
|
|
});
|
|
|
|
it('playstate logs only on an actual pause/play transition, never on an unchanged heartbeat resend', (t) => {
|
|
const logged: string[] = [];
|
|
t.mock.method(console, 'log', (line: string) => { logged.push(line); });
|
|
|
|
const io = fakeIo();
|
|
attach(io);
|
|
const party = PartyService.create('u-alice', 'Alice', 'm1', 'Un Film', '/img/m1', null, 'video');
|
|
const socket = fakeSocket('u-alice');
|
|
io.trigger('connection', socket as unknown as AppSocket);
|
|
socket.trigger('join', { partyId: party.id });
|
|
|
|
const playstateLines = () => logged.filter((l) => l.includes(' playstate '));
|
|
|
|
// The initial state is a real transition (a new member defaults to
|
|
// isPaused:true - see PartyService's newMember) - this is "the party
|
|
// started playing", and should log.
|
|
socket.trigger('playstate', { isPaused: false, positionTicks: 10_000_000 });
|
|
assert.equal(playstateLines().length, 1);
|
|
|
|
// 5 heartbeats, same isPaused, only position moving - none of these
|
|
// should add a line (the player re-sends its current state every 3s
|
|
// regardless of whether anything changed - see docs/architecture.md).
|
|
for (let i = 0; i < 5; i++) socket.trigger('playstate', { isPaused: false, positionTicks: 10_000_000 + i * 30_000_000 });
|
|
assert.equal(playstateLines().length, 1);
|
|
|
|
socket.trigger('playstate', { isPaused: true, positionTicks: 160_000_000 });
|
|
assert.equal(playstateLines().length, 2);
|
|
|
|
for (let i = 0; i < 3; i++) socket.trigger('playstate', { isPaused: true, positionTicks: 160_000_000 });
|
|
assert.equal(playstateLines().length, 2);
|
|
|
|
socket.trigger('playstate', { isPaused: false, positionTicks: 160_000_000 });
|
|
assert.equal(playstateLines().length, 3);
|
|
});
|
|
|
|
it('requestPause is throttled per member (5s) even against a raw repeat', (t) => {
|
|
const logged: string[] = [];
|
|
t.mock.method(console, 'log', (line: string) => { logged.push(line); });
|
|
|
|
const io = fakeIo();
|
|
attach(io);
|
|
const party = PartyService.create('u-alice', 'Alice', 'm1', 'Un Film', '/img/m1', null, 'video');
|
|
PartyService.join(party, 'u-bob', 'Bob');
|
|
const bobSocket = fakeSocket('u-bob');
|
|
io.trigger('connection', bobSocket as unknown as AppSocket);
|
|
bobSocket.trigger('join', { partyId: party.id });
|
|
|
|
bobSocket.trigger('requestPause');
|
|
bobSocket.trigger('requestPause');
|
|
const pauseRequestLines = logged.filter((l) => l.includes(' pauseRequested '));
|
|
assert.equal(pauseRequestLines.length, 1, 'an immediate repeat must not produce a second line');
|
|
});
|
|
|
|
it('requestResume is throttled independently from requestPause (its own 5s window, own counter)', (t) => {
|
|
const logged: string[] = [];
|
|
t.mock.method(console, 'log', (line: string) => { logged.push(line); });
|
|
|
|
const io = fakeIo();
|
|
attach(io);
|
|
const party = PartyService.create('u-alice', 'Alice', 'm1', 'Un Film', '/img/m1', null, 'video');
|
|
PartyService.join(party, 'u-bob', 'Bob');
|
|
const bobSocket = fakeSocket('u-bob');
|
|
io.trigger('connection', bobSocket as unknown as AppSocket);
|
|
bobSocket.trigger('join', { partyId: party.id });
|
|
|
|
bobSocket.trigger('requestPause');
|
|
bobSocket.trigger('requestResume');
|
|
bobSocket.trigger('requestResume');
|
|
assert.equal(logged.filter((l) => l.includes(' pauseRequested ')).length, 1);
|
|
assert.equal(logged.filter((l) => l.includes(' resumeRequested ')).length, 1, 'an immediate repeat must not produce a second line, and requestPause just above must not have consumed this budget');
|
|
});
|
|
|
|
it('the lobby snapshot exposes the host\'s live position for a video party, and null for a music party', () => {
|
|
const io = fakeIo();
|
|
attach(io);
|
|
const videoParty = PartyService.create('u-alice', 'Alice', 'm1', 'Un Film', '/img/m1', null, 'video');
|
|
const musicParty = PartyService.create('u-bob', 'Bob', 't1', 'Track One', '/img/t1', null, 'music');
|
|
MusicQueueService.init(musicParty, { itemId: 't1', itemName: 'Track One', itemImageUrl: '/img/t1', durationTicks: 10_000_000 }, 'u-bob', 'Bob');
|
|
|
|
const aliceSocket = fakeSocket('u-alice');
|
|
io.trigger('connection', aliceSocket as unknown as AppSocket);
|
|
aliceSocket.trigger('join', { partyId: videoParty.id });
|
|
aliceSocket.trigger('playstate', { isPaused: false, positionTicks: 50_000_000 }); // 5s in, playing
|
|
|
|
const subscriber = fakeSocket('u-eve');
|
|
io.trigger('connection', subscriber as unknown as AppSocket);
|
|
subscriber.trigger('subscribeLobby');
|
|
|
|
const parties = subscriber.emitted.find(([event]) => event === 'parties')![1] as LobbyPartyDto[];
|
|
const video = parties.find((p) => p.partyId === videoParty.id)!;
|
|
const music = parties.find((p) => p.partyId === musicParty.id)!;
|
|
|
|
assert.equal(video.positionTicks, 50_000_000);
|
|
assert.equal(video.isPaused, false);
|
|
assert.ok(video.positionUpdatedUtc, 'should carry a timestamp so the client can tick from it');
|
|
|
|
assert.equal(music.positionTicks, null, 'no single "elapsed" makes sense across a rotating music queue');
|
|
assert.equal(music.isPaused, null);
|
|
assert.equal(music.positionUpdatedUtc, null);
|
|
});
|
|
|
|
it('a play/pause transition refreshes the lobby (an unchanged heartbeat resend does not); advancing the queue does too', () => {
|
|
const io = fakeIo();
|
|
attach(io);
|
|
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');
|
|
const socket = fakeSocket('u-alice');
|
|
io.trigger('connection', socket as unknown as AppSocket);
|
|
socket.trigger('join', { partyId: party.id });
|
|
|
|
const lobbyRefreshCount = () => io.roomEmits.filter((e) => e.room === 'lobby' && e.event === 'parties').length;
|
|
|
|
// The default new-member state is isPaused:true (see PartyService's
|
|
// newMember) - becoming "playing" is a real transition, so this
|
|
// should refresh the lobby once.
|
|
socket.trigger('playstate', { isPaused: false, positionTicks: 10_000_000 });
|
|
assert.equal(lobbyRefreshCount(), 1);
|
|
|
|
// Same isPaused, only position moving - the player's 3s heartbeat
|
|
// resend (see Player.ts) - must NOT refresh the lobby again, same
|
|
// discipline as the "playstate logs only on a transition" test above.
|
|
socket.trigger('playstate', { isPaused: false, positionTicks: 40_000_000 });
|
|
assert.equal(lobbyRefreshCount(), 1);
|
|
|
|
socket.trigger('next');
|
|
assert.equal(lobbyRefreshCount(), 2, 'advancing the queue resets position/pause for the new item - the home page needs that immediately, not just whenever some other event next refreshes it');
|
|
});
|
|
|
|
it('a stale connection\'s next heartbeat after a device takeover destroys the party does not crash the process (regression - see docs/architecture.md)', (t) => {
|
|
const crashed: unknown[] = [];
|
|
t.mock.method(console, 'error', (..._args: unknown[]) => { crashed.push(_args); });
|
|
|
|
const io = fakeIo();
|
|
attach(io);
|
|
const party = PartyService.create('u-alice', 'Alice', 'm1', 'Un Film', '/img/m1', null, 'video');
|
|
|
|
const socketA = fakeSocket('u-alice');
|
|
io.trigger('connection', socketA as unknown as AppSocket);
|
|
socketA.trigger('join', { partyId: party.id, deviceId: 'device-A' });
|
|
|
|
// A confirmed device takeover (see the "join" handler below) hands
|
|
// socketA's map slot to socketB - the exact "two connections for the
|
|
// same userId share one slot" shape the original crash needed, now
|
|
// reached through the real takeover flow instead of a raw
|
|
// PartyService.join() call (that method is idempotent now - see its
|
|
// own test in partyService.test.ts - so a second call to it would no
|
|
// longer reproduce this).
|
|
const socketB = fakeSocket('u-alice');
|
|
io.trigger('connection', socketB as unknown as AppSocket);
|
|
socketB.trigger('join', { partyId: party.id, deviceId: 'device-B', force: true });
|
|
|
|
// socketB (currently occupying the slot) leaves - the only member, so
|
|
// the party is destroyed; hostUserId is never reassigned since
|
|
// there's no successor to hand off to (see PartyService.leave).
|
|
socketB.trigger('leave');
|
|
assert.equal(PartyService.getById(party.id), null, 'sanity check: the party really is gone');
|
|
|
|
// socketA's regular 3s heartbeat fires next, completely unaware any
|
|
// of this happened: its own closure still references the same
|
|
// (now-destroyed) Party object, and its stale userId still equals
|
|
// the stale hostUserId, so the host check alone doesn't catch this -
|
|
// party.members.get(userId) is what has to.
|
|
assert.doesNotThrow(() => socketA.trigger('playstate', { isPaused: true, positionTicks: 0 }));
|
|
assert.equal(crashed.length, 0, 'should be a clean early return (the specific guard), not the generic safe() net silently swallowing an actual throw');
|
|
});
|
|
|
|
it('a second device for the same account gets a deviceConflict instead of silently taking over', () => {
|
|
const io = fakeIo();
|
|
attach(io);
|
|
const party = PartyService.create('u-alice', 'Alice', 'm1', 'Un Film', '/img/m1', null, 'video');
|
|
|
|
const socketA = fakeSocket('u-alice');
|
|
io.trigger('connection', socketA as unknown as AppSocket);
|
|
socketA.trigger('join', { partyId: party.id, deviceId: 'device-A' });
|
|
|
|
const socketB = fakeSocket('u-alice');
|
|
io.trigger('connection', socketB as unknown as AppSocket);
|
|
const roomEmitsBefore = io.roomEmits.length;
|
|
socketB.trigger('join', { partyId: party.id, deviceId: 'device-B' });
|
|
|
|
assert.ok(socketB.emitted.some(([event]) => event === 'deviceConflict'), 'the new device should be told about the conflict');
|
|
assert.equal(party.members.get('u-alice')?.connection, socketA, 'the existing connection must stay in place until the conflict is confirmed');
|
|
assert.equal(io.roomEmits.length, roomEmitsBefore, 'a conflicting join must not broadcast "members" - the socket never actually joined');
|
|
assert.ok(!socketA.emitted.some(([event]) => event === 'errorMessage'), 'the existing device must not be kicked yet');
|
|
});
|
|
|
|
it('re-joining with force:true confirms the takeover: kicks the old device, attaches the new one', () => {
|
|
const io = fakeIo();
|
|
attach(io);
|
|
const party = PartyService.create('u-alice', 'Alice', 'm1', 'Un Film', '/img/m1', null, 'video');
|
|
|
|
const socketA = fakeSocket('u-alice');
|
|
io.trigger('connection', socketA as unknown as AppSocket);
|
|
socketA.trigger('join', { partyId: party.id, deviceId: 'device-A' });
|
|
|
|
const socketB = fakeSocket('u-alice');
|
|
io.trigger('connection', socketB as unknown as AppSocket);
|
|
socketB.trigger('join', { partyId: party.id, deviceId: 'device-B' }); // conflict - the client would show a confirm() here
|
|
socketB.trigger('join', { partyId: party.id, deviceId: 'device-B', force: true }); // user confirmed
|
|
|
|
assert.ok(socketA.emitted.some(([event]) => event === 'errorMessage'), 'the old device should be told it got disconnected');
|
|
assert.equal(party.members.get('u-alice')?.connection, socketB, 'the new device now holds the slot');
|
|
assert.equal(party.members.get('u-alice')?.deviceId, 'device-B');
|
|
|
|
// The old connection's own eventual "disconnect" - Socket.IO fires this
|
|
// automatically after a server-side .disconnect(true), simulated here
|
|
// manually like this file's other tests - must NOT be mistaken for a
|
|
// real leave: it no longer holds the member's connection slot (see the
|
|
// "join" handler's own comment on why, and the guard on "leave"/
|
|
// "disconnect" below).
|
|
socketA.trigger('disconnect');
|
|
assert.equal(PartyService.getById(party.id), party, 'the party must survive the superseded connection\'s disconnect');
|
|
assert.ok(party.members.has('u-alice'), 'the member must still be present, still attached to socketB');
|
|
});
|
|
|
|
it('the same device reconnecting (matching deviceId) takes over silently - no deviceConflict', () => {
|
|
const io = fakeIo();
|
|
attach(io);
|
|
const party = PartyService.create('u-alice', 'Alice', 'm1', 'Un Film', '/img/m1', null, 'video');
|
|
|
|
const socketA = fakeSocket('u-alice');
|
|
io.trigger('connection', socketA as unknown as AppSocket);
|
|
socketA.trigger('join', { partyId: party.id, deviceId: 'device-A' });
|
|
|
|
// e.g. a page reload on the same browser: a fresh socket, but the same
|
|
// deviceId persisted in localStorage (see client/src/deviceId.ts).
|
|
const socketA2 = fakeSocket('u-alice');
|
|
io.trigger('connection', socketA2 as unknown as AppSocket);
|
|
socketA2.trigger('join', { partyId: party.id, deviceId: 'device-A' });
|
|
|
|
assert.ok(!socketA2.emitted.some(([event]) => event === 'deviceConflict'), 'the same device should never conflict with itself');
|
|
assert.equal(party.members.get('u-alice')?.connection, socketA2);
|
|
assert.equal(party.members.get('u-alice')?.deviceId, 'device-A');
|
|
});
|
|
|
|
it('setBurnInSubtitle is host-only and a no-op for anyone else (same pattern as "next")', () => {
|
|
const io = fakeIo();
|
|
attach(io);
|
|
const party = PartyService.create('u-alice', 'Alice', 'm1', 'Un Film', '/img/m1', null, 'video');
|
|
PartyService.join(party, 'u-bob', 'Bob');
|
|
|
|
const bobSocket = fakeSocket('u-bob');
|
|
io.trigger('connection', bobSocket as unknown as AppSocket);
|
|
bobSocket.trigger('join', { partyId: party.id, deviceId: 'device-bob' });
|
|
|
|
bobSocket.trigger('setBurnInSubtitle', { subtitleIndex: '4', audioIndex: '2' });
|
|
|
|
assert.equal(party.burnInSubtitleIndex, null, 'a non-host request must not change party state');
|
|
assert.equal(party.burnInAudioIndex, null);
|
|
assert.ok(!io.roomEmits.some((e) => e.event === 'burnInChanged'), 'and must not broadcast anything');
|
|
});
|
|
|
|
it('setBurnInSubtitle from the host updates the party and broadcasts burnInChanged to the room', () => {
|
|
const io = fakeIo();
|
|
attach(io);
|
|
const party = PartyService.create('u-alice', 'Alice', 'm1', 'Un Film', '/img/m1', null, 'video');
|
|
const aliceSocket = fakeSocket('u-alice');
|
|
io.trigger('connection', aliceSocket as unknown as AppSocket);
|
|
aliceSocket.trigger('join', { partyId: party.id, deviceId: 'device-alice' });
|
|
|
|
aliceSocket.trigger('setBurnInSubtitle', { subtitleIndex: '4', audioIndex: '2' });
|
|
|
|
assert.equal(party.burnInSubtitleIndex, '4');
|
|
assert.equal(party.burnInAudioIndex, '2');
|
|
const broadcast = io.roomEmits.find((e) => e.room === party.id && e.event === 'burnInChanged');
|
|
assert.deepEqual(broadcast?.payload, { subtitleIndex: '4', audioIndex: '2' });
|
|
|
|
// Clearing it (a text subtitle/"Aucun" pick) resets both fields
|
|
// together, never leaving a stray audioIndex behind with no subtitle.
|
|
aliceSocket.trigger('setBurnInSubtitle', { subtitleIndex: null });
|
|
assert.equal(party.burnInSubtitleIndex, null);
|
|
assert.equal(party.burnInAudioIndex, null);
|
|
});
|
|
|
|
it('setBurnInSubtitle is a no-op on a music party (no host to drive it, no video stream either)', () => {
|
|
const io = fakeIo();
|
|
attach(io);
|
|
const party = PartyService.create('u-alice', 'Alice', 't1', 'Track One', '/img/t1', null, 'music');
|
|
const aliceSocket = fakeSocket('u-alice');
|
|
io.trigger('connection', aliceSocket as unknown as AppSocket);
|
|
aliceSocket.trigger('join', { partyId: party.id, deviceId: 'device-alice' });
|
|
|
|
aliceSocket.trigger('setBurnInSubtitle', { subtitleIndex: '4' });
|
|
|
|
assert.equal(party.burnInSubtitleIndex, null);
|
|
assert.ok(!io.roomEmits.some((e) => e.event === 'burnInChanged'));
|
|
});
|
|
|
|
it('setTextSubtitle is host-only and a no-op for anyone else (same pattern as "next")', () => {
|
|
const io = fakeIo();
|
|
attach(io);
|
|
const party = PartyService.create('u-alice', 'Alice', 'm1', 'Un Film', '/img/m1', null, 'video');
|
|
PartyService.join(party, 'u-bob', 'Bob');
|
|
|
|
const bobSocket = fakeSocket('u-bob');
|
|
io.trigger('connection', bobSocket as unknown as AppSocket);
|
|
bobSocket.trigger('join', { partyId: party.id, deviceId: 'device-bob' });
|
|
|
|
bobSocket.trigger('setTextSubtitle', { subtitleIndex: '3' });
|
|
|
|
assert.equal(party.textSubtitleIndex, null, 'a non-host request must not change party state');
|
|
assert.ok(!io.roomEmits.some((e) => e.event === 'textSubtitleChanged'), 'and must not broadcast anything');
|
|
});
|
|
|
|
it('setTextSubtitle from the host updates the party and broadcasts textSubtitleChanged to the room', () => {
|
|
const io = fakeIo();
|
|
attach(io);
|
|
const party = PartyService.create('u-alice', 'Alice', 'm1', 'Un Film', '/img/m1', null, 'video');
|
|
const aliceSocket = fakeSocket('u-alice');
|
|
io.trigger('connection', aliceSocket as unknown as AppSocket);
|
|
aliceSocket.trigger('join', { partyId: party.id, deviceId: 'device-alice' });
|
|
|
|
aliceSocket.trigger('setTextSubtitle', { subtitleIndex: '3' });
|
|
|
|
assert.equal(party.textSubtitleIndex, '3');
|
|
const broadcast = io.roomEmits.find((e) => e.room === party.id && e.event === 'textSubtitleChanged');
|
|
assert.deepEqual(broadcast?.payload, { subtitleIndex: '3' });
|
|
|
|
// Clearing it ("Aucun") resets it back to null.
|
|
aliceSocket.trigger('setTextSubtitle', { subtitleIndex: null });
|
|
assert.equal(party.textSubtitleIndex, null);
|
|
});
|
|
|
|
it('setTextSubtitle is a no-op on a music party (no host to drive it, no video stream either)', () => {
|
|
const io = fakeIo();
|
|
attach(io);
|
|
const party = PartyService.create('u-alice', 'Alice', 't1', 'Track One', '/img/t1', null, 'music');
|
|
const aliceSocket = fakeSocket('u-alice');
|
|
io.trigger('connection', aliceSocket as unknown as AppSocket);
|
|
aliceSocket.trigger('join', { partyId: party.id, deviceId: 'device-alice' });
|
|
|
|
aliceSocket.trigger('setTextSubtitle', { subtitleIndex: '3' });
|
|
|
|
assert.equal(party.textSubtitleIndex, null);
|
|
assert.ok(!io.roomEmits.some((e) => e.event === 'textSubtitleChanged'));
|
|
});
|
|
|
|
it('setBurnInSubtitle and setTextSubtitle are mutually exclusive - a real pick on one clears the other', () => {
|
|
const io = fakeIo();
|
|
attach(io);
|
|
const party = PartyService.create('u-alice', 'Alice', 'm1', 'Un Film', '/img/m1', null, 'video');
|
|
const aliceSocket = fakeSocket('u-alice');
|
|
io.trigger('connection', aliceSocket as unknown as AppSocket);
|
|
aliceSocket.trigger('join', { partyId: party.id, deviceId: 'device-alice' });
|
|
|
|
// A real text pick clears an active burn-in - both fields, so no
|
|
// stray audioIndex is left behind with no subtitle (same guarantee
|
|
// setBurnInSubtitle(null) itself already gives).
|
|
aliceSocket.trigger('setBurnInSubtitle', { subtitleIndex: '4', audioIndex: '2' });
|
|
aliceSocket.trigger('setTextSubtitle', { subtitleIndex: '3' });
|
|
assert.equal(party.textSubtitleIndex, '3');
|
|
assert.equal(party.burnInSubtitleIndex, null, 'picking a text subtitle must clear an active burn-in');
|
|
assert.equal(party.burnInAudioIndex, null);
|
|
|
|
// And the other direction: a real burn-in pick clears an active text subtitle.
|
|
aliceSocket.trigger('setTextSubtitle', { subtitleIndex: '3' });
|
|
aliceSocket.trigger('setBurnInSubtitle', { subtitleIndex: '4', audioIndex: '2' });
|
|
assert.equal(party.burnInSubtitleIndex, '4');
|
|
assert.equal(party.textSubtitleIndex, null, 'picking a burn-in must clear an active text subtitle');
|
|
});
|
|
|
|
it('clearing setTextSubtitle to null ("Aucun") does not disturb an unrelated active burn-in', () => {
|
|
const io = fakeIo();
|
|
attach(io);
|
|
const party = PartyService.create('u-alice', 'Alice', 'm1', 'Un Film', '/img/m1', null, 'video');
|
|
const aliceSocket = fakeSocket('u-alice');
|
|
io.trigger('connection', aliceSocket as unknown as AppSocket);
|
|
aliceSocket.trigger('join', { partyId: party.id, deviceId: 'device-alice' });
|
|
|
|
aliceSocket.trigger('setBurnInSubtitle', { subtitleIndex: '4', audioIndex: '2' });
|
|
// null is not a *real* pick fighting for the same slot - only a
|
|
// truthy subtitleIndex is, so this must not clobber the burn-in the
|
|
// way a real text pick does above.
|
|
aliceSocket.trigger('setTextSubtitle', { subtitleIndex: null });
|
|
assert.equal(party.burnInSubtitleIndex, '4', 'clearing the (already-empty) text subtitle must not clear burn-in');
|
|
assert.equal(party.textSubtitleIndex, null);
|
|
});
|
|
|
|
it('setStreamOptions is host-only and a no-op for anyone else (same pattern as "next")', () => {
|
|
const io = fakeIo();
|
|
attach(io);
|
|
const party = PartyService.create('u-alice', 'Alice', 'm1', 'Un Film', '/img/m1', null, 'video');
|
|
PartyService.join(party, 'u-bob', 'Bob');
|
|
|
|
const bobSocket = fakeSocket('u-bob');
|
|
io.trigger('connection', bobSocket as unknown as AppSocket);
|
|
bobSocket.trigger('join', { partyId: party.id, deviceId: 'device-bob' });
|
|
|
|
bobSocket.trigger('setStreamOptions', { audioStreamIndex: '2', quality: '4000000' });
|
|
|
|
assert.equal(party.streamAudioIndex, null, 'a non-host request must not change party state');
|
|
assert.equal(party.streamQuality, null);
|
|
assert.ok(!io.roomEmits.some((e) => e.event === 'streamOptionsChanged'), 'and must not broadcast anything');
|
|
});
|
|
|
|
it('setStreamOptions from the host updates the party and broadcasts streamOptionsChanged to the room', () => {
|
|
const io = fakeIo();
|
|
attach(io);
|
|
const party = PartyService.create('u-alice', 'Alice', 'm1', 'Un Film', '/img/m1', null, 'video');
|
|
const aliceSocket = fakeSocket('u-alice');
|
|
io.trigger('connection', aliceSocket as unknown as AppSocket);
|
|
aliceSocket.trigger('join', { partyId: party.id, deviceId: 'device-alice' });
|
|
|
|
aliceSocket.trigger('setStreamOptions', { audioStreamIndex: '2', quality: '4000000' });
|
|
|
|
assert.equal(party.streamAudioIndex, '2');
|
|
assert.equal(party.streamQuality, '4000000');
|
|
const broadcast = io.roomEmits.find((e) => e.room === party.id && e.event === 'streamOptionsChanged');
|
|
assert.deepEqual(broadcast?.payload, { audioStreamIndex: '2', quality: '4000000' });
|
|
|
|
// Clearing a pick (back to "Emby's own default") resets it to null,
|
|
// independently of the other field.
|
|
aliceSocket.trigger('setStreamOptions', { audioStreamIndex: null, quality: '4000000' });
|
|
assert.equal(party.streamAudioIndex, null);
|
|
assert.equal(party.streamQuality, '4000000');
|
|
});
|
|
|
|
it('setStreamOptions is a no-op on a music party (no picker exists there at all)', () => {
|
|
const io = fakeIo();
|
|
attach(io);
|
|
const party = PartyService.create('u-alice', 'Alice', 't1', 'Track One', '/img/t1', null, 'music');
|
|
const aliceSocket = fakeSocket('u-alice');
|
|
io.trigger('connection', aliceSocket as unknown as AppSocket);
|
|
aliceSocket.trigger('join', { partyId: party.id, deviceId: 'device-alice' });
|
|
|
|
aliceSocket.trigger('setStreamOptions', { audioStreamIndex: '2' });
|
|
|
|
assert.equal(party.streamAudioIndex, null);
|
|
assert.ok(!io.roomEmits.some((e) => e.event === 'streamOptionsChanged'));
|
|
});
|
|
});
|