Asked for directly: views/album's and views/artist's per-track/ per-album buttons had no whole-album or whole-discography equivalent - queuing an album meant clicking every track one at a time, and an artist page had no listen-party action at all beyond drilling into a specific album. Resolved server-side, not via a client-side loop - the same choice already made for playlists, and for the same reason: an artist's entire discography can genuinely be dozens of albums and hundreds of tracks, and a client loop of individual add-track calls would mean that many sequential round trips (slow, and non-atomic) instead of one. New resolveMusicTracksForItem (index.ts) takes a raw Emby item already known to be Audio/MusicAlbum/MusicArtist and resolves it to a flat, ordered track list - a single track, every track in an album (in Emby's own IndexNumber order), or every album's tracks for an artist's whole discography (fetched in parallel, in Emby's own chronological album order). POST /api/parties gained a MusicAlbum/MusicArtist branch right alongside the existing Playlist one, using this plus a new shared createMusicPartyFromTracks - which also let createPartyFromPlaylist's own music branch drop its separate per-track loop in favor of the same two calls. MusicQueueService.addTracks is the batch sibling of the existing addTrack (both now share a buildTrackEntry helper) - same auto-starts-if-the-queue-was-empty behavior, but one queueUpdated broadcast for the whole batch rather than one per track, so a hundred-track discography doesn't spam the party with a hundred intermediate broadcasts for what's really one user action. Adding to an already-live party's queue needed its own new route, POST /api/parties/:partyId/queue - given just an itemId, resolves it the same way and calls addTracks. Kept separate from the existing POST .../tracks, which takes an already-built track object a caller already has in hand (views/album's own per-track buttons) - overloading that route to guess whether it received a full track or a bare id would have been more surprising than a second, clearly-named one. The buttons themselves mirror views/playlist's own "Lancer cette playlist" - same label-reflects-current-state-and-re-checks-on-click discipline views/album's per-track buttons already had, same attach-the-bar-and-stay-put behavior when starting fresh. views/album gained one page-level button (reusing .playlistActions from Playlist.css, same "one CSS bundle regardless of which view's file a rule started in" precedent Album.css already set for .seriesInfo). views/artist gained its first listen-party action ever, sitting in .pageHeader itself (its own justify-content: space-between already puts a second child on the right of the title with no new CSS needed). Verified: full typecheck/test/build on both projects (147/147 server tests - new coverage for addTracks: batch order preserved, auto-start only fires for the batch's own first track when the queue was empty, exactly one broadcast regardless of batch size, harmless no-op for an empty batch). resolveMusicTracksForItem/createMusicPartyFromTracks themselves are index.ts route glue, not independently unit-tested - consistent with this project's existing boundary that route-level orchestration isn't, only the services it calls into are. The two new buttons' layout was checked visually against the real compiled CSS (a static render, real theme tokens), not eyeballed. Not verified: an actual large discography queuing correctly end to end against a real server - no Emby login reachable from this sandbox. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
291 lines
15 KiB
TypeScript
291 lines
15 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 { PlaystateService } from '../src/services/PlaystateService';
|
|
import type { AppSocket, Party, PartyMember, MusicState } 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
|
|
};
|
|
}
|
|
|
|
/** A connected member with a known token - the two things
|
|
* playNext's own markPlayed loop (and index.ts's periodic sweep) both
|
|
* gate on. Not used by most tests in this file at all: `fakeParty`'s
|
|
* own `members` map starts empty, and MusicQueueService's methods never
|
|
* populate it themselves (that's PartyService.join's job) - so unless a
|
|
* test explicitly adds one via this, playNext's markPlayed loop simply
|
|
* has nothing to iterate, same as an empty party in production. */
|
|
function fakeMember(userId: string, userName: string, embyToken: string): PartyMember {
|
|
return {
|
|
userId,
|
|
userName,
|
|
connection: {} as unknown as AppSocket,
|
|
deviceId: null,
|
|
embyToken,
|
|
isPaused: false,
|
|
positionTicks: 0,
|
|
lastUpdatedUtc: Date.now(),
|
|
embySession: 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('addTracks queues a whole batch (an album, a discography) in the given order, behind whatever is already playing', () => {
|
|
const party = fakeParty('p2b');
|
|
MusicQueueService.init(party, { itemId: 't1', itemName: 'Track One', itemImageUrl: '/img/t1', durationTicks: 10_000_000 }, 'u-alice', 'Alice');
|
|
|
|
MusicQueueService.addTracks(party, [
|
|
{ itemId: 't2', itemName: 'Track Two', itemImageUrl: '/img/t2', durationTicks: 20_000_000 },
|
|
{ itemId: 't3', itemName: 'Track Three', itemImageUrl: '/img/t3', durationTicks: 30_000_000 }
|
|
], 'u-bob', 'Bob');
|
|
|
|
assert.equal(music(party).current?.track.itemId, 't1', 'still the track already playing');
|
|
assert.deepEqual(music(party).queue.map((t) => t.itemId), ['t2', 't3']);
|
|
});
|
|
|
|
it('addTracks auto-starts the first track (and only the first) when the queue was empty', () => {
|
|
const party = fakeParty('p2c');
|
|
MusicQueueService.init(party, null, 'u-alice', 'Alice'); // empty queue - nothing playing yet
|
|
|
|
MusicQueueService.addTracks(party, [
|
|
{ itemId: 't1', itemName: 'Track One', itemImageUrl: '/img/t1', durationTicks: 10_000_000 },
|
|
{ itemId: 't2', itemName: 'Track Two', itemImageUrl: '/img/t2', durationTicks: 20_000_000 }
|
|
], 'u-alice', 'Alice');
|
|
|
|
assert.equal(music(party).current?.track.itemId, 't1', 'the batch\'s own first track starts playing');
|
|
assert.deepEqual(music(party).queue.map((t) => t.itemId), ['t2']);
|
|
});
|
|
|
|
it('addTracks broadcasts once for the whole batch, not once per track', () => {
|
|
const party = fakeParty('p2d');
|
|
MusicQueueService.init(party, { itemId: 't1', itemName: 'Track One', itemImageUrl: '/img/t1', durationTicks: 10_000_000 }, 'u-alice', 'Alice');
|
|
let broadcasts = 0;
|
|
// Same "attach a fresh fake io" pattern socketGateway.test.ts's own
|
|
// attach() helper uses - MusicQueueService is a singleton, but every
|
|
// test that cares about broadcasts re-attaches its own fake at the
|
|
// start rather than resetting afterward, and every test that doesn't
|
|
// care never looks at this.io's side effects at all.
|
|
MusicQueueService.attachIo({ to: () => ({ emit: () => { broadcasts++; } }) } as unknown as Parameters<typeof MusicQueueService.attachIo>[0]);
|
|
|
|
MusicQueueService.addTracks(party, [
|
|
{ itemId: 't2', itemName: 'Track Two', itemImageUrl: '/img/t2', durationTicks: 20_000_000 },
|
|
{ itemId: 't3', itemName: 'Track Three', itemImageUrl: '/img/t3', durationTicks: 30_000_000 },
|
|
{ itemId: 't4', itemName: 'Track Four', itemImageUrl: '/img/t4', durationTicks: 40_000_000 }
|
|
], 'u-bob', 'Bob');
|
|
|
|
assert.equal(broadcasts, 1);
|
|
});
|
|
|
|
it('addTracks is a harmless no-op for an empty batch', () => {
|
|
const party = fakeParty('p2e');
|
|
MusicQueueService.init(party, { itemId: 't1', itemName: 'Track One', itemImageUrl: '/img/t1', durationTicks: 10_000_000 }, 'u-alice', 'Alice');
|
|
|
|
assert.doesNotThrow(() => MusicQueueService.addTracks(party, [], 'u-alice', 'Alice'));
|
|
assert.equal(music(party).queue.length, 0);
|
|
});
|
|
|
|
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('a skip marks the OUTGOING track Played for every connected member with a token, under their own account', (t) => {
|
|
const calls: [string, string, string][] = [];
|
|
t.mock.method(PlaystateService, 'markPlayed', async (embyToken: string, userId: string, itemId: string) => {
|
|
calls.push([embyToken, userId, itemId]);
|
|
});
|
|
|
|
const party = fakeParty('p3b');
|
|
party.members.set('u-alice', fakeMember('u-alice', 'Alice', 'tok-alice'));
|
|
party.members.set('u-bob', fakeMember('u-bob', 'Bob', 'tok-bob'));
|
|
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(calls.length, 0, 'starting the very first track has nothing outgoing to mark');
|
|
|
|
// connectedMemberCount=2 needs a strict majority (2) to actually
|
|
// skip - same arithmetic as the "needs a strict majority" test above.
|
|
MusicQueueService.voteSkip(party, 'u-alice', 'Alice', 2);
|
|
assert.equal(calls.length, 0, 'one vote of two is not a majority yet - nothing outgoing to mark');
|
|
MusicQueueService.voteSkip(party, 'u-bob', 'Bob', 2);
|
|
|
|
assert.deepEqual(calls.sort(), [['tok-alice', 'u-alice', 't1'], ['tok-bob', 'u-bob', 't1']].sort(), 'marks t1 (outgoing), not t2 (just started)');
|
|
});
|
|
|
|
it('never marks a member Played if they have no connection or no token yet', (t) => {
|
|
let called = false;
|
|
t.mock.method(PlaystateService, 'markPlayed', async () => { called = true; });
|
|
|
|
const party = fakeParty('p3c');
|
|
party.members.set('u-alice', { ...fakeMember('u-alice', 'Alice', 'tok-alice'), connection: null });
|
|
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');
|
|
|
|
MusicQueueService.voteSkip(party, 'u-alice', 'Alice', 1);
|
|
|
|
assert.equal(called, false);
|
|
});
|
|
|
|
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('seek while paused jumps the frozen elapsed time directly, without needing a resume', () => {
|
|
const party = fakeParty('p4b');
|
|
MusicQueueService.init(party, { itemId: 't1', itemName: 'Track One', itemImageUrl: '/img/t1', durationTicks: 600_000_000 /* 60s */ }, 'u-alice', 'Alice');
|
|
MusicQueueService.setPaused(party, true);
|
|
|
|
MusicQueueService.seek(party, 30);
|
|
|
|
assert.equal(music(party).current?.isPaused, true, 'seeking must not itself resume playback');
|
|
assert.equal(MusicQueueService.getElapsedSeconds(party), 30);
|
|
});
|
|
|
|
it('seek clamps into [0, duration] rather than trusting an out-of-range value verbatim', () => {
|
|
const party = fakeParty('p4c');
|
|
MusicQueueService.init(party, { itemId: 't1', itemName: 'Track One', itemImageUrl: '/img/t1', durationTicks: 100_000_000 /* 10s */ }, 'u-alice', 'Alice');
|
|
MusicQueueService.setPaused(party, true);
|
|
|
|
MusicQueueService.seek(party, 999);
|
|
assert.equal(MusicQueueService.getElapsedSeconds(party), 10, 'clamped to the track\'s own duration');
|
|
|
|
MusicQueueService.seek(party, -5);
|
|
assert.equal(MusicQueueService.getElapsedSeconds(party), 0, 'clamped to zero, not left negative');
|
|
});
|
|
|
|
it('seek while playing re-arms the auto-advance timer against the new position, not the old one', async () => {
|
|
const party = fakeParty('p4d');
|
|
// Exactly a 2s track (20_000_000 ticks / 10_000_000 per second - no
|
|
// rounding to worry about) - seeking to 1.7s leaves ~300ms remaining.
|
|
// If the timer were left un-re-armed (still counting down from the
|
|
// original full 2s mark instead), it would still be playing at the
|
|
// 600ms mark below - comfortably short of the original ~2000ms, so
|
|
// this genuinely distinguishes the two.
|
|
MusicQueueService.init(party, { itemId: 't1', itemName: 'Track One', itemImageUrl: '/img/t1', durationTicks: 20_000_000 }, 'u-alice', 'Alice');
|
|
|
|
MusicQueueService.seek(party, 1.7);
|
|
assert.equal(music(party).current?.track.itemId, 't1', 'still the same track right after seeking');
|
|
|
|
await sleep(600); // past the re-armed ~300ms, comfortably short of the original ~2000ms
|
|
assert.equal(music(party).current, null, 'nothing queued behind it - should have auto-advanced off the re-armed timer');
|
|
});
|
|
|
|
it('seek while playing clears the old timer before scheduling a new one - never double-scheduled', (t) => {
|
|
const realClearTimeout = global.clearTimeout;
|
|
const cleared = mock.fn(realClearTimeout);
|
|
global.clearTimeout = cleared as typeof clearTimeout;
|
|
t.after(() => { global.clearTimeout = realClearTimeout; });
|
|
|
|
const party = fakeParty('p4e');
|
|
MusicQueueService.init(party, { itemId: 't1', itemName: 'Track One', itemImageUrl: '/img/t1', durationTicks: 600_000_000 /* 60s - stays pending */ }, 'u-alice', 'Alice');
|
|
|
|
MusicQueueService.seek(party, 10);
|
|
|
|
assert.equal(cleared.mock.callCount(), 1, 'the pre-seek timer must be cleared, not left running alongside the new one');
|
|
MusicQueueService.destroy(party); // avoid leaking this test's own long-duration timer
|
|
});
|
|
|
|
it('seek is a harmless no-op when nothing is currently playing', () => {
|
|
const party = fakeParty('p4f');
|
|
MusicQueueService.init(party, null, 'u-alice', 'Alice');
|
|
assert.doesNotThrow(() => MusicQueueService.seek(party, 30));
|
|
});
|
|
|
|
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'));
|
|
});
|
|
});
|