'use strict'; import { Logger } from '../Logger'; import { PartyStreamService } from './PartyStreamService'; import { PlaystateService } from './PlaystateService'; import type { AppServer, MusicCurrentPublic, MusicPublicState, MusicTrack, MusicTrackInput, Party } from '../types'; /** * Owns the "participative playlist" behavior for a music party - kept out * of PartyService 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. * * Singleton (exported as an instance below) - the state itself lives on * each `party.music` object; this class is just the behavior around it. */ class MusicQueueServiceClass { private io: AppServer | null = null; private onTrackChanged: (party: Party) => void = () => {}; /** * Called once at server startup (index.ts), 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.ts wires it to broadcastLobby(io). Passed in rather * than imported directly from SocketGateway to avoid a circular import * (SocketGateway already imports this module). */ attachIo(io: AppServer, onTrackChanged?: (party: Party) => void): void { this.io = io; this.onTrackChanged = onTrackChanged || (() => {}); } /** Called once, when a party is created from a music track (index.ts). */ init(party: Party, firstTrack: MusicTrackInput | null, addedByUserId: string, addedByUserName: string): void { party.music = { queue: [], current: null, skipVotes: new Set(), advanceTimer: null }; if (firstTrack) { this.addTrack(party, firstTrack, addedByUserId, addedByUserName); } } addTrack(party: Party, track: MusicTrackInput, addedByUserId: string, addedByUserName: string): void { const music = party.music!; const entry: MusicTrack = { 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 }; music.queue.push(entry); party.lastActivityUtc = Date.now(); Logger.party(`musicQueueAdd partyId=${party.id} user=${Logger.who(addedByUserId, addedByUserName)} track="${entry.itemName}" queueLength=${music.queue.length}`); if (!music.current) { this.playNext(party); // queue was empty - this track starts immediately } this.broadcast(party); } /** No host check - anyone can toggle this (see class comment). */ setPaused(party: Party, isPaused: boolean): void { 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 ?? 0); current.pausedElapsedMs = null; this.scheduleAdvance(party); } party.lastActivityUtc = Date.now(); Logger.party(`musicPlaystate partyId=${party.id} -> ${isPaused ? 'paused' : 'playing'}`); this.broadcast(party); } /** * No host check - anyone can do this (see class comment): a seek is * just a continuous version of pause/resume, reversible and not a * change to what's playing - not the majority-vote model reserved for * actually skipping to different content. Clamped into * [0, durationSeconds] when the duration is known (an unknown one - * never actually seen in practice, see MusicTrack - just trusts the * caller's own non-negative value, same as the video player's seek * has no server-side duration to clamp against either). */ seek(party: Party, positionSeconds: number): void { const current = party.music?.current; if (!current) return; const duration = current.track.durationSeconds; const clampedSeconds = Math.max(0, duration != null ? Math.min(positionSeconds, duration) : positionSeconds); const elapsedMs = clampedSeconds * 1000; if (current.isPaused) { current.pausedElapsedMs = elapsedMs; } else { // Must clear the existing timer first, not just overwrite it - // scheduleAdvance only ever sets advanceTimer, it never clears a // prior one itself (setPaused's own resume branch gets away with // skipping this only because pausing already cleared it - see its // own "if (isPaused)" branch). Skipping this here would leave the // OLD, now-wrong-duration timer running alongside the new one, // eventually firing playNext at the wrong time. this.clearTimer(party); current.startedUtc = Date.now() - elapsedMs; this.scheduleAdvance(party); } party.lastActivityUtc = Date.now(); Logger.party(`musicSeek partyId=${party.id} -> ${clampedSeconds}s`); 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 SocketGateway) 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: Party, userId: string, userName: string, connectedMemberCount: number): boolean { const music = party.music; if (!music || !music.current) return false; music.skipVotes.add(userId); const needed = Math.floor(connectedMemberCount / 2) + 1; const votes = music.skipVotes.size; Logger.party(`musicVoteSkip partyId=${party.id} user=${Logger.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 (SocketGateway/PartyService) 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: Party, userId: string): void { 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: Party): number { const current = party.music?.current; if (!current) return 0; const elapsedMs = current.isPaused ? (current.pausedElapsedMs ?? 0) : (Date.now() - current.startedUtc); return Math.max(0, Math.floor(elapsedMs / 1000)); } /** Public snapshot for PartyService.toPublicState()/the "queueUpdated" broadcast. */ getState(party: Party): MusicPublicState { const music = party.music; if (!music) return { queue: [], current: null, skipVotes: 0 }; const current: MusicCurrentPublic | null = music.current ? { ...music.current.track, isPaused: music.current.isPaused, elapsedSeconds: this.getElapsedSeconds(party) } : null; return { queue: music.queue, current, skipVotes: 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 PartyService.leave()'s destroy path. This is the actual * leak fix: an uncleared advanceTimer would fire against a party that no * longer exists in PartyService'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: Party): void { if (party.music) this.clearTimer(party); } private playNext(party: Party): void { const music = party.music!; const outgoing = music.current; // the track finishing/being skipped, if any - see the markPlayed loop below this.clearTimer(party); music.skipVotes.clear(); // Whatever was previously negotiated (see PartyStreamService) was for // the outgoing track - never valid for whatever plays next, whether // that's a new track or nothing at all. PartyStreamService.endSession(party.id); // The queue moving past `outgoing` - naturally finishing, or a // majority vote-skip, either way - is this app's own signal that // it's "done" with that track, for every member connected right now. // Fire-and-forget, same reasoning as PartyService.advanceQueue's own // identical loop for video - see PlaystateService.markPlayed. `if // (outgoing)` skips this on the very first call for a party (the // queue was empty, nothing outgoing to mark). if (outgoing) { for (const member of party.members.values()) { if (!member.connection || !member.embyToken) continue; PlaystateService.markPlayed(member.embyToken, member.userId, outgoing.track.itemId); } } const next = music.queue.shift(); if (!next) { music.current = null; Logger.party(`musicQueueEmpty partyId=${party.id}`); return; } 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 PartyService.toPublicState, which reads these same // top-level fields for both video and music parties alike. party.itemName = next.itemName; party.itemImageUrl = next.itemImageUrl; Logger.party(`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. */ private scheduleAdvance(party: Party): void { const music = party.music!; const current = music.current; if (!current || !current.track.durationSeconds) return; const elapsedMs = Date.now() - current.startedUtc; const remainingMs = current.track.durationSeconds * 1000 - elapsedMs; music.advanceTimer = setTimeout(() => { this.playNext(party); this.broadcast(party); this.onTrackChanged(party); }, Math.max(0, remainingMs)); } private clearTimer(party: Party): void { if (party.music?.advanceTimer) { clearTimeout(party.music.advanceTimer); party.music.advanceTimer = null; } } private broadcast(party: Party): void { this.io?.to(party.id).emit('queueUpdated', this.getState(party)); } } export const MusicQueueService = new MusicQueueServiceClass();