Queue a whole album or an artist's entire discography in one action #7

Merged
kyuno merged 1 commit from feature/queue-album-and-discography into master 2026-09-16 07:54:14 +02:00
11 changed files with 407 additions and 46 deletions
Showing only changes of commit 62afa88bb7 - Show all commits

View file

@ -340,6 +340,10 @@ Then put it behind your existing reverse proxy like everything else
dashboard sees watch-party streams the same way it sees a native
client's — one session per member, under their own account — see
docs/architecture.md
- [x] Queue a whole album, or an artist's entire discography, in one
action — resolved server-side (one atomic operation, not one
request per track) the same way launching a playlist already
expands into everything it contains — see docs/architecture.md
- [ ] Try a full party end-to-end with two real accounts (still the main gap)
- [ ] Try casting to a real Chromecast (only the SDK's own bootstrap is
confirmed here — no real device/session)

View file

@ -61,6 +61,21 @@ export function addTrackToQueue(partyId: string, track: MusicTrackInput): Promis
return apiFetch(`/api/parties/${partyId}/tracks`, { method: 'POST', body: JSON.stringify(track) });
}
/**
* Music only: queues `itemId` into a party's shared queue, whatever it
* turns out to be - a single track queues itself, an album or an
* artist expands into every track inside it (see index.ts's
* resolveMusicTracksForItem) - the "add to queue" half of
* views/album's/views/artist's own whole-album/whole-discography
* button (createParty(itemId) is the other half, for when there's no
* party active yet - see Album.ts/Artist.ts). Distinct from
* addTrackToQueue above, which takes an already-built track object a
* caller has in hand rather than an id to resolve server-side.
*/
export function queueItem(partyId: string, itemId: string): Promise<PartyPublicState> {
return apiFetch(`/api/parties/${partyId}/queue`, { method: 'POST', body: JSON.stringify({ itemId }) });
}
/**
* Mints a short-lived, party-scoped token a Chromecast can use to reach
* this party's own shared stream routes, which otherwise only accept the

View file

@ -1,3 +1,9 @@
/* .albumHeader's own two children - .seriesInfo (Series.css) and
.playlistActions (Playlist.css) - are reused as-is, same precedent
Album.css already set for .seriesInfo itself: every route's CSS ends
up in the one bundle regardless of which view imports it, so there's
no need to duplicate a rule this generic (a flex row of actions under
a title) just because it was first written for a different page. */
.albumHeader {
display: flex;
gap: 20px;

View file

@ -4,7 +4,10 @@
<div class="seriesInfo">
<h1 id="albumTitle"></h1>
<p class="hint hide" id="albumArtist"></p>
<a class="linklike" id="embyLink" target="_blank" rel="noopener">Voir sur Emby</a>
<div class="playlistActions">
<button id="launchBtn">Écouter cet album</button>
<a class="linklike" id="embyLink" target="_blank" rel="noopener">Voir sur Emby</a>
</div>
</div>
</div>
<input id="searchInput" class="searchBar" type="search" placeholder="Rechercher un morceau..." />

View file

@ -2,7 +2,7 @@ import './Album.css';
import '../../styles/trackList.css';
import html from './Album.html';
import { getAlbumTracks, getItem } from '../../api/embyApi';
import { addTrackToQueue, createParty } from '../../api/party';
import { addTrackToQueue, createParty, queueItem } from '../../api/party';
import { getActiveMusicPartyId, joinMusicParty } from '../../musicBar/MusicBar';
import { escapeHtml, formatDuration, wireSearchFilter } from '../../tiles';
import { LOADING_HINT } from '../../loading';
@ -42,9 +42,11 @@ export async function renderAlbum(root: HTMLElement, params: RouteParams): Promi
(root.querySelector('#embyLink') as HTMLAnchorElement).href = album.embyUrl;
const listEl = root.querySelector('#trackList')!;
const launchBtn = root.querySelector('#launchBtn') as HTMLButtonElement;
if (!tracks.length) {
listEl.innerHTML = '<p class="hint">Aucun morceau dans cet album.</p>';
root.querySelector('#searchInput')!.remove();
launchBtn.disabled = true;
return;
}
@ -56,6 +58,8 @@ export async function renderAlbum(root: HTMLElement, params: RouteParams): Promi
// someone hit "Quitter" on the bar while this page was already open).
const startLabel = 'Écouter ensemble';
const addLabel = 'Ajouter à la file';
const startAlbumLabel = 'Écouter cet album';
const addAlbumLabel = "Ajouter l'album à la file";
listEl.innerHTML = tracks.map((t) => `
<div class="trackRow">
@ -77,14 +81,18 @@ export async function renderAlbum(root: HTMLElement, params: RouteParams): Promi
};
}
// Re-labels every track button from current state, not just the one
// just clicked - starting a party from track 3 means tracks 1, 2 and 4
// can now also be queued instead of starting a party of their own.
// Re-labels every track button (and the whole-album one) from current
// state, not just the one just clicked - starting a party from track 3
// means tracks 1, 2 and 4 can now also be queued instead of starting a
// party of their own.
function refreshLabels(): void {
const active = getActiveMusicPartyId();
listEl.querySelectorAll<HTMLButtonElement>('.startPartyBtn').forEach((b) => {
if (!b.disabled) b.textContent = getActiveMusicPartyId() ? addLabel : startLabel;
if (!b.disabled) b.textContent = active ? addLabel : startLabel;
});
if (!launchBtn.disabled) launchBtn.textContent = active ? addAlbumLabel : startAlbumLabel;
}
refreshLabels(); // sets launchBtn's own initial label - the per-track buttons already get theirs from the template above
listEl.querySelectorAll<HTMLButtonElement>('.startPartyBtn').forEach((btn, i) => {
const track = tracks[i] as ItemDto;
@ -115,5 +123,32 @@ export async function renderAlbum(root: HTMLElement, params: RouteParams): Promi
});
});
// Whole-album equivalent of the per-track buttons above - asked for
// directly, alongside the same action for a whole artist (see
// Artist.ts). itemId is the album itself, not any one track - the
// server expands it into every track inside, in order (see index.ts's
// resolveMusicTracksForItem/createMusicPartyFromTracks), the same way
// launching a playlist already expands into everything it contains.
launchBtn.addEventListener('click', async () => {
launchBtn.disabled = true;
const activePartyId = getActiveMusicPartyId();
try {
if (activePartyId) {
launchBtn.textContent = 'Ajout...';
await queueItem(activePartyId, albumId);
} else {
launchBtn.textContent = 'Démarrage...';
const party = await createParty(albumId);
joinMusicParty(party.partyId, party);
}
launchBtn.textContent = 'Ajouté !';
setTimeout(() => { launchBtn.disabled = false; refreshLabels(); }, 1500);
} catch (err) {
alert((err as Error).message);
launchBtn.disabled = false;
refreshLabels();
}
});
wireSearchFilter(root.querySelector('#searchInput') as HTMLInputElement, root, '.trackRow');
}

View file

@ -1,5 +1,8 @@
<button class="linklike" id="back">&larr; Retour</button>
<div class="pageHeader"><h1></h1></div>
<div class="pageHeader">
<h1></h1>
<button id="launchBtn">Écouter toute la discographie</button>
</div>
<input id="searchInput" class="searchBar" type="search" placeholder="Rechercher un album..." />
<p class="hint searchEmpty hide">Aucun résultat.</p>
<div id="grid" class="grid"></div>

View file

@ -1,5 +1,7 @@
import html from './Artist.html';
import { getArtistAlbums, getItem } from '../../api/embyApi';
import { createParty, queueItem } from '../../api/party';
import { getActiveMusicPartyId, joinMusicParty } from '../../musicBar/MusicBar';
import type { RouteParams } from '../../router';
import { tileHtml, wireSearchFilter } from '../../tiles';
import { LOADING_HINT } from '../../loading';
@ -8,9 +10,10 @@ import { LOADING_HINT } from '../../loading';
* library's top-level grid (see views/library) via a MusicArtist tile's
* "Voir les albums" link (tiles.ts's DRILL_DOWN table). Structurally the
* same "detail header + grid" shape as views/series, just one level up
* the music hierarchy (Artist -> Album, not Series -> Season/Episode) and
* with no per-item action beyond drilling further into an album (an
* artist has nothing itself to start a party from). */
* the music hierarchy (Artist -> Album, not Series -> Season/Episode).
* #launchBtn is the one action available at this level itself (every
* album's own tracks, in one go) - drilling into a specific album is
* still the only way to queue less than the whole discography. */
export async function renderArtist(root: HTMLElement, params: RouteParams): Promise<void> {
const artistId = params.id as string;
root.innerHTML = LOADING_HINT;
@ -28,12 +31,52 @@ export async function renderArtist(root: HTMLElement, params: RouteParams): Prom
root.querySelector('h1')!.textContent = artist.name;
root.querySelector('#back')!.addEventListener('click', () => history.back());
const launchBtn = root.querySelector('#launchBtn') as HTMLButtonElement;
const grid = root.querySelector('#grid')!;
if (!albums.length) {
grid.innerHTML = '<p class="hint">Aucun album trouvé pour cet artiste.</p>';
root.querySelector('#searchInput')!.remove();
launchBtn.disabled = true;
return;
}
grid.innerHTML = albums.map(tileHtml).join('');
wireSearchFilter(root.querySelector('#searchInput') as HTMLInputElement, root, '.tile');
// Label reflects whether a listen party is already active - same
// "harmless, occasionally-stale until the next refresh" reasoning as
// views/album's own equivalent buttons (the click handler below
// always re-checks getActiveMusicPartyId() fresh regardless).
const startLabel = 'Écouter toute la discographie';
const addLabel = 'Ajouter la discographie à la file';
function refreshLabel(): void {
if (!launchBtn.disabled) launchBtn.textContent = getActiveMusicPartyId() ? addLabel : startLabel;
}
refreshLabel();
// itemId is the artist itself - the server expands it into every
// album's tracks, in order, the same mechanism a whole-album launch
// uses one level down (see views/album/Album.ts, and index.ts's
// resolveMusicTracksForItem/createMusicPartyFromTracks).
launchBtn.addEventListener('click', async () => {
launchBtn.disabled = true;
const activePartyId = getActiveMusicPartyId();
try {
if (activePartyId) {
launchBtn.textContent = 'Ajout...';
await queueItem(activePartyId, artistId);
} else {
launchBtn.textContent = 'Démarrage...';
// Same "attach the bar, stay put" pattern as views/album - see
// its own comment.
const party = await createParty(artistId);
joinMusicParty(party.partyId, party);
}
launchBtn.textContent = 'Ajouté !';
setTimeout(() => { launchBtn.disabled = false; refreshLabel(); }, 1500);
} catch (err) {
alert((err as Error).message);
launchBtn.disabled = false;
refreshLabel();
}
});
}

View file

@ -3343,6 +3343,82 @@ Authorization` Device/Client/Version header a real client would, only
`X-Emby-Token` - is genuinely unconfirmed. Worth watching the real
dashboard the first time a party runs after this ships.
## Queuing a whole album, or an artist's entire discography, in one action
Asked for directly: `views/album`'s and `views/artist`'s per-track/
per-album "Écouter ensemble"/"Ajouter à la file" buttons had no
whole-album or whole-discography equivalent - queuing an album meant
clicking every track in it one at a time, and an artist page had no
listen-party action at all beyond drilling into a specific album.
**Server-side resolution, not a client-side loop** - the same choice
already made for playlists (`createPartyFromPlaylist`), and for the
same reason: an artist's "entire discography" can genuinely be dozens
of albums and hundreds of tracks, and a client-side loop of individual
`addTrackToQueue` calls would mean that many sequential round trips
(slow, and non-atomic - a network hiccup partway through leaves a
partial queue) instead of one. New `resolveMusicTracksForItem(session,
item)` (`index.ts`) takes a raw Emby item already known to be
`Audio`/`MusicAlbum`/`MusicArtist` and resolves it to a flat, ordered
`MusicTrackInput[]` - a single track for `Audio`, every track in Emby's
own `IndexNumber` order for a `MusicAlbum`
(`CatalogService.getAlbumTracks`), every album's tracks (same order)
for a `MusicArtist`'s own albums (`getArtistAlbums`, itself already
sorted `ProductionYear,SortName` - i.e. chronological), fetched with
one `Promise.all` rather than sequentially. `POST /api/parties` gained
a `MusicAlbum`/`MusicArtist` branch, right alongside the existing
`Playlist` one, calling this and a new shared `createMusicPartyFromTracks`
- itself just `MusicQueueService.init` for the first track plus the new
`addTracks` (below) for the rest, extracted so `createPartyFromPlaylist`'s
own music branch could be simplified onto the exact same two calls
rather than keep its own separate per-track loop.
**`MusicQueueService.addTracks`** is the batch sibling of the existing
`addTrack` (both now share a `buildTrackEntry` helper for the actual
per-track shape) - same auto-starts-if-the-queue-was-empty behavior,
but one `queueUpdated` broadcast for the whole batch rather than one
per track. That distinction is what a naive "just call `addTrack` in a
loop" version would have gotten wrong: for a hundred-track discography,
that's a hundred intermediate broadcasts to everyone in the party for
what is, from a user's perspective, a single action.
**Adding to an already-live party's queue** needed its own new route,
`POST /api/parties/:partyId/queue` - given just an `itemId`, it resolves
it the same way (`resolveMusicTracksForItem`) 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, which never needed this resolution step at
all) - overloading that route to guess whether it received a full
track or a bare id to resolve would have been more surprising than a
second, clearly-named one.
**The buttons themselves** mirror `views/playlist`'s own "Lancer cette
playlist" exactly - same label-reflects-current-state-and-re-checks-on-
click discipline `views/album`'s per-track buttons already established,
same "attach the bar and stay on the page" behavior when starting fresh.
`views/album` gained one page-level button (`.playlistActions`, reused
from `Playlist.css` the same way `Album.css` already reuses `.seriesInfo`
from `Series.css` - one CSS bundle regardless of which view's file a
rule was first written in, so nothing needed duplicating). `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, and a
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 (same reasoning `createPartyFromPlaylist` was never
tested directly either). The two new buttons' layout was checked
visually against the real compiled CSS (a static render, real theme
tokens) rather than eyeballed. **Not verified**: an actual large
discography queuing correctly end to end against a real server - no
Emby login reachable from this sandbox.
## Known limitations
- Playback always goes through `hls.js` against whatever Emby's

View file

@ -17,7 +17,7 @@ import { PlaystateService } from './services/PlaystateService';
import { EmbySessionService } from './services/EmbySessionService';
import { CastTokenService } from './services/CastTokenService';
import { SocketGateway } from './realtime/SocketGateway';
import type { ClientToServerEvents, EmbySession, MediaType, QueueItem, ServerToClientEvents } from './types';
import type { ClientToServerEvents, EmbyItem, EmbySession, ItemDto, MediaType, MusicTrackInput, QueueItem, ServerToClientEvents } from './types';
const app = express();
app.set('trust proxy', 1); // behind openresty/nginx - needed for req.secure to reflect the real scheme
@ -345,6 +345,18 @@ app.post('/api/parties', requireSession, asyncRoute(async (req, res) => {
return;
}
// Neither is itself streamable (same reason a Playlist above isn't) -
// launching one queues every track inside it instead: every album for
// an artist, every track for an album, in Emby's own sort order (see
// resolveMusicTracksForItem). Asked for directly, alongside the same
// action on the party's own existing queue - see the "queue" route
// below - both share this resolution.
if (item.Type === 'MusicAlbum' || item.Type === 'MusicArtist') {
const tracks = await resolveMusicTracksForItem(session, item);
createMusicPartyFromTracks(res, session, tracks);
return;
}
const mediaType: MediaType = item.Type === 'Audio' ? 'music' : 'video';
// Starting a party from an episode queues the rest of the series from
@ -432,33 +444,15 @@ async function createPartyFromPlaylist(res: Response, session: EmbySession, play
const items = startIndex > 0 ? allItems.slice(startIndex) : allItems;
if (mediaType === 'music') {
const first = items[0]!;
const party = PartyService.create(
session.userId, session.userName,
first.Id, first.Name, imageUrlFor(first),
null, mediaType
);
PartyService.setMemberEmbyToken(party, session.userId, session.embyToken);
// Same mechanism POST /api/parties/:id/tracks already uses (see
// below) - init seeds+auto-starts the first track, addTrack queues
// the rest, exactly as if they'd been added one at a time.
items.forEach((track, i) => {
const trackInput = {
itemId: track.Id,
itemName: track.Name,
artistName: track.AlbumArtist || track.Artists?.[0] || null,
albumName: track.Album || null,
itemImageUrl: imageUrlFor(track),
durationTicks: track.RunTimeTicks || null
};
if (i === 0) {
MusicQueueService.init(party, trackInput, session.userId, session.userName);
} else {
MusicQueueService.addTrack(party, trackInput, session.userId, session.userName);
}
});
SocketGateway.broadcastLobby(io);
res.json(PartyService.toPublicState(party, session.userId));
const trackInputs: MusicTrackInput[] = items.map((track) => ({
itemId: track.Id,
itemName: track.Name,
artistName: track.AlbumArtist || track.Artists?.[0] || null,
albumName: track.Album || null,
itemImageUrl: imageUrlFor(track),
durationTicks: track.RunTimeTicks || null
}));
createMusicPartyFromTracks(res, session, trackInputs);
return;
}
@ -477,6 +471,73 @@ async function createPartyFromPlaylist(res: Response, session: EmbySession, play
res.json(PartyService.toPublicState(party, session.userId));
}
/** A raw Emby item known/expected to be music-shaped (Audio, MusicAlbum,
* or MusicArtist - the three POST /api/parties and the "queue" route
* below ever call this for) resolved into a flat, ordered track list -
* a single track for Audio, every track in Emby's own IndexNumber order
* for an album, every album's tracks in Emby's own ProductionYear/
* SortName order for an artist's whole discography. Two Emby round
* trips for an artist's own albums+tracks would be one call each if
* this app ever needed to page through a huge discography - not
* attempted speculatively, since Emby has yet to show any sign of
* needing it for a personal library's worth of artists. */
async function resolveMusicTracksForItem(session: EmbySession, item: EmbyItem): Promise<MusicTrackInput[]> {
if (item.Type === 'MusicAlbum') {
const tracks = await CatalogService.getAlbumTracks(session, item.Id);
return tracks.map(itemDtoToTrackInput);
}
if (item.Type === 'MusicArtist') {
const albums = await CatalogService.getArtistAlbums(session, item.Id);
const perAlbum = await Promise.all(albums.map((album) => CatalogService.getAlbumTracks(session, album.id)));
return perAlbum.flat().map(itemDtoToTrackInput);
}
// A single Audio track - the only other type this is ever called for.
return [{
itemId: item.Id,
itemName: item.Name,
artistName: item.AlbumArtist || item.Artists?.[0] || null,
albumName: item.Album || null,
itemImageUrl: imageUrlFor(item),
durationTicks: item.RunTimeTicks || null
}];
}
function itemDtoToTrackInput(item: ItemDto): MusicTrackInput {
return {
itemId: item.id,
itemName: item.name,
artistName: item.artistName,
albumName: item.albumName,
itemImageUrl: item.imageUrl,
durationTicks: item.durationTicks
};
}
/** Shared by every "launch this as a listen party" path that can produce
* more than one track up front (an album, an artist's discography, a
* music playlist - see createPartyFromPlaylist above and the
* MusicAlbum/MusicArtist branch of POST /api/parties below) - seeds
* party.music with `tracks`, in order: the first auto-starts,
* everything else queues behind it via MusicQueueService.addTracks
* (one broadcast for however many tracks that is, not one per track). */
function createMusicPartyFromTracks(res: Response, session: EmbySession, tracks: MusicTrackInput[]): void {
if (!tracks.length) {
res.status(400).json({ error: 'Aucun morceau à lire.' });
return;
}
const [first, ...rest] = tracks;
const party = PartyService.create(
session.userId, session.userName,
first!.itemId, first!.itemName, first!.itemImageUrl,
null, 'music'
);
PartyService.setMemberEmbyToken(party, session.userId, session.embyToken);
MusicQueueService.init(party, first!, session.userId, session.userName);
MusicQueueService.addTracks(party, rest, session.userId, session.userName);
SocketGateway.broadcastLobby(io);
res.json(PartyService.toPublicState(party, session.userId));
}
// Adding a track to a music party's shared queue is REST, not a socket
// event (see SocketGateway's top comment): the album-browsing page that
// this is actually called from has no live connection to the party it's
@ -505,6 +566,39 @@ app.post('/api/parties/:partyId/tracks', requireSession, (req, res) => {
res.json(PartyService.toPublicState(party, session.userId));
});
// Same "queue this item" resolution as the MusicAlbum/MusicArtist branch
// of POST /api/parties above (a track queues itself; an album/artist
// expands into every track inside it - see resolveMusicTracksForItem),
// but adding into an ALREADY-LIVE party's queue instead of creating a
// new one - views/album's and views/artist's own "add to queue" buttons.
// Kept as its own route rather than overloading POST .../tracks above:
// that one takes an already-built track object the caller already has
// in hand (views/album's per-track buttons) - making it also guess
// whether it received that or a bare id-to-resolve would be more
// surprising than a second, clearly-named route.
app.post('/api/parties/:partyId/queue', requireSession, asyncRoute(async (req, res) => {
const party = PartyService.getById(param(req, 'partyId'));
if (!party || party.mediaType !== 'music') {
res.status(404).json({ error: 'Watch party musique introuvable.' });
return;
}
const session = req.embySession!;
if (!party.members.has(session.userId)) {
res.status(403).json({ error: "Tu n'es pas membre de cette watch party." });
return;
}
const itemId = req.body?.itemId;
if (!itemId) {
res.status(400).json({ error: 'itemId is required.' });
return;
}
const item = await CatalogService.getRawItem(session, itemId);
const tracks = await resolveMusicTracksForItem(session, item);
MusicQueueService.addTracks(party, tracks, session.userId, session.userName);
SocketGateway.broadcastLobby(io); // the queue/now-playing preview may have just changed
res.json(PartyService.toPublicState(party, session.userId));
}));
app.post('/api/parties/:partyId/join', requireSession, (req, res) => {
const party = PartyService.getById(param(req, 'partyId'));
if (!party) {

View file

@ -52,7 +52,42 @@ class MusicQueueServiceClass {
addTrack(party: Party, track: MusicTrackInput, addedByUserId: string, addedByUserName: string): void {
const music = party.music!;
const entry: MusicTrack = {
const entry = this.buildTrackEntry(track, addedByUserId, addedByUserName);
music.queue.push(entry);
party.lastActivityUtc = Date.now();
Logger.party(`musicQueueAdd partyId=${party.id} user=${Logger.who(addedByUserId, addedByUserName)} track="${entry.itemName}" queueLength=${music.queue.length}`);
if (!music.current) {
this.playNext(party); // queue was empty - this track starts immediately
}
this.broadcast(party);
}
/**
* Same per-track behavior as addTrack (auto-starts if the queue was
* empty when the first of these lands), but ONE broadcast at the end
* instead of one per track - asked for directly, to queue a whole
* album or an artist's entire discography in one action rather than
* one track at a time. Without this, a hundred-track discography
* would spam the party with a hundred intermediate "queueUpdated"
* events for what's really a single user action - see index.ts's
* createMusicPartyFromTracks/the "queue" route, the two callers.
*/
addTracks(party: Party, tracks: MusicTrackInput[], addedByUserId: string, addedByUserName: string): void {
if (!tracks.length) return;
const music = party.music!;
for (const track of tracks) {
music.queue.push(this.buildTrackEntry(track, addedByUserId, addedByUserName));
if (!music.current) {
this.playNext(party);
}
}
party.lastActivityUtc = Date.now();
Logger.party(`musicQueueAddBatch partyId=${party.id} user=${Logger.who(addedByUserId, addedByUserName)} count=${tracks.length} queueLength=${music.queue.length}`);
this.broadcast(party);
}
private buildTrackEntry(track: MusicTrackInput, addedByUserId: string, addedByUserName: string): MusicTrack {
return {
itemId: track.itemId,
itemName: track.itemName,
artistName: track.artistName || null,
@ -64,13 +99,6 @@ class MusicQueueServiceClass {
addedByUserId,
addedByUserName
};
music.queue.push(entry);
party.lastActivityUtc = Date.now();
Logger.party(`musicQueueAdd partyId=${party.id} user=${Logger.who(addedByUserId, addedByUserName)} track="${entry.itemName}" queueLength=${music.queue.length}`);
if (!music.current) {
this.playNext(party); // queue was empty - this track starts immediately
}
this.broadcast(party);
}
/** No host check - anyone can toggle this (see class comment). */

View file

@ -77,6 +77,60 @@ describe('MusicQueueService', () => {
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');