'use strict'; import { Logger } from '../Logger'; import { MusicQueueService } from '../services/MusicQueueService'; import { PartyService } from '../services/PartyService'; import type { AppServer, LeaveResult, LobbyPartyDto, Party, SocketMemberDto } from '../types'; /** * Realtime side of the app: one party's own room, plus a shared "lobby" * room that powers the home page's live "watch parties en cours" list. * * Auth comes from the session cookie sent during the initial HTTP * handshake - exposed here via socket.request.headers.cookie. * * Events (client -> server unless noted) - see types.ts's * ClientToServerEvents/ServerToClientEvents for the exact payload shapes: * "subscribeLobby" joins the "lobby" room * "parties" (server -> client) full snapshot, sent on subscribe * and again whenever any party changes * "join" {partyId, deviceId, force?} joins that party's own room - * deviceId identifies which physical * device/browser this is (see * client/src/deviceId.ts); force:true * is a confirmed takeover, re-sent * after "deviceConflict" below * "deviceConflict" (server -> client, to the joining socket only) * this account is already connected * from a *different* deviceId - the * socket stays connected but inert * until the client re-sends "join" * with force:true or gives up * "members" (server -> client) that party's member list * * -- mediaType: 'video' parties -- * "playstate" {isPaused, positionTicks} (host only - ignored otherwise) * "playstate" (server -> client, to everyone in the party but the sender) * "next" (host only - advance the queue) * "itemChanged" (server -> client) * "setBurnInSubtitle" {subtitleIndex, audioIndex?} (host only - picks * or clears the party's *shared* self-hosted-ffmpeg burn-in stream, * for a PGS/image subtitle a native can't render - see * TranscodeService. A text subtitle never goes through here - that * stays the existing per-viewer client-side .) * "burnInChanged" (server -> client) * * -- mediaType: 'music' parties (see MusicQueueService for the model) -- * "voteSkip" (anyone - see MusicQueueService 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.ts - 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) * "requestPause"/"requestResume" (each throttled per member, * independently - see below; * video only - a music party has no * host to ask, everyone already has * direct pause/vote-skip access) * "pauseRequested"/"resumeRequested" (server -> client, everyone in the * party) - a plain signal, same as each other: broadcast and done, no * accept/deny state tracked server-side. The host sees the same * banner/avatar highlight as everyone else and acts (or doesn't) with * their own existing playstate controls - see docs/architecture.md. * "leave" * "errorMessage" (server -> client) * ("error" is a reserved Socket.IO event name - using our own to avoid * colliding with its internal error handling.) */ class SocketGatewayClass { attach(io: AppServer, parseCookies: (header: string | undefined) => Record, getSession: (id: string) => { userId: string; userName: string } | null): void { io.on('connection', (socket) => { const cookies = parseCookies(socket.request.headers.cookie); const session = cookies.wp_session ? getSession(cookies.wp_session) : null; if (!session) { socket.emit('errorMessage', { message: 'Session expirée - reconnecte-toi.' }); socket.disconnect(true); return; } const userId = session.userId; let party: Party | null = null; socket.on('subscribeLobby', this.safe(() => { socket.join('lobby'); socket.emit('parties', this.lobbySnapshot()); })); socket.on('join', this.safe((msg: { partyId: string; deviceId: string; force?: boolean }) => { const p = PartyService.getById(msg?.partyId); const member = p?.members.get(userId); if (!p || !member) { socket.emit('errorMessage', { message: "Tu n'es pas membre de cette party." }); socket.disconnect(true); return; } const deviceId = msg?.deviceId || null; // Same account, a DIFFERENT device already actively connected as // this member - ask the new socket to confirm a takeover instead of // silently booting the other device. member.deviceId is null until // the first real socket "join" ever succeeds (a member just created // by the REST join/create routes has no live connection yet), so // there's nothing to conflict with the very first time. if (member.connection && member.deviceId && deviceId && member.deviceId !== deviceId && !msg?.force) { socket.emit('deviceConflict', { partyId: p.id }); // This socket stays connected but inert: not joined to the party // room, member.connection untouched - until the client re-sends // "join" with force:true or gives up. return; } // Either a confirmed takeover (force:true) or the same device // reconnecting (reload, dropped connection) - boot whatever // connection currently holds the slot before taking its place, so // ITS eventual "disconnect" can't be mistaken for a real leave (see // the connection===socket guard on "leave"/"disconnect" below). if (member.connection && member.connection !== socket) { member.connection.emit('errorMessage', { message: 'Tu as rejoint depuis un autre appareil.' }); member.connection.disconnect(true); } party = p; member.connection = socket; member.deviceId = deviceId; socket.join(party.id); this.broadcastMembers(io, party); })); socket.on('playstate', this.safe((msg: { isPaused: boolean; positionTicks: number }) => { if (!party || userId !== party.hostUserId) { return; // only the host drives playback - see docs/architecture.md } // Not just defensive dead code: confirmed as a real crash. Two // connections for the same userId (two tabs/devices, no per-device // identity yet - see docs/architecture.md) share the one // userId-keyed map slot; if the connection currently occupying it // is the last member and leaves, the party is destroyed and this // slot is gone, but party.hostUserId is never reassigned (there's // no successor to hand off to) - so the OTHER, still-connected // socket's next 3s heartbeat still passes the host check above // (its stale userId still equals the stale hostUserId) and used // to crash the whole process on the `!` right here. const member = party.members.get(userId); if (!member) return; const wasPaused = member.isPaused; member.isPaused = !!msg?.isPaused; member.positionTicks = Number(msg?.positionTicks) || 0; member.lastUpdatedUtc = Date.now(); party.lastActivityUtc = Date.now(); // Logged (and the lobby refreshed) only on an actual play/pause // transition, not every one of these - the host's player also // re-sends the current state on a plain 3s heartbeat (see player), // which would otherwise spam this line - and every home-page // viewer's socket - for every active party every 3 seconds // regardless of whether anything actually changed. Between // transitions, the home page just ticks its own "elapsed" label // locally from the last broadcast position - see Home.ts. if (member.isPaused !== wasPaused) { const positionSeconds = Math.round(member.positionTicks / 10000000); Logger.party(`playstate partyId=${party.id} host=${Logger.who(userId, member.userName)} -> ${member.isPaused ? 'paused' : 'playing'} at ${positionSeconds}s`); this.broadcastLobby(io); } socket.to(party.id).emit('playstate', { isPaused: member.isPaused, positionTicks: member.positionTicks }); })); socket.on('next', this.safe(() => { if (!party || userId !== party.hostUserId) { return; // only the host can advance the queue } if (PartyService.advanceQueue(party)) { io.to(party.id).emit('itemChanged', { itemId: party.itemId, itemName: party.itemName, itemImageUrl: party.itemImageUrl, queueIndex: party.queueIndex, queueLength: party.queue.length, hasNext: party.queueIndex < party.queue.length - 1 }); // advanceQueue() already reset every member's position/pause to // (0, false) for the new item - refresh the lobby now so the // home page's card picks up the new title/poster AND that reset // immediately, rather than only whenever some other event (a // join/leave) happens to refresh it next. this.broadcastLobby(io); } })); // Host-only, video-only: picks (or clears) the party's *shared* // burn-in stream - see TranscodeService and Party.burnInSubtitleIndex's // own comment. Deliberately doesn't touch TranscodeService directly // here - this only updates party state and broadcasts it, the same // way "next" only updates queueIndex and broadcasts "itemChanged" // without reaching into CatalogService itself. The actual ffmpeg // session starts/restarts lazily, whenever a client's