Add music: Artist/Album/Track browsing + participative listen-party queue
All checks were successful
Publier l'image Docker / publish (push) Successful in 7s

The last deferred piece from the original spec. Chosen permission model
(asked, not assumed): anyone can add a track to the shared queue any time;
anyone can pause/resume directly; skipping the current track needs a
strict majority vote of currently connected members. No host for
playback - a genuinely different shape of shared state than video's "host
drives, everyone follows", which is why it's a separate service
(server/src/musicQueueService.js, MusicQueueService) rather than more
branches in partyManager.js/socketHandler.js's existing video-shaped logic.

Server:
- musicQueueService.js: owns a music party's queue/current-track/pause/
  vote-skip state. Server-authoritative timing (no client is "the host"
  reporting position) - a wall-clock startedUtc plus the track's known
  duration is the only state; auto-advance is one setTimeout scheduled
  for exactly when that runs out, not any client reporting "it ended"
  (which could race across several players finishing near-simultaneously
  with no host to arbitrate). attachIo(io, onTrackChanged), called once
  at startup like PartyManager's own wiring, lets it broadcast
  "queueUpdated" from inside that timer's callback, which has no
  socket/request in scope at all.
- The timer is the one new leak surface this adds, given how much this
  project has already had to fix real leaks - destroy(party) clears it,
  wired into both of partyManager.js's existing destruction paths
  (leave's last-member case, removeIdleParties' sweep).
- Party gained mediaType: 'video'|'music' (inferred server-side from the
  clicked item's Emby Type - the client never has to know or say which).
- Three new catalog endpoints (Artists -> Albums -> Tracks), same
  reasoning as the existing Shows -> Seasons -> Episodes: a real Emby
  endpoint per level, not generic recursive /Items guessing.
- Audio streams through the *same* /master.m3u8 route as video (Emby's
  PlaybackInfo negotiation isn't video-specific) - the one difference is
  the hand-built-fallback path prefix (/Audio/ vs /Videos/), which the
  client tags via a mediaType=audio query hint rather than costing an
  extra Emby round trip to detect server-side.
- Adding a track is REST (POST /api/parties/:partyId/tracks), not a
  socket event like voteSkip/musicPlaystate - it needs to work from the
  player's inline browse-and-add panel regardless of realtime-connection
  specifics, and MusicQueueService's own broadcast reaches everyone
  connected either way.

Client:
- player.js's renderPartyPlayer is now a small dispatcher: fetches the
  party once, then delegates to renderVideoPlayer or the new
  renderMusicPlayer (views/musicPlayer.js) based on mediaType. Can't be
  async despite awaiting that fetch - route()'s contract needs its
  cleanup function back synchronously (router.js's `typeof cleanup ===
  'function'` check would silently skip a Promise, reintroducing the
  exact leak class already fixed once) - solved with a mutable box
  returned immediately, filled in once the fetch resolves.
- musicPlayer.js: not full-viewport/theater-mode like the video player
  (no image to declutter around, sidebar stays up) - which is what makes
  its own inline Artist -> Album -> Track browser work for "add tracks
  while listening" without navigating away and losing player state or
  threading party context through query params across three route levels.
- New views/artist.js, views/album.js (library.js's music branch renders
  an artist grid instead of the old placeholder). tiles.js's DRILL_DOWN
  table generalizes the old single seriesHref special-case now that
  there are three drill-down targets, not two.
- party.js: addTrackToQueue (REST) alongside voteSkip/setMusicPaused
  (socket) on the existing connectParty() connection object.

Real bug found while testing, not just the mock's fault: applyMusicState
used to update the queue list/vote button *after* the current-track
branch, which calls loadTrack() - and loadTrack()'s hls.js setup can
throw. Since JS doesn't roll back earlier statements when a later one
throws but does abort everything after it, an exception there took the
unrelated queue list and vote tally down with it, and on the first render
escaped all the way to the dispatcher's catch, which alerts and bounces
back to /home - discarding the whole view over what's really just "this
one track's audio failed to load". Fixed by reordering (queue/vote/icon
updates - which can't fail - before the track-loading branch, mirroring
how player.js's onItemChanged already updates title/next-button before
calling loadVideo) and wrapping loadTrack's hls.js setup in its own
try/catch so a failure there logs and stops instead of propagating.

Verified server-side directly against the real shipped modules (not
reimplemented mocks): 18 checks against musicQueueService.js alone (auto
-start, queueing, vote-skip majority, pause/resume elapsed-time math, a
real setTimeout auto-advance, timer cleanup on destroy - instrumented
setTimeout/clearTimeout counting, same technique as the earlier client
-side leak tests) and 7 more through socketHandler.js + partyManager.js
together (no-ops on a video party, a non-host pausing/resuming, a
two-vote majority skip, the resulting broadcast really reaching the
room). Client-side against the real local server with mocked fetch/Hls/
io: the full browsing chain, party creation, the dispatcher choosing the
right player (and still choosing video when mediaType is absent - back
-compat), now-playing/vote-tally/queue rendering and updates including a
live track change, play/pause and vote-skip emitting the right events,
the inline browse-and-add panel POSTing the right payload, and the crash
fix re-confirmed fixed via the same broken scenario that first found it.

Not verified: the real Emby round trip for any of this (Artists/albums
-tracks response shapes, audio PlaybackInfo/TranscodingUrl behavior,
actual playback quality) - same limit as everything else in this project
tested without live Emby access.

docs/architecture.md has the full writeup.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Nicolas 2026-09-14 01:49:22 +02:00
parent 841975e408
commit efaa7f55f0
17 changed files with 1379 additions and 106 deletions

View file

@ -31,25 +31,32 @@ including why this replaced an earlier Emby-plugin attempt (still in
initials, on the player itself). If you're not the host, clicking your initials, on the player itself). If you're not the host, clicking your
own avatar asks the host to pause — everyone sees the request, including own avatar asks the host to pause — everyone sees the request, including
the host, without needing chat. the host, without needing chat.
6. Music works differently, on purpose: pick a track (browsing Artist →
Album → Track) to start a listening party. There's no host for
playback — anyone can add more tracks to the shared queue at any time,
anyone can pause/resume, and skipping the current track needs a
majority vote from whoever's currently listening. The player has its
own inline "Ajouter des morceaux" browser, so building the queue
doesn't mean leaving the page.
Everything — catalog, images, video, sync — flows through this app's own Everything — catalog, images, video, audio, sync — flows through this
backend, which proxies whatever it needs from Emby server-side. The browser app's own backend, which proxies whatever it needs from Emby server-side.
never talks to Emby directly and never holds an Emby access token; it only The browser never talks to Emby directly and never holds an Emby access
ever has a session cookie for this app. token; it only ever has a session cookie for this app.
Music isn't part of this yet — it needs its own player and a collaborative
playlist, different enough to be its own pass. The sidebar already lists a
Music library if you have one; the page just says so for now.
## Status ## Status
Login, and the rendering/navigation logic for the sidebar/home/library/series Login, and the rendering/navigation logic for every page (sidebar, home,
pages, have been exercised end to end (login against the real Emby server library, series, artist/album, both players), have been exercised end to
with intentionally-wrong credentials; the pages against mocked API end (login against the real Emby server with intentionally-wrong
responses). See [docs/architecture.md](docs/architecture.md#assumptions-to-verify) credentials; the pages against mocked API responses; the music queue's
for exactly what that does and doesn't confirm — the real Emby response timer/vote/pause logic against the real server modules directly - no Emby
shapes this now depends on (library `CollectionType`, seasons/episodes, involved there at all). See
`PlaybackInfo`'s media streams) and actual HLS playback are still unverified. [docs/architecture.md](docs/architecture.md#assumptions-to-verify) for
exactly what that does and doesn't confirm — the real Emby response shapes
this now depends on (library `CollectionType`, seasons/episodes, artists/
albums/tracks, `PlaybackInfo`'s media streams) and actual HLS playback
(video or audio) are still unverified.
## Project layout ## Project layout
@ -62,6 +69,7 @@ server/ Node.js backend: proxies ALL Emby access (auth, catalog,
src/embyProxy.js generic Emby request/response proxying (incl. HLS) src/embyProxy.js generic Emby request/response proxying (incl. HLS)
src/socketHandler.js Socket.IO protocol (see comments in the file) src/socketHandler.js Socket.IO protocol (see comments in the file)
src/partyManager.js in-memory room state src/partyManager.js in-memory room state
src/musicQueueService.js participative music queue - see docs/architecture.md
src/logger.js party lifecycle logging (docker logs/journalctl-friendly) src/logger.js party lifecycle logging (docker logs/journalctl-friendly)
client/ Static frontend (plain JS, ES modules, no build step). client/ Static frontend (plain JS, ES modules, no build step).
@ -70,7 +78,8 @@ client/ Static frontend (plain JS, ES modules, no build step).
js/tiles.js poster-tile rendering shared by home.js and library.js js/tiles.js poster-tile rendering shared by home.js and library.js
js/embyApi.js calls our own /api/* routes (auth, catalog, playback options) js/embyApi.js calls our own /api/* routes (auth, catalog, playback options)
js/party.js our own backend's party REST + Socket.IO client js/party.js our own backend's party REST + Socket.IO client
js/views/ one file per screen: login, home, library, series, player js/views/ one file per screen: login, home, library, series, player,
artist, album, musicPlayer
src/WatchParty/ The earlier Emby-plugin attempt. Builds fine and installs, src/WatchParty/ The earlier Emby-plugin attempt. Builds fine and installs,
but Emby has no supported extension point for a player but Emby has no supported extension point for a player
@ -120,8 +129,9 @@ Then put it behind your existing reverse proxy like everything else
- [x] Presence overlay (avatars) + "request pause" in the player - [x] Presence overlay (avatars) + "request pause" in the player
- [x] Server-side logging of the party lifecycle (create/join/leave/destroy, - [x] Server-side logging of the party lifecycle (create/join/leave/destroy,
host handoff, play/pause, pause requests) — see docs/architecture.md host handoff, play/pause, pause requests) — see docs/architecture.md
- [x] Music: dedicated player, Artist/Album/Track browsing, participative
queue (free add, vote-to-skip, no host) — 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)
- [ ] Music: dedicated player, artist/album browsing, collaborative playlist
- [ ] Tune transcoding target / support direct play when possible - [ ] Tune transcoding target / support direct play when possible
No chat, no OIDC — deliberately out of scope (Emby-only auth was the No chat, no OIDC — deliberately out of scope (Emby-only auth was the

View file

@ -476,6 +476,117 @@ button[disabled] { opacity: .5; cursor: default; }
.episodeInfo { order: 3; flex-basis: 100%; } .episodeInfo { order: 3; flex-basis: 100%; }
} }
/* ---- Music: album page, track lists (shared with the music player's own
queue/browse panel), now-playing header ---- */
.albumHeader {
display: flex;
gap: 20px;
margin-bottom: 24px;
}
.albumCover {
width: 160px;
aspect-ratio: 1 / 1;
object-fit: cover;
border-radius: 8px;
background: var(--panel);
flex-shrink: 0;
}
.albumCover.noImage { visibility: hidden; }
.trackList {
display: flex;
flex-direction: column;
gap: 8px;
margin-bottom: 20px;
}
.trackRow {
display: grid;
grid-template-columns: 28px 1fr auto auto;
align-items: center;
gap: 12px;
background: var(--bg-header);
border-radius: 8px;
padding: 8px 12px;
}
.trackIndex { color: var(--muted); font-size: 13px; text-align: center; }
.trackName { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.trackDuration { color: var(--muted); font-size: 12px; font-variant-numeric: tabular-nums; }
.trackRow button { flex-shrink: 0; padding: 6px 10px; font-size: 12px; }
@media (max-width: 600px) {
.albumHeader { flex-direction: column; }
.albumCover { width: 120px; }
.trackRow { grid-template-columns: 24px 1fr; }
.trackRow .trackDuration, .trackRow button { grid-column: 2; justify-self: start; }
}
/* Music player's "now playing" header - see musicPlayer.js. Not inside a
.playerWrap like the video player: there's no video image to declutter
around, so this renders as a normal page (sidebar stays up) rather than
theater-mode full-viewport. */
.nowPlaying {
display: flex;
gap: 16px;
align-items: center;
background: var(--bg-header);
border-radius: 10px;
padding: 14px;
margin-bottom: 20px;
}
.nowPlayingImage {
width: 96px;
aspect-ratio: 1 / 1;
object-fit: cover;
border-radius: 8px;
background: var(--panel);
flex-shrink: 0;
}
.nowPlayingImage.noImage { visibility: hidden; }
.nowPlayingInfo { display: flex; flex-direction: column; gap: 4px; min-width: 0; }
.nowPlayingInfo strong { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.nowPlayingControls {
display: flex;
align-items: center;
gap: 12px;
margin-top: 6px;
}
.nowPlayingControls .iconBtn { font-size: 20px; }
/* The presence row reused from the video player (same .avatar component -
see player.js) but in normal page flow here, not absolutely positioned
over a video frame. */
.musicPresence { margin-bottom: 16px; }
.browsePanel {
background: var(--bg-header);
border-radius: 10px;
padding: 14px;
margin-top: 8px;
}
.browseList {
display: flex;
flex-direction: column;
gap: 2px;
}
.browseItem {
display: block;
width: 100%;
text-align: left;
padding: 8px 4px;
}
/* ---- Player ---- */ /* ---- Player ---- */
.playerWrap { .playerWrap {

View file

@ -5,6 +5,8 @@ import { renderLogin } from './views/login.js';
import { renderHome } from './views/home.js'; import { renderHome } from './views/home.js';
import { renderLibrary } from './views/library.js'; import { renderLibrary } from './views/library.js';
import { renderSeries } from './views/series.js'; import { renderSeries } from './views/series.js';
import { renderArtist } from './views/artist.js';
import { renderAlbum } from './views/album.js';
import { renderPartyPlayer } from './views/player.js'; import { renderPartyPlayer } from './views/player.js';
route('/', () => navigate('/home')); route('/', () => navigate('/home'));
@ -12,6 +14,8 @@ route('/login', renderLogin);
route('/home', renderHome); route('/home', renderHome);
route('/library/:id', renderLibrary); route('/library/:id', renderLibrary);
route('/series/:id', renderSeries); route('/series/:id', renderSeries);
route('/artist/:id', renderArtist);
route('/album/:id', renderAlbum);
route('/party/:id', renderPartyPlayer); route('/party/:id', renderPartyPlayer);
/** /**

View file

@ -83,14 +83,36 @@ export async function getSeriesEpisodes(seriesId, seasonId) {
return data.episodes || []; return data.episodes || [];
} }
export async function getArtists(libraryId) {
const data = await apiFetch(`/api/catalog/libraries/${libraryId}/artists`);
return data.artists || [];
}
export async function getArtistAlbums(artistId) {
const data = await apiFetch(`/api/catalog/artists/${artistId}/albums`);
return data.albums || [];
}
export async function getAlbumTracks(albumId) {
const data = await apiFetch(`/api/catalog/albums/${albumId}/tracks`);
return data.tracks || [];
}
export async function getPlaybackOptions(itemId) { export async function getPlaybackOptions(itemId) {
return apiFetch(`/api/catalog/item/${itemId}/playback-options`); return apiFetch(`/api/catalog/item/${itemId}/playback-options`);
} }
/** `options` may carry AudioStreamIndex/SubtitleStreamIndex/MaxStreamingBitrate. */ /** `options` may carry AudioStreamIndex/SubtitleStreamIndex/MaxStreamingBitrate/mediaType. */
export function streamUrl(itemId, options = {}) { export function streamUrl(itemId, options = {}) {
const qs = new URLSearchParams( const qs = new URLSearchParams(
Object.fromEntries(Object.entries(options).filter(([, v]) => v !== '' && v != null)) Object.fromEntries(Object.entries(options).filter(([, v]) => v !== '' && v != null))
).toString(); ).toString();
return `/api/catalog/stream/${itemId}/master.m3u8${qs ? '?' + qs : ''}`; return `/api/catalog/stream/${itemId}/master.m3u8${qs ? '?' + qs : ''}`;
} }
/** Same stream, tagged for a music track - see server/src/index.js's
* /master.m3u8 route for why mediaType is the one thing that matters here
* (its hand-built-fallback path is genuinely different for audio). */
export function audioStreamUrl(itemId) {
return streamUrl(itemId, { mediaType: 'audio' });
}

View file

@ -36,6 +36,19 @@ export function getParty(partyId) {
return apiFetch(`/api/parties/${partyId}`); return apiFetch(`/api/parties/${partyId}`);
} }
/**
* Music only: adds a track to a party's shared queue. REST, not a socket
* event, deliberately - unlike voteSkip/setMusicPaused below, this needs to
* work from the album-browsing page (album.js), which has no live
* connection to the party it might be adding into, only the player view
* (musicPlayer.js) does. `track` is whatever toItemDto shape the catalog
* endpoints already returned (itemId/itemName/artistName/albumName/
* itemImageUrl/durationTicks) - see server/src/musicQueueService.js.
*/
export function addTrackToQueue(partyId, track) {
return apiFetch(`/api/parties/${partyId}/tracks`, { method: 'POST', body: JSON.stringify(track) });
}
/** /**
* Live "watch parties en cours" feed for the home page. `onParties` gets * Live "watch parties en cours" feed for the home page. `onParties` gets
* called with the full current list on subscribe, and again every time any * called with the full current list on subscribe, and again every time any
@ -61,7 +74,7 @@ export function connectLobby(onParties) {
* keeping alive via closures, never gets released. * keeping alive via closures, never gets released.
*/ */
export function connectParty(partyId, { export function connectParty(partyId, {
onMembers, onPlaystate, onItemChanged, onHostChanged, onPauseRequested, onError, onClose onMembers, onPlaystate, onItemChanged, onHostChanged, onPauseRequested, onQueueUpdated, onError, onClose
} = {}) { } = {}) {
const socket = window.io(); const socket = window.io();
@ -77,6 +90,7 @@ export function connectParty(partyId, {
if (onItemChanged) socket.on('itemChanged', onItemChanged); if (onItemChanged) socket.on('itemChanged', onItemChanged);
if (onHostChanged) socket.on('hostChanged', onHostChanged); if (onHostChanged) socket.on('hostChanged', onHostChanged);
if (onPauseRequested) socket.on('pauseRequested', onPauseRequested); if (onPauseRequested) socket.on('pauseRequested', onPauseRequested);
if (onQueueUpdated) socket.on('queueUpdated', onQueueUpdated); // music parties only - see musicPlayer.js
if (onError) socket.on('errorMessage', (msg) => onError(msg.message)); if (onError) socket.on('errorMessage', (msg) => onError(msg.message));
if (onClose) socket.on('disconnect', onClose); if (onClose) socket.on('disconnect', onClose);
@ -88,10 +102,20 @@ export function connectParty(partyId, {
next() { next() {
socket.emit('next'); socket.emit('next');
}, },
/** Asks the host (and tells everyone else) "please pause". */ /** Asks the host (and tells everyone else) "please pause". Video only -
* a music party has no host to ask, see requestSkip/setMusicPaused. */
requestPause() { requestPause() {
socket.emit('requestPause'); socket.emit('requestPause');
}, },
/** Music only: vote to skip the current track - see musicQueueService.js
* on the server for the majority threshold. */
voteSkip() {
socket.emit('voteSkip');
},
/** Music only: no host check server-side, unlike sendPlaystate above. */
setMusicPaused(isPaused) {
socket.emit('musicPlaystate', { isPaused });
},
leave() { leave() {
socket.emit('leave'); socket.emit('leave');
socket.disconnect(); socket.disconnect();

View file

@ -7,15 +7,55 @@ export function escapeHtml(str) {
return div.innerHTML; return div.innerHTML;
} }
/** "m:ss" (or "h:mm:ss" past an hour) - shared by the album track list and
* the music player, so a track's listed duration and its playing position
* always read the same way. */
export function formatDuration(totalSeconds) {
const s = Math.max(0, Math.round(totalSeconds || 0));
const h = Math.floor(s / 3600);
const m = Math.floor((s % 3600) / 60);
const sec = s % 60;
const pad = (n) => String(n).padStart(2, '0');
return h > 0 ? `${h}:${pad(m)}:${pad(sec)}` : `${m}:${pad(sec)}`;
}
// Small deterministic palette so the same person always gets the same
// avatar color across a session, without needing the server to assign one
// - shared by the video player's presence overlay and the music player's
// listener list, so the same person reads as the same color in either.
const AVATAR_COLORS = ['#52b54b', '#4c8bf5', '#f5a623', '#e05d5d', '#9b59b6', '#1abc9c', '#e67e22', '#3498db'];
export function colorForName(name) {
let hash = 0;
for (let i = 0; i < name.length; i++) hash = (hash * 31 + name.charCodeAt(i)) >>> 0;
return AVATAR_COLORS[hash % AVATAR_COLORS.length];
}
export function initialsFor(name) {
return (name || '').trim().slice(0, 2).toUpperCase() || '?';
}
// Drill-down targets: a tile for a type with no direct playback (Series,
// MusicArtist, MusicAlbum) links into its own browsing page instead of
// getting a "start a party" button - canWatchParty is already false for
// all three server-side (see WATCH_PARTY_TYPES in server/src/index.js).
const DRILL_DOWN = {
Series: { route: 'series', label: 'Voir les épisodes' },
MusicArtist: { route: 'artist', label: 'Voir les albums' },
MusicAlbum: { route: 'album', label: 'Voir les morceaux' }
};
export function tileHtml(item) { export function tileHtml(item) {
const seriesHref = item.type === 'Series' ? `#/series/${item.id}` : null; const drill = DRILL_DOWN[item.type];
const drillHref = drill ? `#/${drill.route}/${item.id}` : null;
const partyLabel = item.type === 'Audio' ? 'Écouter ensemble' : 'Lancer une watch party';
return ` return `
<div class="tile"> <div class="tile">
${seriesHref ? `<a href="${seriesHref}" class="tileLink" aria-label="${escapeHtml(item.name)}"></a>` : ''} ${drillHref ? `<a href="${drillHref}" class="tileLink" aria-label="${escapeHtml(item.name)}"></a>` : ''}
<img src="${item.imageUrl}" alt="" loading="lazy" onerror="this.classList.add('noImage')" /> <img src="${item.imageUrl}" alt="" loading="lazy" onerror="this.classList.add('noImage')" />
<div class="tileOverlay"> <div class="tileOverlay">
${item.canWatchParty ? `<button class="tileBtn startPartyBtn" data-item-id="${item.id}">Lancer une watch party</button>` : ''} ${item.canWatchParty ? `<button class="tileBtn startPartyBtn" data-item-id="${item.id}">${partyLabel}</button>` : ''}
${seriesHref ? `<a class="tileBtn" href="${seriesHref}">Voir les épisodes</a>` : ''} ${drillHref ? `<a class="tileBtn" href="${drillHref}">${drill.label}</a>` : ''}
<a class="tileBtn tileBtnLink" href="${item.embyUrl}" target="_blank" rel="noopener">Voir sur Emby</a> <a class="tileBtn tileBtnLink" href="${item.embyUrl}" target="_blank" rel="noopener">Voir sur Emby</a>
</div> </div>
<span class="tileLabel">${escapeHtml(item.name)}${item.year ? ` (${item.year})` : ''}</span> <span class="tileLabel">${escapeHtml(item.name)}${item.year ? ` (${item.year})` : ''}</span>

68
client/js/views/album.js Normal file
View file

@ -0,0 +1,68 @@
import { getItem, getAlbumTracks } from '../embyApi.js';
import { createParty } from '../party.js';
import { navigate } from '../router.js';
import { escapeHtml, formatDuration } from '../tiles.js';
const TICKS_PER_SECOND = 10000000; // Emby/.NET convention: 1 tick = 100ns, same as everywhere else this app touches durations/positions
/** Route `/album/:id` - the last level of the music hierarchy: one album's
* tracks, each startable as its own listen party (mirrors series.js's
* episode list, but flat - an album has no further sub-level the way a
* series' seasons do). */
export async function renderAlbum(root, params) {
const albumId = params.id;
root.innerHTML = '<p class="hint">Chargement...</p>';
let album;
let tracks;
try {
[album, tracks] = await Promise.all([getItem(albumId), getAlbumTracks(albumId)]);
} catch (err) {
root.innerHTML = '<p class="error">Impossible de charger cet album.</p>';
return;
}
root.innerHTML = `
<button class="linklike" id="back">&larr; Retour</button>
<div class="albumHeader">
<img class="albumCover" src="${album.imageUrl}" alt="" loading="lazy" onerror="this.classList.add('noImage')" />
<div class="seriesInfo">
<h1>${escapeHtml(album.name)}${album.year ? ` <span class="seriesYear">(${album.year})</span>` : ''}</h1>
${album.artistName ? `<p class="hint">${escapeHtml(album.artistName)}</p>` : ''}
<a class="linklike" href="${album.embyUrl}" target="_blank" rel="noopener">Voir sur Emby</a>
</div>
</div>
<div id="trackList" class="trackList"></div>`;
root.querySelector('#back').addEventListener('click', () => history.back());
const listEl = root.querySelector('#trackList');
if (!tracks.length) {
listEl.innerHTML = '<p class="hint">Aucun morceau dans cet album.</p>';
return;
}
listEl.innerHTML = tracks.map((t) => `
<div class="trackRow">
<span class="trackIndex">${t.indexNumber != null ? t.indexNumber : ''}</span>
<span class="trackName">${escapeHtml(t.name)}</span>
<span class="trackDuration">${t.durationTicks ? formatDuration(t.durationTicks / TICKS_PER_SECOND) : ''}</span>
<button class="startPartyBtn" data-item-id="${t.id}">Écouter ensemble</button>
</div>
`).join('');
listEl.querySelectorAll('.startPartyBtn').forEach((btn) => {
btn.addEventListener('click', async () => {
btn.disabled = true;
btn.textContent = 'Démarrage...';
try {
const party = await createParty(btn.dataset.itemId);
navigate(`/party/${party.partyId}`);
} catch (err) {
alert(err.message);
btn.disabled = false;
btn.textContent = 'Écouter ensemble';
}
});
});
}

35
client/js/views/artist.js Normal file
View file

@ -0,0 +1,35 @@
import { getItem, getArtistAlbums } from '../embyApi.js';
import { tileHtml, escapeHtml } from '../tiles.js';
/** Route `/artist/:id` - one artist's albums, reached from a music
* library's top-level grid (see library.js) via a MusicArtist tile's
* "Voir les albums" link (tiles.js's DRILL_DOWN table). Structurally the
* same "detail header + grid" shape as series.js, 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). */
export async function renderArtist(root, params) {
const artistId = params.id;
root.innerHTML = '<p class="hint">Chargement...</p>';
let artist;
let albums;
try {
[artist, albums] = await Promise.all([getItem(artistId), getArtistAlbums(artistId)]);
} catch (err) {
root.innerHTML = '<p class="error">Impossible de charger cet artiste.</p>';
return;
}
root.innerHTML = `
<button class="linklike" id="back">&larr; Retour</button>
<div class="pageHeader"><h1>${escapeHtml(artist.name)}</h1></div>
<div id="grid" class="grid"></div>`;
root.querySelector('#back').addEventListener('click', () => history.back());
const grid = root.querySelector('#grid');
grid.innerHTML = albums.length
? albums.map(tileHtml).join('')
: '<p class="hint">Aucun album trouvé pour cet artiste.</p>';
}

View file

@ -109,8 +109,6 @@ async function loadRecentSections(root) {
} }
for (const lib of libraries) { for (const lib of libraries) {
if (lib.collectionType === 'music') continue; // music gets its own experience later, not a poster row
const section = document.createElement('section'); const section = document.createElement('section');
section.className = 'section'; section.className = 'section';
section.innerHTML = `<h2>Ajouts récents - ${escapeHtml(lib.name)}</h2><div class="carousel"><p class="hint">Chargement...</p></div>`; section.innerHTML = `<h2>Ajouts récents - ${escapeHtml(lib.name)}</h2><div class="carousel"><p class="hint">Chargement...</p></div>`;

View file

@ -1,4 +1,4 @@
import { getLibraries, getLibraryItems } from '../embyApi.js'; import { getLibraries, getLibraryItems, getArtists } from '../embyApi.js';
import { createParty } from '../party.js'; import { createParty } from '../party.js';
import { navigate } from '../router.js'; import { navigate } from '../router.js';
import { tileHtml, wireTileButtons, escapeHtml } from '../tiles.js'; import { tileHtml, wireTileButtons, escapeHtml } from '../tiles.js';
@ -6,12 +6,14 @@ import { tileHtml, wireTileButtons, escapeHtml } from '../tiles.js';
/** /**
* Generic "one library" page, reached from the sidebar. What it renders * Generic "one library" page, reached from the sidebar. What it renders
* depends entirely on the library's Emby CollectionType: * depends entirely on the library's Emby CollectionType:
* - movies/tvshows (or anything else playable): a tile grid, same as the * - movies/tvshows/musicvideos (or anything else playable): a tile grid,
* home page's carousels (Series tiles link into /series/:id instead of * same as the home page's carousels (Series/MusicArtist/MusicAlbum
* starting a party directly - see tiles.js). * tiles drill further in instead of starting a party directly - see
* - music: a placeholder for now - see docs/architecture.md, deliberately * tiles.js's DRILL_DOWN table).
* out of scope for this pass (needs its own player + collaborative * - music: a tile grid too, just of artists (Emby's dedicated /Artists
* playlist, not just a grid). * endpoint, not a generic library listing - see
* GET /api/catalog/libraries/:id/artists) - Album -> Tracks is one
* level further in (artist.js, then album.js).
*/ */
export async function renderLibrary(root, params) { export async function renderLibrary(root, params) {
root.innerHTML = '<p class="hint">Chargement...</p>'; root.innerHTML = '<p class="hint">Chargement...</p>';
@ -30,17 +32,6 @@ export async function renderLibrary(root, params) {
return; return;
} }
if (library.collectionType === 'music') {
root.innerHTML = `
<div class="pageHeader"><h1>${escapeHtml(library.name)}</h1></div>
<p class="hint">
La musique arrive dans une prochaine version : lecteur dédié, navigation
artiste / album, et une playlist partagée où chaque membre de la party
peut ajouter des morceaux. Pas encore disponible ici.
</p>`;
return;
}
root.innerHTML = ` root.innerHTML = `
<div class="pageHeader"><h1>${escapeHtml(library.name)}</h1></div> <div class="pageHeader"><h1>${escapeHtml(library.name)}</h1></div>
<div id="grid" class="grid"><p class="hint">Chargement...</p></div>`; <div id="grid" class="grid"><p class="hint">Chargement...</p></div>`;
@ -48,7 +39,9 @@ export async function renderLibrary(root, params) {
const grid = root.querySelector('#grid'); const grid = root.querySelector('#grid');
let items; let items;
try { try {
items = await getLibraryItems(library.id); items = library.collectionType === 'music'
? await getArtists(library.id)
: await getLibraryItems(library.id);
} catch (err) { } catch (err) {
grid.innerHTML = '<p class="error">Impossible de charger cette bibliothèque.</p>'; grid.innerHTML = '<p class="error">Impossible de charger cette bibliothèque.</p>';
return; return;

View file

@ -0,0 +1,338 @@
import { getLibraries, getArtists, getArtistAlbums, getAlbumTracks, audioStreamUrl } from '../embyApi.js';
import { leaveParty, connectParty, addTrackToQueue } from '../party.js';
import { navigate } from '../router.js';
import { escapeHtml, formatDuration, colorForName, initialsFor } from '../tiles.js';
/**
* Music half of the party route (see player.js's dispatcher). Deliberately
* NOT full-viewport/theater-mode like the video player: there's no image
* to declutter around, so the sidebar stays up and this renders as a
* normal page - which also means "browse and add a track" can happen
* inline, right here, instead of navigating away and losing player state.
*
* Permission model (chosen, not assumed - see session history): anyone can
* add a track to the shared queue at any time; anyone can pause/resume;
* skipping the current track needs a strict majority vote of currently
* connected members. No host-only controls at all - see
* server/src/musicQueueService.js for the full reasoning and the
* server-authoritative auto-advance timer that drives this without any
* single client having to be "in charge".
*/
export function renderMusicPlayer(root, partyId, party) {
root.innerHTML = `
<div class="pageHeader">
<h1>Écoute partagée</h1>
<button class="linklike" id="leaveBtn">Quitter</button>
</div>
<div id="presenceOverlay" class="presenceOverlay musicPresence"></div>
<div class="nowPlaying" id="nowPlaying">
<img id="trackImage" class="nowPlayingImage" alt="" onerror="this.classList.add('noImage')" />
<div class="nowPlayingInfo">
<strong id="trackName">-</strong>
<span class="hint" id="trackArtist"></span>
<div class="nowPlayingControls">
<button class="iconBtn" id="playPauseBtn" title="Lecture / pause" aria-label="Lecture / pause">&#9654;</button>
<span id="trackPosition" class="hint">0:00 / 0:00</span>
<button class="linklike" id="voteSkipBtn">Passer (vote)</button>
</div>
</div>
</div>
<audio id="audio"></audio>
<div class="pageHeader">
<h2>File d'attente</h2>
<button class="linklike" id="toggleBrowseBtn">Ajouter des morceaux</button>
</div>
<div id="queueList" class="trackList"><p class="hint">Rien en attente.</p></div>
<div id="browsePanel" class="browsePanel hide"></div>`;
const audio = root.querySelector('#audio');
const presenceOverlay = root.querySelector('#presenceOverlay');
const trackImage = root.querySelector('#trackImage');
const trackName = root.querySelector('#trackName');
const trackArtist = root.querySelector('#trackArtist');
const playPauseBtn = root.querySelector('#playPauseBtn');
const trackPosition = root.querySelector('#trackPosition');
const voteSkipBtn = root.querySelector('#voteSkipBtn');
const queueList = root.querySelector('#queueList');
const browsePanel = root.querySelector('#browsePanel');
let conn = null;
let hls = null;
let currentItemId = null;
let currentDurationSeconds = 0;
let suppressLocalEvents = false; // avoid feeding our own correction back as a new event - same pattern as player.js
let positionTicker = null;
let latestMembers = [];
// Same HLS pipeline as the video player (player.js's loadVideo) - Emby's
// PlaybackInfo negotiation isn't video-specific, so its TranscodingUrl
// for a music track just as plausibly comes back HLS-shaped, and most
// browsers (everything but Safari) have no native .m3u8 support at all -
// an <audio src="...m3u8"> alone would silently just not play anywhere
// but Safari. `elapsedSeconds`/`isPaused` are applied once the manifest
// is actually parsed, not immediately after setting src - setting
// currentTime before a fresh source has loaded metadata is unreliable
// and gets silently reset by most browsers.
function loadTrack(track, elapsedSeconds, isPaused) {
currentItemId = track.itemId;
currentDurationSeconds = track.durationSeconds || 0;
trackImage.src = track.itemImageUrl || '';
trackImage.classList.remove('noImage');
trackName.textContent = track.itemName || '';
trackArtist.textContent = [track.artistName, track.albumName].filter(Boolean).join(' - ');
if (hls) {
hls.destroy();
hls = null;
}
const src = audioStreamUrl(track.itemId);
const resume = () => {
suppressLocalEvents = true;
audio.currentTime = elapsedSeconds || 0;
if (!isPaused) audio.play().catch(() => {});
setTimeout(() => { suppressLocalEvents = false; }, 250);
};
// Caught, not left to propagate: this runs from inside applyMusicState,
// which every "queueUpdated" push calls - an uncaught throw here would
// abort that entire handler (and, the first time it happens, the
// player's whole synchronous init - see player.js's dispatcher), taking
// the queue list and vote button down with a problem that's really
// just "this one track's audio failed to load". A skip vote is still
// this feature's actual recovery path (see musicQueueService.js) - this
// just needs to not crash on the way there.
try {
if (window.Hls && window.Hls.isSupported()) {
hls = new window.Hls();
hls.on(window.Hls.Events.MANIFEST_PARSED, resume);
hls.on(window.Hls.Events.ERROR, (event, data) => {
if (data.fatal) console.error('[watchparty musicPlayer] HLS error', data);
});
hls.loadSource(src);
hls.attachMedia(audio);
} else if (audio.canPlayType('application/vnd.apple.mpegurl')) {
audio.src = src;
audio.addEventListener('loadedmetadata', resume, { once: true });
} else {
console.error('[watchparty musicPlayer] no HLS support and hls.js unavailable');
}
} catch (err) {
console.error('[watchparty musicPlayer] failed to load track audio', err);
}
}
function applyMusicState(music) {
// Queue/vote-button/play-icon updates happen first, unconditionally -
// deliberately BEFORE the track-loading branch below, which touches
// hls.js and could throw (a bad manifest, a network hiccup). Same
// ordering the video player already uses (see onItemChanged in
// player.js, which updates the title/next-button before calling
// loadVideo): a media-loading failure should never also take the
// queue list and vote tally down with it, since they're not actually
// related to whether THIS particular track's audio loaded.
playPauseBtn.textContent = music.current && !music.current.isPaused ? '⏸' : '▶';
playPauseBtn.disabled = !music.current;
voteSkipBtn.disabled = !music.current;
// "needed" mirrors the exact math musicQueueService.js uses server-side
// (strict majority of currently connected members) - computed here from
// the member list the "members" event already provides, rather than
// MusicQueueService's own broadcast repeating that count on every
// single queue change (see its getState()'s own comment on this).
const connectedCount = latestMembers.filter((m) => m.connected).length || 1;
const needed = Math.floor(connectedCount / 2) + 1;
voteSkipBtn.textContent = music.current ? `Passer (vote) - ${music.skipVotes}/${needed}` : 'Passer (vote)';
queueList.innerHTML = music.queue.length
? music.queue.map((t) => `
<div class="trackRow">
<span class="trackName">${escapeHtml(t.itemName)}</span>
<span class="hint">${escapeHtml([t.artistName, t.albumName].filter(Boolean).join(' - '))}</span>
<span class="trackDuration">${t.durationSeconds ? formatDuration(t.durationSeconds) : ''}</span>
<span class="hint">Ajouté par ${escapeHtml(t.addedByUserName)}</span>
</div>`).join('')
: '<p class="hint">Rien en attente.</p>';
if (!music.current) {
currentItemId = null;
trackName.textContent = 'Rien ne joue pour le moment.';
trackArtist.textContent = '';
trackImage.src = '';
if (hls) { hls.destroy(); hls = null; }
audio.pause();
audio.removeAttribute('src');
} else if (music.current.itemId !== currentItemId) {
loadTrack(music.current, music.current.elapsedSeconds, music.current.isPaused);
} else {
// Same track still playing - just resync pause state/position, don't reload the source.
suppressLocalEvents = true;
if (Math.abs(audio.currentTime - music.current.elapsedSeconds) > 2) {
audio.currentTime = music.current.elapsedSeconds;
}
if (music.current.isPaused && !audio.paused) audio.pause();
if (!music.current.isPaused && audio.paused) audio.play().catch(() => {});
setTimeout(() => { suppressLocalEvents = false; }, 250);
}
}
function sendLocalPlaystate() {
if (suppressLocalEvents || !conn) return;
conn.setMusicPaused(audio.paused);
}
audio.addEventListener('play', sendLocalPlaystate);
audio.addEventListener('pause', sendLocalPlaystate);
playPauseBtn.addEventListener('click', () => {
if (!conn) return;
conn.setMusicPaused(!audio.paused);
});
voteSkipBtn.addEventListener('click', () => { if (conn) conn.voteSkip(); });
// Ticks the visible position label between server updates - the same
// "derive it client-side from a snapshot" approach as the home page's
// party-elapsed-time labels, rather than the server pushing this every
// second for every listener.
positionTicker = setInterval(() => {
if (!currentItemId) return;
trackPosition.textContent = `${formatDuration(audio.currentTime)} / ${formatDuration(currentDurationSeconds)}`;
}, 500);
function renderMembers(members) {
latestMembers = members;
presenceOverlay.innerHTML = members.map((m) => `
<div class="avatar" style="background:${colorForName(m.userName)}" title="${escapeHtml(m.userName)}">
${escapeHtml(initialsFor(m.userName))}
</div>`).join('');
}
// --- Inline "add tracks" browser: artist -> album -> tracks, entirely
// within this page (see class comment for why not a route navigation). ---
let browsing = false;
root.querySelector('#toggleBrowseBtn').addEventListener('click', () => {
browsing = !browsing;
browsePanel.classList.toggle('hide', !browsing);
if (browsing) loadMusicLibraries();
});
async function loadMusicLibraries() {
browsePanel.innerHTML = '<p class="hint">Chargement...</p>';
try {
const libraries = (await getLibraries()).filter((l) => l.collectionType === 'music');
if (!libraries.length) {
browsePanel.innerHTML = '<p class="hint">Aucune bibliothèque musicale.</p>';
return;
}
// Almost always exactly one - skip straight to its artists rather
// than making the common case click through a one-item list.
if (libraries.length === 1) {
loadArtists(libraries[0].id);
} else {
browsePanel.innerHTML = renderBrowseList(libraries.map((l) => ({ id: l.id, name: l.name })), null);
wireBrowseList((id) => loadArtists(id));
}
} catch (err) {
browsePanel.innerHTML = '<p class="error">Impossible de charger les bibliothèques.</p>';
}
}
async function loadArtists(libraryId) {
browsePanel.innerHTML = '<p class="hint">Chargement...</p>';
try {
const artists = await getArtists(libraryId);
browsePanel.innerHTML = renderBrowseList(artists.map((a) => ({ id: a.id, name: a.name })), null);
wireBrowseList((id) => loadAlbums(id));
} catch (err) {
browsePanel.innerHTML = '<p class="error">Impossible de charger les artistes.</p>';
}
}
async function loadAlbums(artistId) {
browsePanel.innerHTML = '<p class="hint">Chargement...</p>';
try {
const albums = await getArtistAlbums(artistId);
browsePanel.innerHTML = renderBrowseList(albums.map((a) => ({ id: a.id, name: a.year ? `${a.name} (${a.year})` : a.name })), () => loadMusicLibraries());
wireBrowseList((id) => loadTracks(id));
} catch (err) {
browsePanel.innerHTML = '<p class="error">Impossible de charger les albums.</p>';
}
}
async function loadTracks(albumId) {
browsePanel.innerHTML = '<p class="hint">Chargement...</p>';
try {
const tracks = await getAlbumTracks(albumId);
browsePanel.innerHTML = `
<button class="linklike browseBack">&larr; Retour</button>
${tracks.map((t) => `
<div class="trackRow">
<span class="trackIndex">${t.indexNumber != null ? t.indexNumber : ''}</span>
<span class="trackName">${escapeHtml(t.name)}</span>
<span class="trackDuration">${t.durationTicks ? formatDuration(t.durationTicks / 10000000) : ''}</span>
<button class="addTrackBtn" data-track="${encodeURIComponent(JSON.stringify({
itemId: t.id, itemName: t.name, artistName: t.artistName, albumName: t.albumName,
itemImageUrl: t.imageUrl, durationTicks: t.durationTicks
}))}">Ajouter</button>
</div>`).join('')}`;
browsePanel.querySelector('.browseBack').addEventListener('click', () => loadMusicLibraries());
browsePanel.querySelectorAll('.addTrackBtn').forEach((btn) => {
btn.addEventListener('click', async () => {
btn.disabled = true;
const originalLabel = btn.textContent;
try {
await addTrackToQueue(partyId, JSON.parse(decodeURIComponent(btn.dataset.track)));
btn.textContent = 'Ajouté !';
setTimeout(() => { btn.textContent = originalLabel; btn.disabled = false; }, 1500);
} catch (err) {
alert(err.message);
btn.disabled = false;
}
});
});
} catch (err) {
browsePanel.innerHTML = '<p class="error">Impossible de charger les morceaux.</p>';
}
}
function renderBrowseList(items, onBack) {
const back = onBack ? '<button class="linklike browseBack">&larr; Retour</button>' : '';
const body = items.length
? items.map((i) => `<button class="linklike browseItem" data-id="${escapeHtml(i.id)}">${escapeHtml(i.name)}</button>`).join('')
: '<p class="hint">Rien à afficher.</p>';
return `${back}<div class="browseList">${body}</div>`;
}
function wireBrowseList(onPick) {
const back = browsePanel.querySelector('.browseBack');
if (back) back.addEventListener('click', () => loadMusicLibraries());
browsePanel.querySelectorAll('.browseItem').forEach((btn) => {
btn.addEventListener('click', () => onPick(btn.dataset.id));
});
}
// --- Init ---
applyMusicState(party.music);
conn = connectParty(partyId, {
onMembers: renderMembers,
onQueueUpdated: applyMusicState,
onError: (message) => alert(message),
onClose: () => {}
});
root.querySelector('#leaveBtn').addEventListener('click', () => navigate('/home'));
return function cleanup() {
if (positionTicker) clearInterval(positionTicker);
if (hls) hls.destroy();
audio.pause();
audio.removeAttribute('src');
if (conn) conn.leave();
leaveParty(partyId).catch(() => {});
};
}

View file

@ -1,7 +1,8 @@
import { streamUrl, getPlaybackOptions, auth } from '../embyApi.js'; import { streamUrl, getPlaybackOptions, auth } from '../embyApi.js';
import { getParty, leaveParty, connectParty } from '../party.js'; import { getParty, leaveParty, connectParty } from '../party.js';
import { navigate } from '../router.js'; import { navigate } from '../router.js';
import { escapeHtml } from '../tiles.js'; import { escapeHtml, colorForName, initialsFor } from '../tiles.js';
import { renderMusicPlayer } from './musicPlayer.js';
const TICKS_PER_SECOND = 10000000; // Emby/.NET convention: 1 tick = 100ns const TICKS_PER_SECOND = 10000000; // Emby/.NET convention: 1 tick = 100ns
const DRIFT_TOLERANCE_SECONDS = 1.5; const DRIFT_TOLERANCE_SECONDS = 1.5;
@ -18,20 +19,6 @@ const QUALITY_PRESETS = [
{ label: '480p (~1.5 Mbps)', value: '1500000' } { label: '480p (~1.5 Mbps)', value: '1500000' }
]; ];
// Small deterministic palette so the same person always gets the same
// avatar color across a session, without needing the server to assign one.
const AVATAR_COLORS = ['#52b54b', '#4c8bf5', '#f5a623', '#e05d5d', '#9b59b6', '#1abc9c', '#e67e22', '#3498db'];
function colorForName(name) {
let hash = 0;
for (let i = 0; i < name.length; i++) hash = (hash * 31 + name.charCodeAt(i)) >>> 0;
return AVATAR_COLORS[hash % AVATAR_COLORS.length];
}
function initialsFor(name) {
return (name || '').trim().slice(0, 2).toUpperCase() || '?';
}
// Plain inline SVG rather than a Unicode glyph (unlike the settings gear) // Plain inline SVG rather than a Unicode glyph (unlike the settings gear)
// specifically so this small a badge stays a crisp two-bar shape regardless // specifically so this small a badge stays a crisp two-bar shape regardless
// of the platform's emoji font - pause/play glyphs default to emoji // of the platform's emoji font - pause/play glyphs default to emoji
@ -42,9 +29,58 @@ const PAUSE_ICON_SVG = `
<rect x="6.1" y="1" width="2.4" height="8" fill="#fff"></rect> <rect x="6.1" y="1" width="2.4" height="8" fill="#fff"></rect>
</svg>`; </svg>`;
/**
* Route `/party/:id` - dispatches on the party's mediaType, fetched here
* once, since both renderVideoPlayer and renderMusicPlayer need to know it
* before they can render anything at all.
*
* Has to return its cleanup function *synchronously* (route()'s contract -
* see router.js), but which cleanup function that even is depends on a
* fetch that hasn't resolved yet at the time this returns. Solved with a
* mutable box: an outer cleanup wrapper is handed back immediately, and it
* calls whatever the real cleanup turns out to be once the fetch settles -
* or nothing at all, correctly, if the fetch is still pending or failed
* when it runs (nothing was ever set up in that case - no socket, no
* timer, nothing to leak).
*/
export function renderPartyPlayer(root, params) { export function renderPartyPlayer(root, params) {
const partyId = params.id; const partyId = params.id;
root.innerHTML = '<p class="hint">Chargement...</p>';
let cleanup = () => {};
let cancelled = false;
getParty(partyId).then((party) => {
if (cancelled) return;
cleanup = (party.mediaType === 'music' ? renderMusicPlayer : renderVideoPlayer)(root, partyId, party) || (() => {});
}).catch((err) => {
if (cancelled) return;
// Full error (not just .message) to the console - this also catches a
// rendering bug in renderVideoPlayer/renderMusicPlayer itself, not just
// a genuine "party not found", so the stack trace matters here.
console.error('[watchparty player] failed to open party', err);
alert(err.message);
navigate('/home');
});
return function outerCleanup() {
cancelled = true;
cleanup();
};
}
/**
* Renders the video half of a party - called by the dispatcher above once
* it already knows (from a fetched `party`) that mediaType isn't 'music'.
* Takes the already-fetched party instead of fetching it itself: the
* dispatcher needs to fetch it anyway just to read mediaType, and
* route()'s contract needs its cleanup function back *synchronously* (see
* router.js) - an async renderVideoPlayer awaiting its own fetch would
* return a Promise instead, which router.js's `typeof cleanup ===
* 'function'` check would silently skip, reintroducing the exact leak
* class this app already spent real effort fixing once.
*/
function renderVideoPlayer(root, partyId, party) {
root.innerHTML = ` root.innerHTML = `
<div class="playerWrap"> <div class="playerWrap">
<video id="video" playsinline></video> <video id="video" playsinline></video>
@ -322,15 +358,6 @@ export function renderPartyPlayer(root, params) {
} }
async function init() { async function init() {
let party;
try {
party = await getParty(partyId);
} catch (err) {
alert(err.message);
navigate('/home');
return;
}
itemTitleEl.textContent = party.itemName || ''; itemTitleEl.textContent = party.itemName || '';
hasNextQueued = !!party.hasNext; hasNextQueued = !!party.hasNext;
followerNotice.classList.toggle('hide', party.youAreHost); followerNotice.classList.toggle('hide', party.youAreHost);

View file

@ -517,9 +517,9 @@ generic grid:
- **`views/library.js`** (route `/library/:id`): dispatches purely on the - **`views/library.js`** (route `/library/:id`): dispatches purely on the
library's `collectionType`. `movies`/`tvshows`/`musicvideos` all get a tile library's `collectionType`. `movies`/`tvshows`/`musicvideos` all get a tile
grid (`GET /api/catalog/libraries/:id/items`, `Recursive=false` — Movies grid (`GET /api/catalog/libraries/:id/items`, `Recursive=false` — Movies
and music videos as themselves, TV as Series tiles); `music` gets an and music videos as themselves, TV as Series tiles); `music` gets a tile
honest "not built yet" message instead of a half-working page (see grid too, just of artists (`GET /api/catalog/libraries/:id/artists`) —
Non-goals). see "Music: participative playlist" below for the rest of that hierarchy.
- **Tiles** (`client/js/tiles.js`, shared by the home carousels and the - **Tiles** (`client/js/tiles.js`, shared by the home carousels and the
library grid so they can't drift apart): poster + name, hover-revealed library grid so they can't drift apart): poster + name, hover-revealed
overlay with "Lancer une watch party" (only for directly-playable types — overlay with "Lancer une watch party" (only for directly-playable types —
@ -617,19 +617,11 @@ track/quality reload are new protocol surface with **no test coverage at
all yet** (no way to exercise real HLS playback without a live session) — all yet** (no way to exercise real HLS playback without a live session) —
the first things to check once a real party actually plays something. the first things to check once a real party actually plays something.
## Non-goals (updated) ## Non-goals
**Music** was requested (dedicated audio player, artist/album browsing, a Chat/reactions and OIDC/SSO remain out of scope - see Goals/Non-goals at
shared/collaborative playlist party members can add to together) but is the top. Music was in this list for a while (see git history) - it's
deliberately not in this pass: it's a genuinely different subsystem — a implemented now, see its own section below.
different player paradigm, full library-depth browsing down to the track,
and real-time collaborative queue state that doesn't fit the existing
"host drives, everyone follows" model at all (multiple people adding to one
shared list is a different kind of shared state than one host's playback
position). The sidebar and library routing already treat `music` as a first
-class `collectionType` specifically so slotting in a real implementation
later doesn't need another structural pass — see `library.js`'s early
return. Right now it just shows an honest placeholder.
## Server-side logging ## Server-side logging
@ -678,14 +670,227 @@ throttled repeat `"requestPause"` produced no second `"pauseRequested"`
line - all assertions passed, counted programmatically rather than line - all assertions passed, counted programmatically rather than
eyeballed. eyeballed.
## Music: participative playlist
The last deferred piece from the original UI spec, built once asked for
directly. Three things needed designing before any code: the permission
model for the shared queue (asked, not assumed - see session history), how
audio playback fits the existing party/socket infrastructure, and how
"browse and add a track" works without leaving the player.
**Chosen model: free add, vote-to-skip, no host for playback.** Anyone in
the party can add a track to the shared queue at any time. Anyone can
pause/resume directly (reversible, low-stakes - unlike video's host-only
`"playstate"`). Skipping the *current* track needs a strict majority vote
of currently connected members - the one action asked to be gated, since
unlike adding (purely additive) or pausing (trivially undone), skipping
throws away where the party is now. This is a genuinely different shape
of shared state than video's "one host drives, everyone follows" - not a
size difference, a different model - which is exactly why it's a separate
service (`server/src/musicQueueService.js`, `MusicQueueService`) rather
than more branches inside `partyManager.js`/`socketHandler.js`'s existing
video-shaped logic.
### Data model and server-authoritative timing
`Party` gained a `mediaType: 'video' | 'music'` field
(`PartyManager.create`'s new last argument, inferred server-side in
`POST /api/parties` from the clicked item's Emby `Type` - `'Audio'` means
music, everything else already handled means video - the client never has
to know or say which). A music party's `party.music` object
(`{queue, current, skipVotes, advanceTimer}`) is entirely separate from
the fixed pre-built `party.queue`/`queueIndex` video parties use - a music
queue is live and mutable, not something computed once at creation.
No single client is "the position" for music the way a video host's
heartbeat is. Instead, `party.music.current.startedUtc` (a wall-clock
timestamp) plus the track's known duration is the only state that exists -
`getElapsedSeconds()` computes position on demand
(`Date.now() - startedUtc`, or a frozen `pausedElapsedMs` while paused),
never ticked or stored. This is also what makes auto-advance possible
without any client "reporting" a track ended (which, with several clients'
players all finishing within milliseconds of each other and no single
host, could race): `_scheduleAdvance` sets one `setTimeout` for exactly
when the current track's duration runs out, computed from the same
`startedUtc`. A track with no known duration just has nothing scheduled -
it relies on a manual vote-skip instead, same as it would if the timer
fired.
That timer is the one genuinely new leak surface this feature adds, given
how much this project has already had to fix real memory leaks (see
"Realtime transport" above) - `_clearTimer` runs before every reschedule
and from a new `destroy(party)` hook wired into **both** of
`partyManager.js`'s existing party-destruction paths (`leave`'s last-member
case and `removeIdleParties`' sweep), so a torn-down party can never leave
a timer firing against state that no longer exists in `PartyManager`'s map.
`MusicQueueService` needs to broadcast from inside that timer's callback,
which runs with no socket/request in scope at all - solved with
`attachIo(io, onTrackChanged)`, called once at server startup
(`index.js`, right next to where `attachSocketServer` itself is wired up),
the same "give the singleton what it needs once, up front" shape
`PartyManager`'s own singleton already has. `onTrackChanged` is wired to
`broadcastLobby(io)` specifically so a party's home-page card (preview
image/name) stays current even when the *timer*, not a person clicking
anything, is what changed what's playing.
### Catalog: Artists → Albums → Tracks
Three more dedicated Emby endpoints, same reasoning as `Shows → Seasons →
Episodes`: guessing folder structure via generic recursive `/Items` calls
is fragile, a real endpoint per level isn't. `GET /Artists?ParentId=...`
for a library's artists, `GET /Users/{id}/Items?ArtistIds=...
&IncludeItemTypes=MusicAlbum` for an artist's albums, `GET
/Users/{id}/Items?ParentId=<albumId>&IncludeItemTypes=Audio` for an
album's tracks. `toItemDto` grew `durationTicks`/`indexNumber`/
`artistName`/`albumName` - additive, `undefined` for every other item type,
so nothing existing had to change shape. `WATCH_PARTY_TYPES` grew
`'Audio'` (a track can start a party) - `MusicArtist`/`MusicAlbum` stay
un-playable, drill-down only, joining `Series` in `tiles.js`'s
`DRILL_DOWN` table (generalized from a single `seriesHref` special case to
a small per-type lookup once there were three, not two, drill-down
targets - `/artist/:id` → `artist.js`, `/album/:id` → `album.js`, each a
smaller version of the library-grid/episode-list shape already used
elsewhere).
### Streaming: the same route, one hint
Audio goes through the *exact same* `/api/catalog/stream/:itemId/master.m3u8`
route as video, not a parallel endpoint - Emby's `PlaybackInfo` negotiation
isn't video-specific, so the whole `TranscodingUrl`-first,
hand-built-fallback dance (see "Player track selection" above) applies
identically. The one real difference is the hand-built fallback's path
prefix (`/Videos/` vs `/Audio/`), which the response doesn't reliably
reveal on its own and looking up would cost an extra Emby round trip for
something the client already knows - so the client tags the request
(`streamUrl(itemId, {mediaType:'audio'})`, `audioStreamUrl()` in
`embyApi.js`) and the route reads it back
(`req.query.mediaType === 'audio'`). The music player uses hls.js for the
same reason the video player does: most browsers have no native `.m3u8`
support at all, and there's no reason to assume Emby's negotiation
produces anything *other* than HLS just because the media is audio.
### Player: not theater-mode, and why adding a track doesn't navigate away
`renderPartyPlayer` (route `/party/:id`, `player.js`) is now a small
dispatcher: it fetches the party once (both the video and music renderers
need `mediaType` before they can render anything at all) and delegates to
`renderVideoPlayer` or the new `renderMusicPlayer`
(`views/musicPlayer.js`). The dispatch itself can't be `async` despite
needing to `await` that fetch - `route()`'s contract needs its cleanup
function back *synchronously* (see `router.js`), and an `async` handler
would hand back a `Promise` instead, which `router.js`'s `typeof cleanup
=== 'function'` check silently skips - reintroducing the exact leak class
this project already spent real effort fixing once. Solved with a mutable
box: `renderPartyPlayer` returns a wrapper immediately, which calls
whichever real cleanup function the fetch eventually produces - or nothing
at all, correctly, if the party navigates away before that fetch even
resolves (nothing was set up yet in that case - no socket, no timer,
nothing to leak).
Unlike the video player, the music player is **not** full-viewport/theater
-mode (no `.playerWrap`-style `position:fixed`) - there's no video image to
declutter around, so it renders as a normal page and the sidebar stays up.
That's also what makes "add tracks while listening" simple: rather than
navigating to a separate browsing page and losing the player's own state
(and having to thread "which party am I adding to" through a chain of
query params across three route levels - considered, rejected as needless
complexity for what browsing-with-context would have bought), the player
has its own small embedded Artist → Album → Track browser
(`musicPlayer.js`'s `loadMusicLibraries`/`loadArtists`/`loadAlbums`/
`loadTracks`), reusing the exact same catalog endpoints, rendered inline
in a toggleable panel. Adding a track is REST
(`POST /api/parties/:partyId/tracks`, `addTrackToQueue()` in `party.js`),
not a socket event like `voteSkip`/`musicPlaystate` - deliberately, since
it needs to work from this same panel regardless of realtime-connection
state, and `MusicQueueService.addTrack`'s own `"queueUpdated"` broadcast
(via the `attachIo` wiring above) reaches everyone actually connected
either way, so nothing about the realtime side needed a second code path.
### A real bug found while testing, not just the mock's fault
`applyMusicState` (the "queueUpdated"/initial-render handler) used to
update the queue list and vote button *after* the current-track branch,
which calls `loadTrack()` - and `loadTrack()`'s hls.js setup can throw (a
bad manifest, hls.js failing to load from the CDN, any of it). Because
JavaScript doesn't roll back a function's earlier statements when a later
one throws, but *does* abort everything after the throw in the same call,
an exception there took the queue list and vote tally down with it even
though they have nothing to do with whether one specific track's audio
loaded - and worse, on the very first render, it escaped `renderMusicPlayer`
entirely into the dispatcher's `.catch()`, which alerts and navigates back
to `/home`, discarding the whole view. Fixed two ways: reordered
`applyMusicState` so the queue/vote/icon updates - which can't fail - run
*before* the current-track branch that can (mirroring `player.js`'s
`onItemChanged`, which already updates the title/next-button before
calling `loadVideo` for exactly this reason), and wrapped `loadTrack`'s
hls.js setup in its own `try/catch` so a failure there logs and stops,
rather than propagating out of the handler at all. Found by a test whose
own mock happened to be missing `Hls.Events` - which pointed at a real
fragility in the shipped code, not just an incomplete mock, so it's fixed
in `musicPlayer.js` itself, not worked around in the test.
### Verified
Server-side, directly against the real shipped modules, not reimplemented
mocks (see session history): a scripted sequence against the real
`musicQueueService.js` alone - init auto-starts the first track, a second
add while one plays queues rather than jumping the queue, a two-vote
majority skips and clears the tally, pause/resume freezes and correctly
resumes elapsed time, a short real track auto-advances on its own via a
genuine `setTimeout` after its duration, and `destroy()` clears a pending
timer (18 checks, instrumented `setTimeout`/`clearTimeout` counting to
catch anything left dangling - same technique used for the client-side
router-cleanup leak earlier in this project). A second pass through the
real `socketHandler.js` + `partyManager.js` together (fake `io`/sockets)
confirmed `voteSkip`/`musicPlaystate` are no-ops on a video party, a
non-host member can pause and resume a music party's track directly, a
two-connected-member vote-skip needs exactly two votes, and the resulting
`"queueUpdated"` really reaches the party's socket room (7 checks).
Client-side, against the real running local server with mocked
`fetch`/`Hls`/`io` (see session history): the full library → artist →
album browsing chain renders correct tiles and drill-down links at each
level; starting a party from a track correctly creates one and navigates;
the dispatcher correctly renders the music player (not the video one) for
a `mediaType:'music'` party and still renders the video player when
`mediaType` is absent (back-compat); the now-playing header, vote tally
(computed against the live member list, matching `musicQueueService.js`'s
own majority math), and queue list all render correctly and update
correctly on a `"queueUpdated"` push including a live track change (new
audio source loaded); play/pause and vote-skip clicks emit the right
socket events; the inline browse-and-add panel correctly walks
artist → album → tracks and POSTs the exact right payload shape to
`/api/parties/:partyId/tracks`; and the crash fixed above was confirmed
fixed by re-running the same broken-`Hls.Events` scenario through the
full dispatcher afterward - no alert, no navigation away, the queue/track
info render correctly regardless.
**Not verified** - the usual limit of testing without real Emby access:
whether `/Artists`/the album-tracks query really return what's assumed
here, whether a music item's `PlaybackInfo`/`TranscodingUrl` behaves the
same way a video's does (reasoned to, per "Streaming" above, not
observed), and the real audio quality/gapless-ness of playback through a
transcode. First things to check once this gets a real retest.
## Known limitations ## Known limitations
- Playback always goes through `hls.js` against whatever Emby's - Playback always goes through `hls.js` against whatever Emby's
`PlaybackInfo` suggests. If that ever comes back as a direct-play source `PlaybackInfo` suggests. If that ever comes back as a direct-play source
with no `TranscodingUrl` (no transcoding needed/possible), the client has with no `TranscodingUrl` (no transcoding needed/possible), the client has
no fallback path yet - it'll try to hand hls.js something that isn't HLS. no fallback path yet - it'll try to hand hls.js something that isn't HLS.
- One item per party; no shared queue. - One fixed item/episode-queue per **video** party (a music party's queue
- A party member needs a browser tab open and connected for sync to apply — is the live, participative one - see "Music" above).
there's no "catch up automatically when you come back" beyond whatever - A video party member needs a browser tab open and connected for sync to
the next heartbeat corrects. apply - there's no "catch up automatically when you come back" beyond
whatever the next heartbeat corrects. Music has no equivalent heartbeat
at all (a deliberate simplification, not an oversight - see "Music"
above): position only resyncs on a discrete event (add/skip/pause/
resume), so two listeners' `<audio>` elements could drift apart slightly
during a long stretch with no such event - accepted for v1 given music's
more forgiving tolerance for exact sync compared to video+subtitles.
- No persistence: server restart or long idle time ends every party. - No persistence: server restart or long idle time ends every party.
- No free seeking within a music track (only vote-skip to the *next* one)
- not asked for, and would need its own protocol addition
(`MusicQueueService` has no notion of "jump to position X" today, only
play/pause and advance).

View file

@ -17,6 +17,7 @@ const {
} = require('./embyAuth'); } = require('./embyAuth');
const { embyFetch, proxyEmbyResponse } = require('./embyProxy'); const { embyFetch, proxyEmbyResponse } = require('./embyProxy');
const { PartyManager } = require('./partyManager'); const { PartyManager } = require('./partyManager');
const { MusicQueueService } = require('./musicQueueService');
const { attachSocketServer, broadcastLobby, applyLeave } = require('./socketHandler'); const { attachSocketServer, broadcastLobby, applyLeave } = require('./socketHandler');
const { streamLog } = require('./logger'); const { streamLog } = require('./logger');
@ -183,6 +184,56 @@ app.get('/api/catalog/series/:seriesId/episodes', requireSession, async (req, re
res.json({ episodes: (data.Items || []).map((e) => toItemDto(e, req.embySession.serverId)) }); res.json({ episodes: (data.Items || []).map((e) => toItemDto(e, req.embySession.serverId)) });
}); });
// Library -> Artists -> Albums -> Tracks, for the "Musique" browsing pages
// (client/js/views/library.js, artist.js, album.js). Each its own Emby
// endpoint/query rather than guessing folder structure, same reasoning as
// Shows -> Seasons -> Episodes above.
app.get('/api/catalog/libraries/:libraryId/artists', requireSession, async (req, res) => {
const params = new URLSearchParams({ ParentId: req.params.libraryId, UserId: req.embySession.userId, SortBy: 'SortName' });
const embyRes = await embyFetch(`/Artists?${params}`, req.embySession.embyToken);
if (!embyRes.ok) {
res.status(502).json({ error: 'Impossible de récupérer les artistes.' });
return;
}
const data = await embyRes.json();
res.json({ artists: (data.Items || []).map((a) => toItemDto(a, req.embySession.serverId)) });
});
app.get('/api/catalog/artists/:artistId/albums', requireSession, async (req, res) => {
const params = new URLSearchParams({
ArtistIds: req.params.artistId,
UserId: req.embySession.userId,
IncludeItemTypes: 'MusicAlbum',
Recursive: 'true',
SortBy: 'ProductionYear,SortName',
Fields: 'ProductionYear'
});
const embyRes = await embyFetch(`/Users/${req.embySession.userId}/Items?${params}`, req.embySession.embyToken);
if (!embyRes.ok) {
res.status(502).json({ error: 'Impossible de récupérer les albums.' });
return;
}
const data = await embyRes.json();
res.json({ albums: (data.Items || []).map((a) => toItemDto(a, req.embySession.serverId)) });
});
app.get('/api/catalog/albums/:albumId/tracks', requireSession, async (req, res) => {
const params = new URLSearchParams({
ParentId: req.params.albumId,
UserId: req.embySession.userId,
IncludeItemTypes: 'Audio',
SortBy: 'IndexNumber',
Fields: 'RunTimeTicks,IndexNumber,Album,AlbumArtist'
});
const embyRes = await embyFetch(`/Users/${req.embySession.userId}/Items?${params}`, req.embySession.embyToken);
if (!embyRes.ok) {
res.status(502).json({ error: 'Impossible de récupérer les morceaux.' });
return;
}
const data = await embyRes.json();
res.json({ tracks: (data.Items || []).map((t) => toItemDto(t, req.embySession.serverId)) });
});
// Audio/subtitle tracks available for an item, read from Emby's own playback // Audio/subtitle tracks available for an item, read from Emby's own playback
// negotiation (PlaybackInfo) - powers the player's language/subtitle pickers. // negotiation (PlaybackInfo) - powers the player's language/subtitle pickers.
app.get('/api/catalog/item/:itemId/playback-options', requireSession, async (req, res) => { app.get('/api/catalog/item/:itemId/playback-options', requireSession, async (req, res) => {
@ -280,6 +331,17 @@ app.get('/api/catalog/stream/:itemId/master.m3u8', async (req, res) => {
// have one at all) - the hand-built fallback below now at least supplies // have one at all) - the hand-built fallback below now at least supplies
// the parameters PlaybackInfo reveals (MediaSourceId, PlaySessionId, // the parameters PlaybackInfo reveals (MediaSourceId, PlaySessionId,
// UserId) instead of guessing, but is still a fallback, not the primary path. // UserId) instead of guessing, but is still a fallback, not the primary path.
// Music tracks stream through this exact route too (same PlaybackInfo ->
// TranscodingUrl dance - Emby's negotiation isn't video-specific) - the
// only place that actually differs is the hand-built fallback path,
// which is genuinely different between the two (/Videos/ vs /Audio/) and
// has no way to know which without being told: PlaybackInfo's own
// response doesn't reliably carry the item's Type back, and looking it
// up would be an extra Emby round trip for what the client already
// knows. So the client passes mediaType=audio (see streamUrl() in
// embyApi.js) - cosmetic in every other sense, this is the one place it
// actually changes what gets requested.
const isAudio = req.query.mediaType === 'audio';
let target; let target;
if (source.TranscodingUrl) { if (source.TranscodingUrl) {
const [urlPath, urlQuery] = source.TranscodingUrl.split('?'); const [urlPath, urlQuery] = source.TranscodingUrl.split('?');
@ -292,11 +354,11 @@ app.get('/api/catalog/stream/:itemId/master.m3u8', async (req, res) => {
UserId: session.userId, UserId: session.userId,
MediaSourceId: source.Id || '', MediaSourceId: source.Id || '',
PlaySessionId: info.PlaySessionId || '', PlaySessionId: info.PlaySessionId || '',
VideoCodec: 'h264', AudioCodec: 'aac',
AudioCodec: 'aac' ...(isAudio ? {} : { VideoCodec: 'h264' })
}); });
applyPlaybackOverrides(params, req.query); applyPlaybackOverrides(params, req.query);
target = `/Videos/${itemId}/master.m3u8?${params}`; target = `/${isAudio ? 'Audio' : 'Videos'}/${itemId}/master.m3u8?${params}`;
logStreamRequest(itemId, 'fallback', params); logStreamRequest(itemId, 'fallback', params);
} }
@ -376,8 +438,8 @@ app.get('/api/catalog/stream/:itemId/raw', async (req, res) => {
// contents (distinct from the generic 'Video', which is home videos) - // contents (distinct from the generic 'Video', which is home videos) -
// needed now that musicvideos is one of the visible library types (see // needed now that musicvideos is one of the visible library types (see
// VISIBLE_COLLECTION_TYPES above), otherwise every tile in it would render // VISIBLE_COLLECTION_TYPES above), otherwise every tile in it would render
// with no "Lancer une watch party" button at all. // with no "Lancer une watch party" button at all. 'Audio' is a music track.
const WATCH_PARTY_TYPES = new Set(['Movie', 'Episode', 'Video', 'MusicVideo']); const WATCH_PARTY_TYPES = new Set(['Movie', 'Episode', 'Video', 'MusicVideo', 'Audio']);
function toItemDto(item, serverId) { function toItemDto(item, serverId) {
return { return {
@ -386,11 +448,18 @@ function toItemDto(item, serverId) {
overview: item.Overview, overview: item.Overview,
year: item.ProductionYear, year: item.ProductionYear,
type: item.Type, type: item.Type,
// Series/Season aren't directly playable - only show "start a party" for // Series/Season/MusicArtist/MusicAlbum aren't directly playable - only
// things Emby can actually stream. "Voir sur Emby" always works though. // show "start a party" for things Emby can actually stream. "Voir sur
// Emby" always works though.
canWatchParty: WATCH_PARTY_TYPES.has(item.Type), canWatchParty: WATCH_PARTY_TYPES.has(item.Type),
imageUrl: `/api/catalog/image/${item.Id}`, imageUrl: `/api/catalog/image/${item.Id}`,
embyUrl: `${EMBY_SERVER_URL}/web/index.html#!/item?id=${item.Id}&serverId=${encodeURIComponent(serverId || '')}` embyUrl: `${EMBY_SERVER_URL}/web/index.html#!/item?id=${item.Id}&serverId=${encodeURIComponent(serverId || '')}`,
// Track-specific (undefined/null for anything else - harmless, every
// consumer that cares about these only reads them for an Audio item).
durationTicks: item.RunTimeTicks || null,
indexNumber: item.IndexNumber != null ? item.IndexNumber : null,
artistName: item.AlbumArtist || (item.Artists && item.Artists[0]) || null,
albumName: item.Album || null
}; };
} }
@ -413,12 +482,15 @@ app.post('/api/parties', requireSession, async (req, res) => {
return; return;
} }
const item = await embyRes.json(); const item = await embyRes.json();
const 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
// there on, so the host can hit "next" (see socketHandler.js) instead of // there on, so the host can hit "next" (see socketHandler.js) instead of
// everyone having to separately start a new party per episode. // everyone having to separately start a new party per episode. Doesn't
// apply to music - that party's queue is the live, participative one
// instead (see MusicQueueService.init below).
let queue = null; let queue = null;
if (item.Type === 'Episode' && item.SeriesId) { if (mediaType === 'video' && item.Type === 'Episode' && item.SeriesId) {
const epsRes = await embyFetch( const epsRes = await embyFetch(
`/Shows/${item.SeriesId}/Episodes?userId=${req.embySession.userId}&Fields=Overview`, `/Shows/${item.SeriesId}/Episodes?userId=${req.embySession.userId}&Fields=Overview`,
req.embySession.embyToken req.embySession.embyToken
@ -442,8 +514,21 @@ app.post('/api/parties', requireSession, async (req, res) => {
itemId, itemId,
queue ? queue[0].itemName : item.Name, queue ? queue[0].itemName : item.Name,
`/api/catalog/image/${itemId}`, `/api/catalog/image/${itemId}`,
queue queue,
mediaType
); );
if (mediaType === 'music') {
MusicQueueService.init(party, {
itemId,
itemName: item.Name,
artistName: item.AlbumArtist || (item.Artists && item.Artists[0]) || null,
albumName: item.Album || null,
itemImageUrl: `/api/catalog/image/${itemId}`,
durationTicks: item.RunTimeTicks || null
}, req.embySession.userId, req.embySession.userName);
}
broadcastLobby(io); // the new party shows up on everyone's home page immediately broadcastLobby(io); // the new party shows up on everyone's home page immediately
res.json(PartyManager.toPublicState(party, req.embySession.userId)); res.json(PartyManager.toPublicState(party, req.embySession.userId));
}); });
@ -456,6 +541,33 @@ function formatEpisodeName(episode) {
return episode.Name; return episode.Name;
} }
// Adding a track to a music party's shared queue is REST, not a socket
// event (see socketHandler.js's top comment): the album-browsing page
// (client/js/views/album.js) that this is actually called from has no live
// connection to the party it's adding into - only the player view
// (musicPlayer.js) does. MusicQueueService's own "queueUpdated" broadcast
// (wired to the real io instance via attachIo, below) reaches anyone
// actually connected regardless of which page made this request.
app.post('/api/parties/:partyId/tracks', requireSession, (req, res) => {
const party = PartyManager.getById(req.params.partyId);
if (!party || party.mediaType !== 'music') {
res.status(404).json({ error: 'Watch party musique introuvable.' });
return;
}
if (!party.members.has(req.embySession.userId)) {
res.status(403).json({ error: "Tu n'es pas membre de cette watch party." });
return;
}
const track = req.body || {};
if (!track.itemId) {
res.status(400).json({ error: 'itemId is required.' });
return;
}
MusicQueueService.addTrack(party, track, req.embySession.userId, req.embySession.userName);
broadcastLobby(io); // the queue/now-playing preview may have just changed
res.json(PartyManager.toPublicState(party, req.embySession.userId));
});
app.post('/api/parties/:partyId/join', requireSession, (req, res) => { app.post('/api/parties/:partyId/join', requireSession, (req, res) => {
const party = PartyManager.getById(req.params.partyId); const party = PartyManager.getById(req.params.partyId);
if (!party) { if (!party) {
@ -495,6 +607,14 @@ const server = http.createServer(app);
const io = new SocketIoServer(server); const io = new SocketIoServer(server);
attachSocketServer(io, { parseCookies, getSession }); attachSocketServer(io, { parseCookies, getSession });
// Gives MusicQueueService a way to broadcast from its own internal
// auto-advance timer (no socket/request in scope there at all) and to
// refresh the home page's lobby card when the timer - not a person
// clicking anything - changes what's playing. Passed in rather than
// required directly from socketHandler.js/musicQueueService.js to avoid a
// circular require between the two.
MusicQueueService.attachIo(io, () => broadcastLobby(io));
setInterval(() => PartyManager.removeIdleParties(4 * 60 * 60 * 1000), 10 * 60 * 1000); setInterval(() => PartyManager.removeIdleParties(4 * 60 * 60 * 1000), 10 * 60 * 1000);
const port = process.env.PORT || 3000; const port = process.env.PORT || 3000;

View file

@ -0,0 +1,228 @@
'use strict';
const { partyLog, who } = require('./logger');
/**
* Owns the "participative playlist" behavior for a music party - kept out
* of partyManager.js because it's genuinely a different kind of shared
* state than the video model's single host-driven position (see
* docs/architecture.md's "Music" section): a live, mutable queue anyone
* can add to, server-authoritative timing (nobody is "the host" driving
* playback - a track just plays until its known duration runs out or a
* vote skips it), and skip-by-vote instead of a single controller.
*
* Chosen permission model (asked, not assumed - see session history):
* - Anyone can add a track to the queue, any time.
* - Anyone can pause/resume - it's reversible and low-stakes, unlike
* skipping to different content entirely.
* - Skipping the current track needs a strict majority vote of
* currently CONNECTED members ("membres présents"), not a simple click.
*
* One singleton instance (same shape as PartyManager below) - the state
* itself lives on each `party.music` object; this class is just the
* behavior around it, so it slots into the "one service class per
* feature" architecture the app is moving toward without a separate
* rewrite pass just for this file.
*/
class MusicQueueService {
constructor() {
this._io = null;
this._onTrackChanged = () => {};
}
/**
* Called once at server startup (index.js), after the Socket.IO server
* exists - nothing here can broadcast before this runs. `onTrackChanged`
* is how a party's home-page lobby card (host/preview image/name) gets
* refreshed when the *timer* advances the queue with nobody clicking
* anything - index.js wires it to broadcastLobby(io). Passed in rather
* than required directly from socketHandler.js to avoid a circular
* require (socketHandler.js already requires this module).
*/
attachIo(io, onTrackChanged) {
this._io = io;
this._onTrackChanged = onTrackChanged || (() => {});
}
/** Called once, when a party is created from a music track (index.js). */
init(party, firstTrack, addedByUserId, addedByUserName) {
party.music = { queue: [], current: null, skipVotes: new Set(), advanceTimer: null };
if (firstTrack) {
this.addTrack(party, firstTrack, addedByUserId, addedByUserName);
}
}
addTrack(party, track, addedByUserId, addedByUserName) {
const entry = {
itemId: track.itemId,
itemName: track.itemName,
artistName: track.artistName || null,
albumName: track.albumName || null,
itemImageUrl: track.itemImageUrl,
// Emby ticks (100ns, same convention as video position) - converted
// once here so the rest of this class only ever deals in seconds.
durationSeconds: track.durationTicks ? Math.round(track.durationTicks / 10000000) : null,
addedByUserId,
addedByUserName
};
party.music.queue.push(entry);
party.lastActivityUtc = Date.now();
partyLog(`musicQueueAdd partyId=${party.id} user=${who(addedByUserId, addedByUserName)} track="${entry.itemName}" queueLength=${party.music.queue.length}`);
if (!party.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). */
setPaused(party, isPaused) {
const current = party.music.current;
if (!current || current.isPaused === isPaused) return;
if (isPaused) {
current.isPaused = true;
current.pausedElapsedMs = Date.now() - current.startedUtc;
this._clearTimer(party);
} else {
current.isPaused = false;
current.startedUtc = Date.now() - current.pausedElapsedMs;
current.pausedElapsedMs = null;
this._scheduleAdvance(party);
}
party.lastActivityUtc = Date.now();
partyLog(`musicPlaystate partyId=${party.id} -> ${isPaused ? 'paused' : 'playing'}`);
this._broadcast(party);
}
/**
* Registers `userId`'s vote to skip the current track - a strict
* majority of `connectedMemberCount` (computed by the caller, which has
* the member list - see socketHandler.js) needed to actually skip, so a
* party of one skips on its own first vote. Broadcasts either way, so
* everyone sees the live tally even before it passes.
*/
voteSkip(party, userId, userName, connectedMemberCount) {
if (!party.music.current) return false;
party.music.skipVotes.add(userId);
const needed = Math.floor(connectedMemberCount / 2) + 1;
const votes = party.music.skipVotes.size;
partyLog(`musicVoteSkip partyId=${party.id} user=${who(userId, userName)} votes=${votes}/${connectedMemberCount} (needs ${needed})`);
const passed = votes >= needed;
if (passed) {
this._playNext(party);
}
this._broadcast(party);
return passed;
}
/**
* Called when a member leaves (socketHandler.js/partyManager.js) so a
* stale vote from someone no longer around can't help a future skip
* clear a bar computed against a smaller, more-accurate connected count.
*/
removeVote(party, userId) {
if (party.music) party.music.skipVotes.delete(userId);
}
/** Live position, in seconds - computed on demand from wall-clock time,
* never ticked/stored, so there's nothing to drift or leak. */
getElapsedSeconds(party) {
const current = party.music.current;
if (!current) return 0;
const elapsedMs = current.isPaused ? current.pausedElapsedMs : (Date.now() - current.startedUtc);
return Math.max(0, Math.floor(elapsedMs / 1000));
}
/** Public snapshot for toPublicState()/the "queueUpdated" broadcast. */
getState(party) {
const current = party.music.current;
return {
queue: party.music.queue.map(publicTrack),
current: current ? { ...publicTrack(current.track), isPaused: current.isPaused, elapsedSeconds: this.getElapsedSeconds(party) } : null,
skipVotes: party.music.skipVotes.size
// Deliberately no "votes needed" here - the client already gets the
// member list (with each one's connected flag) from the existing
// "members" broadcast and can compute the same
// Math.floor(connected/2)+1 this class uses, without this payload
// needing to duplicate that count on every single queue change.
};
}
/**
* Called from PartyManager.leave()'s destroy path. This is the actual
* leak fix: an uncleared advanceTimer would fire against a party that no
* longer exists in PartyManager's map, forever holding a reference to
* it (and everything it closes over) - the exact class of bug the
* Socket.IO/router-cleanup work earlier in this project was about
* catching (see docs/architecture.md).
*/
destroy(party) {
if (party.music) this._clearTimer(party);
}
_playNext(party) {
this._clearTimer(party);
party.music.skipVotes.clear();
const next = party.music.queue.shift();
if (!next) {
party.music.current = null;
partyLog(`musicQueueEmpty partyId=${party.id}`);
return;
}
party.music.current = { track: next, startedUtc: Date.now(), isPaused: false, pausedElapsedMs: null };
// Keeps the lobby/home-page party card's preview (name + image)
// current even though this is a queue-driven change, not a host
// action - see PartyManager.toPublicState, which reads these same
// top-level fields for both video and music parties alike.
party.itemName = next.itemName;
party.itemImageUrl = next.itemImageUrl;
partyLog(`musicNowPlaying partyId=${party.id} track="${next.itemName}"`);
this._scheduleAdvance(party);
}
/**
* Server-authoritative auto-advance: schedules the move to the next
* track for exactly when this one's known duration runs out, rather
* than waiting for any client to report "it ended" (which, with no
* single host for music, could race across several clients' players all
* finishing within milliseconds of each other and all trying to
* advance). A track with no known duration just relies on a manual
* vote-skip instead - nothing scheduled, nothing to leak.
*/
_scheduleAdvance(party) {
const current = party.music.current;
if (!current || !current.track.durationSeconds) return;
const elapsedMs = Date.now() - current.startedUtc;
const remainingMs = current.track.durationSeconds * 1000 - elapsedMs;
party.music.advanceTimer = setTimeout(() => {
this._playNext(party);
this._broadcast(party);
this._onTrackChanged(party);
}, Math.max(0, remainingMs));
}
_clearTimer(party) {
if (party.music && party.music.advanceTimer) {
clearTimeout(party.music.advanceTimer);
party.music.advanceTimer = null;
}
}
_broadcast(party) {
if (this._io) this._io.to(party.id).emit('queueUpdated', this.getState(party));
}
}
function publicTrack(t) {
return {
itemId: t.itemId,
itemName: t.itemName,
artistName: t.artistName,
albumName: t.albumName,
itemImageUrl: t.itemImageUrl,
durationSeconds: t.durationSeconds,
addedByUserId: t.addedByUserId,
addedByUserName: t.addedByUserName
};
}
module.exports = { MusicQueueService: new MusicQueueService() };

View file

@ -2,6 +2,7 @@
const crypto = require('crypto'); const crypto = require('crypto');
const { partyLog, who } = require('./logger'); const { partyLog, who } = require('./logger');
const { MusicQueueService } = require('./musicQueueService');
/** /**
* In-memory party store - mirrors the Party/PartyMember/PartyManager design * In-memory party store - mirrors the Party/PartyMember/PartyManager design
@ -27,11 +28,16 @@ class PartyManager {
* one item; for "start watching this series from here", index.js builds * one item; for "start watching this series from here", index.js builds
* the rest of the season/series into it. `itemId`/`itemName`/`itemImageUrl` * the rest of the season/series into it. `itemId`/`itemName`/`itemImageUrl`
* always mirror queue[queueIndex] - kept as top-level fields too since * always mirror queue[queueIndex] - kept as top-level fields too since
* that's what most of the UI reads. * that's what most of the UI reads. Unused for `mediaType: 'music'`
* parties (still populated with a one-item fallback so those same
* top-level fields have *something* sane in them for the lobby/home-page
* card) - a music party's real, mutable queue is `party.music`, owned by
* musicQueueService.js, not this fixed pre-built one.
*/ */
create(hostUserId, hostUserName, itemId, itemName, itemImageUrl, queue) { create(hostUserId, hostUserName, itemId, itemName, itemImageUrl, queue, mediaType) {
const party = { const party = {
id: crypto.randomUUID(), id: crypto.randomUUID(),
mediaType: mediaType || 'video',
itemId, itemId,
itemName, itemName,
itemImageUrl, itemImageUrl,
@ -46,7 +52,7 @@ class PartyManager {
party.members.set(hostUserId, this._newMember(hostUserId, hostUserName)); party.members.set(hostUserId, this._newMember(hostUserId, hostUserName));
this.partiesById.set(party.id, party); this.partiesById.set(party.id, party);
partyLog(`created partyId=${party.id} host=${who(hostUserId, hostUserName)} item="${itemName}" queueLength=${party.queue.length}`); partyLog(`created partyId=${party.id} mediaType=${party.mediaType} host=${who(hostUserId, hostUserName)} item="${itemName}" queueLength=${party.queue.length}`);
return party; return party;
} }
@ -74,6 +80,7 @@ class PartyManager {
const wasHost = party.hostUserId === userId; const wasHost = party.hostUserId === userId;
party.members.delete(userId); party.members.delete(userId);
party.lastActivityUtc = Date.now(); party.lastActivityUtc = Date.now();
MusicQueueService.removeVote(party, userId); // no-op for a video party (party.music is unset)
let newHostUserId = party.hostUserId; let newHostUserId = party.hostUserId;
if (wasHost) { if (wasHost) {
@ -86,6 +93,7 @@ class PartyManager {
const whoLeft = leavingMember ? who(userId, leavingMember.userName) : userId; const whoLeft = leavingMember ? who(userId, leavingMember.userName) : userId;
if (destroyed) { if (destroyed) {
this.partiesById.delete(party.id); this.partiesById.delete(party.id);
MusicQueueService.destroy(party); // clears the auto-advance timer, if any - see its own comment
partyLog(`destroyed partyId=${party.id} (last member ${whoLeft} left)`); partyLog(`destroyed partyId=${party.id} (last member ${whoLeft} left)`);
} else if (wasHost && newHostUserId != null) { } else if (wasHost && newHostUserId != null) {
const newHost = party.members.get(newHostUserId); const newHost = party.members.get(newHostUserId);
@ -131,6 +139,7 @@ class PartyManager {
for (const party of this.partiesById.values()) { for (const party of this.partiesById.values()) {
if (party.lastActivityUtc < cutoff) { if (party.lastActivityUtc < cutoff) {
this.partiesById.delete(party.id); this.partiesById.delete(party.id);
MusicQueueService.destroy(party); // same leak this class's own destroy path guards against
const idleMinutes = Math.round((Date.now() - party.lastActivityUtc) / 60000); const idleMinutes = Math.round((Date.now() - party.lastActivityUtc) / 60000);
partyLog(`destroyed partyId=${party.id} (idle ${idleMinutes}min, ${party.members.size} member(s) still connected)`); partyLog(`destroyed partyId=${party.id} (idle ${idleMinutes}min, ${party.members.size} member(s) still connected)`);
} }
@ -147,10 +156,13 @@ class PartyManager {
const host = party.members.get(party.hostUserId); const host = party.members.get(party.hostUserId);
return { return {
partyId: party.id, partyId: party.id,
mediaType: party.mediaType,
itemId: party.itemId, itemId: party.itemId,
itemName: party.itemName, itemName: party.itemName,
itemImageUrl: party.itemImageUrl, itemImageUrl: party.itemImageUrl,
createdUtc: party.createdUtc, createdUtc: party.createdUtc,
// hasNext/queueLength/queueIndex describe the fixed video episode
// queue - meaningless for a music party (see `music` below instead).
hasNext: party.queueIndex < party.queue.length - 1, hasNext: party.queueIndex < party.queue.length - 1,
queueLength: party.queue.length, queueLength: party.queue.length,
queueIndex: party.queueIndex, queueIndex: party.queueIndex,
@ -164,7 +176,8 @@ class PartyManager {
isPaused: m.isPaused, isPaused: m.isPaused,
positionTicks: m.positionTicks, positionTicks: m.positionTicks,
connected: !!m.connection connected: !!m.connection
})) })),
music: party.mediaType === 'music' ? MusicQueueService.getState(party) : null
}; };
} }

View file

@ -1,6 +1,7 @@
'use strict'; 'use strict';
const { PartyManager } = require('./partyManager'); const { PartyManager } = require('./partyManager');
const { MusicQueueService } = require('./musicQueueService');
const { partyLog, who } = require('./logger'); const { partyLog, who } = require('./logger');
/** /**
@ -16,12 +17,29 @@ const { partyLog, who } = require('./logger');
* and again whenever any party changes * and again whenever any party changes
* "join" {partyId} joins that party's own room * "join" {partyId} joins that party's own room
* "members" (server -> client) [...] that party's member list * "members" (server -> client) [...] that party's member list
*
* -- mediaType: 'video' parties --
* "playstate" {isPaused, positionTicks} (host only - ignored otherwise) * "playstate" {isPaused, positionTicks} (host only - ignored otherwise)
* "playstate" (server -> client, to everyone in the party but the sender) * "playstate" (server -> client, to everyone in the party but the sender)
* "next" (host only - advance the queue) * "next" (host only - advance the queue)
* "itemChanged" (server -> client) {itemId, itemName, itemImageUrl, queueIndex, queueLength, hasNext} * "itemChanged" (server -> client) {itemId, itemName, itemImageUrl, queueIndex, queueLength, hasNext}
*
* -- mediaType: 'music' parties (see musicQueueService.js for the model) --
* "voteSkip" (anyone - see musicQueueService.js for the threshold)
* "musicPlaystate" {isPaused} (anyone - no host check, unlike video's "playstate")
* "queueUpdated" (server -> client) - see MusicQueueService.getState() for the shape
* (adding a track is REST, not a socket event - POST
* /api/parties/:partyId/tracks in index.js - since it needs to work
* from the album-browsing page too, which has no live connection to
* the party it might be adding into; MusicQueueService's own
* "queueUpdated" broadcast fires regardless of which path called it)
*
* -- both --
* "hostChanged" (server -> client, to the new host only, no payload) * "hostChanged" (server -> client, to the new host only, no payload)
* "requestPause" (throttled per member - see below) * "requestPause" (throttled per member - see below;
* video only - a music party has no
* host to ask, everyone already has
* direct pause/vote-skip access)
* "pauseRequested" (server -> client, everyone in the party) {userId, userName} * "pauseRequested" (server -> client, everyone in the party) {userId, userName}
* "leave" * "leave"
* "errorMessage" (server -> client) {message} * "errorMessage" (server -> client) {message}
@ -101,14 +119,32 @@ function attachSocketServer(io, { parseCookies, getSession }) {
} }
}); });
// Majority of currently CONNECTED members, not everyone who's ever
// joined - see musicQueueService.js.
socket.on('voteSkip', () => {
const member = party && party.mediaType === 'music' && party.members.get(userId);
if (!member) return;
const connectedCount = Array.from(party.members.values()).filter((m) => m.connection).length;
MusicQueueService.voteSkip(party, userId, member.userName, connectedCount);
broadcastLobby(io);
});
// No host check, unlike video's "playstate" - see musicQueueService.js.
socket.on('musicPlaystate', (msg) => {
if (!party || party.mediaType !== 'music' || !party.members.get(userId)) return;
MusicQueueService.setPaused(party, !!(msg && msg.isPaused));
});
// Anyone (not just followers - harmless either way) can ask the host to // Anyone (not just followers - harmless either way) can ask the host to
// pause; the presence overlay (client/js/views/player.js) is what // pause; the presence overlay (client/js/views/player.js) is what
// triggers this. Broadcast to the whole party, not just the host, so // triggers this. Broadcast to the whole party, not just the host, so
// everyone sees why playback is about to pause. Throttled per member // everyone sees why playback is about to pause. Throttled per member
// server-side (not just the client's own cooldown, which a stray extra // server-side (not just the client's own cooldown, which a stray extra
// click - or anyone poking the socket directly - would bypass). // click - or anyone poking the socket directly - would bypass). Video
// only: a music party has no host to ask - everyone already has direct
// pause access via "musicPlaystate".
socket.on('requestPause', () => { socket.on('requestPause', () => {
if (!party) return; if (!party || party.mediaType === 'music') return;
const member = party.members.get(userId); const member = party.members.get(userId);
if (!member) return; if (!member) return;
const now = Date.now(); const now = Date.now();
@ -173,6 +209,7 @@ function lobbySnapshot() {
const host = party.members.get(party.hostUserId); const host = party.members.get(party.hostUserId);
return { return {
partyId: party.id, partyId: party.id,
mediaType: party.mediaType,
itemId: party.itemId, itemId: party.itemId,
itemName: party.itemName, itemName: party.itemName,
itemImageUrl: party.itemImageUrl, itemImageUrl: party.itemImageUrl,