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>
124 lines
4.7 KiB
JavaScript
124 lines
4.7 KiB
JavaScript
// Party REST + realtime - same origin as everything else, auth is the
|
|
// session cookie (see embyApi.js), so no headers/tokens to pass around here.
|
|
// Realtime is Socket.IO (window.io, loaded via <script> in index.html) -
|
|
// gives us automatic reconnection and, on the server side, heartbeat-based
|
|
// dead-connection detection for free (see server/src/socketHandler.js).
|
|
//
|
|
// No join codes: joining happens straight from the home page's live list
|
|
// (connectLobby below) by party id - see docs/architecture.md.
|
|
|
|
async function apiFetch(path, options = {}) {
|
|
const res = await fetch(path, {
|
|
credentials: 'same-origin',
|
|
...options,
|
|
headers: { 'Content-Type': 'application/json', ...(options.headers || {}) }
|
|
});
|
|
if (!res.ok) {
|
|
const body = await res.json().catch(() => ({}));
|
|
throw new Error(body.error || `Request failed: ${res.status}`);
|
|
}
|
|
return res.json();
|
|
}
|
|
|
|
export function createParty(itemId) {
|
|
return apiFetch('/api/parties', { method: 'POST', body: JSON.stringify({ itemId }) });
|
|
}
|
|
|
|
export function joinParty(partyId) {
|
|
return apiFetch(`/api/parties/${encodeURIComponent(partyId)}/join`, { method: 'POST' });
|
|
}
|
|
|
|
export function leaveParty(partyId) {
|
|
return apiFetch(`/api/parties/${partyId}/leave`, { method: 'POST' });
|
|
}
|
|
|
|
export function getParty(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
|
|
* called with the full current list on subscribe, and again every time any
|
|
* party anywhere changes (created, someone joined/left, host handed off).
|
|
* Caller MUST call .disconnect() when navigating away (see home.js's router
|
|
* cleanup) - same leak-prevention pattern as connectParty below.
|
|
*/
|
|
export function connectLobby(onParties) {
|
|
const socket = window.io();
|
|
socket.on('connect', () => socket.emit('subscribeLobby'));
|
|
socket.on('parties', onParties);
|
|
return {
|
|
disconnect() {
|
|
socket.disconnect();
|
|
}
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Opens the realtime connection for a party. Returns an object the caller
|
|
* MUST eventually call .leave() on (e.g. from a router cleanup function -
|
|
* see router.js/player.js) - otherwise the socket, and whatever it's
|
|
* keeping alive via closures, never gets released.
|
|
*/
|
|
export function connectParty(partyId, {
|
|
onMembers, onPlaystate, onItemChanged, onHostChanged, onPauseRequested, onQueueUpdated, onError, onClose
|
|
} = {}) {
|
|
const socket = window.io();
|
|
|
|
// Reconnects (Socket.IO retries automatically) re-fire "connect", so this
|
|
// also covers rejoining after a transient network drop, not just the
|
|
// first connection.
|
|
socket.on('connect', () => {
|
|
socket.emit('join', { partyId });
|
|
});
|
|
|
|
if (onMembers) socket.on('members', onMembers);
|
|
if (onPlaystate) socket.on('playstate', onPlaystate);
|
|
if (onItemChanged) socket.on('itemChanged', onItemChanged);
|
|
if (onHostChanged) socket.on('hostChanged', onHostChanged);
|
|
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 (onClose) socket.on('disconnect', onClose);
|
|
|
|
return {
|
|
sendPlaystate(isPaused, positionTicks) {
|
|
socket.emit('playstate', { isPaused, positionTicks });
|
|
},
|
|
/** Host-only: advance the party's queue (e.g. to the next episode). */
|
|
next() {
|
|
socket.emit('next');
|
|
},
|
|
/** Asks the host (and tells everyone else) "please pause". Video only -
|
|
* a music party has no host to ask, see requestSkip/setMusicPaused. */
|
|
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() {
|
|
socket.emit('leave');
|
|
socket.disconnect();
|
|
}
|
|
};
|
|
}
|