Queue a whole album or an artist's entire discography in one action
Some checks failed
Publier l'image Docker / test (pull_request) Failing after 4s
Publier l'image Docker / publish (pull_request) Has been skipped

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>
This commit is contained in:
Nicolas 2026-09-16 07:46:38 +02:00
parent b41437e23e
commit 62afa88bb7
11 changed files with 407 additions and 46 deletions

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,8 +4,11 @@
<div class="seriesInfo">
<h1 id="albumTitle"></h1>
<p class="hint hide" id="albumArtist"></p>
<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..." />
<p class="hint searchEmpty hide">Aucun résultat.</p>

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 = {
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
};
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));
}));
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');