diff --git a/README.md b/README.md index 675d0c4..f90bd76 100644 --- a/README.md +++ b/README.md @@ -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 - [x] Picture-in-picture for the video player (standard `requestPictureInPicture`, confirmed present on Android Chrome) — asked for directly for mobile, where there's no second window to diff --git a/client/src/api/party.ts b/client/src/api/party.ts index adbf8cd..86436a1 100644 --- a/client/src/api/party.ts +++ b/client/src/api/party.ts @@ -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 { + 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 diff --git a/client/src/views/album/Album.css b/client/src/views/album/Album.css index 2c80060..9aeea2a 100644 --- a/client/src/views/album/Album.css +++ b/client/src/views/album/Album.css @@ -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; diff --git a/client/src/views/album/Album.html b/client/src/views/album/Album.html index d3417c2..78e7cc5 100644 --- a/client/src/views/album/Album.html +++ b/client/src/views/album/Album.html @@ -4,7 +4,10 @@

- Voir sur Emby +
+ + Voir sur Emby +
diff --git a/client/src/views/album/Album.ts b/client/src/views/album/Album.ts index 7876e3b..3d7a991 100644 --- a/client/src/views/album/Album.ts +++ b/client/src/views/album/Album.ts @@ -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 = '

Aucun morceau dans cet album.

'; 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) => `
@@ -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('.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('.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'); } diff --git a/client/src/views/artist/Artist.html b/client/src/views/artist/Artist.html index 12694c8..534bd01 100644 --- a/client/src/views/artist/Artist.html +++ b/client/src/views/artist/Artist.html @@ -1,5 +1,8 @@ - +

Aucun résultat.

diff --git a/client/src/views/artist/Artist.ts b/client/src/views/artist/Artist.ts index 43e7644..827e7b3 100644 --- a/client/src/views/artist/Artist.ts +++ b/client/src/views/artist/Artist.ts @@ -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 { 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 = '

Aucun album trouvé pour cet artiste.

'; 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(); + } + }); } diff --git a/docs/architecture.md b/docs/architecture.md index 9747efa..fe7e06f 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -3404,6 +3404,82 @@ device/login reachable from this sandbox, same standing gap as this project's other browser-native integrations (casting, the intro-skip buttons). +## 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 diff --git a/server/src/index.ts b/server/src/index.ts index a478b6f..15ddb96 100644 --- a/server/src/index.ts +++ b/server/src/index.ts @@ -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 { + 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) { diff --git a/server/src/services/MusicQueueService.ts b/server/src/services/MusicQueueService.ts index 6e96e7f..8b43479 100644 --- a/server/src/services/MusicQueueService.ts +++ b/server/src/services/MusicQueueService.ts @@ -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). */ diff --git a/server/test/musicQueueService.test.ts b/server/test/musicQueueService.test.ts index 26a5775..a1b5466 100644 --- a/server/test/musicQueueService.test.ts +++ b/server/test/musicQueueService.test.ts @@ -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[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');