Queue 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 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:
parent
b41437e23e
commit
62afa88bb7
11 changed files with 407 additions and 46 deletions
|
|
@ -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
|
dashboard sees watch-party streams the same way it sees a native
|
||||||
client's — one session per member, under their own account — see
|
client's — one session per member, under their own account — see
|
||||||
docs/architecture.md
|
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 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
|
- [ ] Try casting to a real Chromecast (only the SDK's own bootstrap is
|
||||||
confirmed here — no real device/session)
|
confirmed here — no real device/session)
|
||||||
|
|
|
||||||
|
|
@ -61,6 +61,21 @@ export function addTrackToQueue(partyId: string, track: MusicTrackInput): Promis
|
||||||
return apiFetch(`/api/parties/${partyId}/tracks`, { method: 'POST', body: JSON.stringify(track) });
|
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
|
* 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
|
* this party's own shared stream routes, which otherwise only accept the
|
||||||
|
|
|
||||||
|
|
@ -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 {
|
.albumHeader {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 20px;
|
gap: 20px;
|
||||||
|
|
|
||||||
|
|
@ -4,8 +4,11 @@
|
||||||
<div class="seriesInfo">
|
<div class="seriesInfo">
|
||||||
<h1 id="albumTitle"></h1>
|
<h1 id="albumTitle"></h1>
|
||||||
<p class="hint hide" id="albumArtist"></p>
|
<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>
|
<a class="linklike" id="embyLink" target="_blank" rel="noopener">Voir sur Emby</a>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<input id="searchInput" class="searchBar" type="search" placeholder="Rechercher un morceau..." />
|
<input id="searchInput" class="searchBar" type="search" placeholder="Rechercher un morceau..." />
|
||||||
<p class="hint searchEmpty hide">Aucun résultat.</p>
|
<p class="hint searchEmpty hide">Aucun résultat.</p>
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,7 @@ import './Album.css';
|
||||||
import '../../styles/trackList.css';
|
import '../../styles/trackList.css';
|
||||||
import html from './Album.html';
|
import html from './Album.html';
|
||||||
import { getAlbumTracks, getItem } from '../../api/embyApi';
|
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 { getActiveMusicPartyId, joinMusicParty } from '../../musicBar/MusicBar';
|
||||||
import { escapeHtml, formatDuration, wireSearchFilter } from '../../tiles';
|
import { escapeHtml, formatDuration, wireSearchFilter } from '../../tiles';
|
||||||
import { LOADING_HINT } from '../../loading';
|
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;
|
(root.querySelector('#embyLink') as HTMLAnchorElement).href = album.embyUrl;
|
||||||
|
|
||||||
const listEl = root.querySelector('#trackList')!;
|
const listEl = root.querySelector('#trackList')!;
|
||||||
|
const launchBtn = root.querySelector('#launchBtn') as HTMLButtonElement;
|
||||||
if (!tracks.length) {
|
if (!tracks.length) {
|
||||||
listEl.innerHTML = '<p class="hint">Aucun morceau dans cet album.</p>';
|
listEl.innerHTML = '<p class="hint">Aucun morceau dans cet album.</p>';
|
||||||
root.querySelector('#searchInput')!.remove();
|
root.querySelector('#searchInput')!.remove();
|
||||||
|
launchBtn.disabled = true;
|
||||||
return;
|
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).
|
// someone hit "Quitter" on the bar while this page was already open).
|
||||||
const startLabel = 'Écouter ensemble';
|
const startLabel = 'Écouter ensemble';
|
||||||
const addLabel = 'Ajouter à la file';
|
const addLabel = 'Ajouter à la file';
|
||||||
|
const startAlbumLabel = 'Écouter cet album';
|
||||||
|
const addAlbumLabel = "Ajouter l'album à la file";
|
||||||
|
|
||||||
listEl.innerHTML = tracks.map((t) => `
|
listEl.innerHTML = tracks.map((t) => `
|
||||||
<div class="trackRow">
|
<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
|
// Re-labels every track button (and the whole-album one) from current
|
||||||
// just clicked - starting a party from track 3 means tracks 1, 2 and 4
|
// state, not just the one just clicked - starting a party from track 3
|
||||||
// can now also be queued instead of starting a party of their own.
|
// means tracks 1, 2 and 4 can now also be queued instead of starting a
|
||||||
|
// party of their own.
|
||||||
function refreshLabels(): void {
|
function refreshLabels(): void {
|
||||||
|
const active = getActiveMusicPartyId();
|
||||||
listEl.querySelectorAll<HTMLButtonElement>('.startPartyBtn').forEach((b) => {
|
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) => {
|
listEl.querySelectorAll<HTMLButtonElement>('.startPartyBtn').forEach((btn, i) => {
|
||||||
const track = tracks[i] as ItemDto;
|
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');
|
wireSearchFilter(root.querySelector('#searchInput') as HTMLInputElement, root, '.trackRow');
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,8 @@
|
||||||
<button class="linklike" id="back">← Retour</button>
|
<button class="linklike" id="back">← 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..." />
|
<input id="searchInput" class="searchBar" type="search" placeholder="Rechercher un album..." />
|
||||||
<p class="hint searchEmpty hide">Aucun résultat.</p>
|
<p class="hint searchEmpty hide">Aucun résultat.</p>
|
||||||
<div id="grid" class="grid"></div>
|
<div id="grid" class="grid"></div>
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,7 @@
|
||||||
import html from './Artist.html';
|
import html from './Artist.html';
|
||||||
import { getArtistAlbums, getItem } from '../../api/embyApi';
|
import { getArtistAlbums, getItem } from '../../api/embyApi';
|
||||||
|
import { createParty, queueItem } from '../../api/party';
|
||||||
|
import { getActiveMusicPartyId, joinMusicParty } from '../../musicBar/MusicBar';
|
||||||
import type { RouteParams } from '../../router';
|
import type { RouteParams } from '../../router';
|
||||||
import { tileHtml, wireSearchFilter } from '../../tiles';
|
import { tileHtml, wireSearchFilter } from '../../tiles';
|
||||||
import { LOADING_HINT } from '../../loading';
|
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
|
* 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
|
* "Voir les albums" link (tiles.ts's DRILL_DOWN table). Structurally the
|
||||||
* same "detail header + grid" shape as views/series, just one level up
|
* same "detail header + grid" shape as views/series, just one level up
|
||||||
* the music hierarchy (Artist -> Album, not Series -> Season/Episode) and
|
* the music hierarchy (Artist -> Album, not Series -> Season/Episode).
|
||||||
* with no per-item action beyond drilling further into an album (an
|
* #launchBtn is the one action available at this level itself (every
|
||||||
* artist has nothing itself to start a party from). */
|
* 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> {
|
export async function renderArtist(root: HTMLElement, params: RouteParams): Promise<void> {
|
||||||
const artistId = params.id as string;
|
const artistId = params.id as string;
|
||||||
root.innerHTML = LOADING_HINT;
|
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('h1')!.textContent = artist.name;
|
||||||
root.querySelector('#back')!.addEventListener('click', () => history.back());
|
root.querySelector('#back')!.addEventListener('click', () => history.back());
|
||||||
|
|
||||||
|
const launchBtn = root.querySelector('#launchBtn') as HTMLButtonElement;
|
||||||
const grid = root.querySelector('#grid')!;
|
const grid = root.querySelector('#grid')!;
|
||||||
if (!albums.length) {
|
if (!albums.length) {
|
||||||
grid.innerHTML = '<p class="hint">Aucun album trouvé pour cet artiste.</p>';
|
grid.innerHTML = '<p class="hint">Aucun album trouvé pour cet artiste.</p>';
|
||||||
root.querySelector('#searchInput')!.remove();
|
root.querySelector('#searchInput')!.remove();
|
||||||
|
launchBtn.disabled = true;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
grid.innerHTML = albums.map(tileHtml).join('');
|
grid.innerHTML = albums.map(tileHtml).join('');
|
||||||
wireSearchFilter(root.querySelector('#searchInput') as HTMLInputElement, root, '.tile');
|
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();
|
||||||
|
}
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -3343,6 +3343,82 @@ Authorization` Device/Client/Version header a real client would, only
|
||||||
`X-Emby-Token` - is genuinely unconfirmed. Worth watching the real
|
`X-Emby-Token` - is genuinely unconfirmed. Worth watching the real
|
||||||
dashboard the first time a party runs after this ships.
|
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
|
## Known limitations
|
||||||
|
|
||||||
- Playback always goes through `hls.js` against whatever Emby's
|
- Playback always goes through `hls.js` against whatever Emby's
|
||||||
|
|
|
||||||
|
|
@ -17,7 +17,7 @@ import { PlaystateService } from './services/PlaystateService';
|
||||||
import { EmbySessionService } from './services/EmbySessionService';
|
import { EmbySessionService } from './services/EmbySessionService';
|
||||||
import { CastTokenService } from './services/CastTokenService';
|
import { CastTokenService } from './services/CastTokenService';
|
||||||
import { SocketGateway } from './realtime/SocketGateway';
|
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();
|
const app = express();
|
||||||
app.set('trust proxy', 1); // behind openresty/nginx - needed for req.secure to reflect the real scheme
|
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;
|
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';
|
const mediaType: MediaType = item.Type === 'Audio' ? 'music' : 'video';
|
||||||
|
|
||||||
// Starting a party from an episode queues the rest of the series from
|
// 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;
|
const items = startIndex > 0 ? allItems.slice(startIndex) : allItems;
|
||||||
|
|
||||||
if (mediaType === 'music') {
|
if (mediaType === 'music') {
|
||||||
const first = items[0]!;
|
const trackInputs: MusicTrackInput[] = items.map((track) => ({
|
||||||
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,
|
itemId: track.Id,
|
||||||
itemName: track.Name,
|
itemName: track.Name,
|
||||||
artistName: track.AlbumArtist || track.Artists?.[0] || null,
|
artistName: track.AlbumArtist || track.Artists?.[0] || null,
|
||||||
albumName: track.Album || null,
|
albumName: track.Album || null,
|
||||||
itemImageUrl: imageUrlFor(track),
|
itemImageUrl: imageUrlFor(track),
|
||||||
durationTicks: track.RunTimeTicks || null
|
durationTicks: track.RunTimeTicks || null
|
||||||
};
|
}));
|
||||||
if (i === 0) {
|
createMusicPartyFromTracks(res, session, trackInputs);
|
||||||
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));
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -477,6 +471,73 @@ async function createPartyFromPlaylist(res: Response, session: EmbySession, play
|
||||||
res.json(PartyService.toPublicState(party, session.userId));
|
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
|
// 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
|
// 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
|
// 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));
|
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) => {
|
app.post('/api/parties/:partyId/join', requireSession, (req, res) => {
|
||||||
const party = PartyService.getById(param(req, 'partyId'));
|
const party = PartyService.getById(param(req, 'partyId'));
|
||||||
if (!party) {
|
if (!party) {
|
||||||
|
|
|
||||||
|
|
@ -52,7 +52,42 @@ class MusicQueueServiceClass {
|
||||||
|
|
||||||
addTrack(party: Party, track: MusicTrackInput, addedByUserId: string, addedByUserName: string): void {
|
addTrack(party: Party, track: MusicTrackInput, addedByUserId: string, addedByUserName: string): void {
|
||||||
const music = party.music!;
|
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,
|
itemId: track.itemId,
|
||||||
itemName: track.itemName,
|
itemName: track.itemName,
|
||||||
artistName: track.artistName || null,
|
artistName: track.artistName || null,
|
||||||
|
|
@ -64,13 +99,6 @@ class MusicQueueServiceClass {
|
||||||
addedByUserId,
|
addedByUserId,
|
||||||
addedByUserName
|
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). */
|
/** No host check - anyone can toggle this (see class comment). */
|
||||||
|
|
|
||||||
|
|
@ -77,6 +77,60 @@ describe('MusicQueueService', () => {
|
||||||
assert.equal(music(party).queue.length, 1);
|
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', () => {
|
it('voteSkip needs a strict majority of connected members, not any count', () => {
|
||||||
const party = fakeParty('p3');
|
const party = fakeParty('p3');
|
||||||
MusicQueueService.init(party, { itemId: 't1', itemName: 'Track One', itemImageUrl: '/img/t1', durationTicks: 10_000_000 }, 'u-alice', 'Alice');
|
MusicQueueService.init(party, { itemId: 't1', itemName: 'Track One', itemImageUrl: '/img/t1', durationTicks: 10_000_000 }, 'u-alice', 'Alice');
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue