Merge remote-tracking branch 'origin/master' into feature/picture-in-picture
# Conflicts: # README.md # docs/architecture.md
This commit is contained in:
commit
dfd109fc50
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
|
||||
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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,5 +1,8 @@
|
|||
<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..." />
|
||||
<p class="hint searchEmpty hide">Aucun résultat.</p>
|
||||
<div id="grid" class="grid"></div>
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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). */
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
|
|
|
|||
Loading…
Reference in a new issue