'use strict'; const crypto = require('crypto'); // No 0/O/1/I - easy to read aloud / type on a phone. const JOIN_CODE_ALPHABET = 'ABCDEFGHJKLMNPQRSTUVWXYZ23456789'; function generateJoinCode() { const bytes = crypto.randomBytes(6); let code = ''; for (let i = 0; i < 6; i++) { code += JOIN_CODE_ALPHABET[bytes[i] % JOIN_CODE_ALPHABET.length]; } return code; } /** * In-memory party store - mirrors the Party/PartyMember/PartyManager design * from the earlier Emby-plugin attempt (see git history / docs/architecture.md), * adapted for a model where we own the player: one host drives playback, * everyone else's client just applies whatever the host reports. Nothing is * persisted to disk; a server restart clears every party, which is fine - a * party is meant to live for one watch session, not persist for weeks. */ class PartyManager { constructor() { this.partiesById = new Map(); this.partyIdByJoinCode = new Map(); } create(hostUserId, hostUserName, itemId, itemName, itemImageUrl) { const party = { id: crypto.randomUUID(), joinCode: generateJoinCode(), itemId, itemName, itemImageUrl, hostUserId, createdUtc: Date.now(), lastActivityUtc: Date.now(), // userId -> { userId, userName, connection, isPaused, positionTicks, lastUpdatedUtc } members: new Map() }; party.members.set(hostUserId, this._newMember(hostUserId, hostUserName)); this.partiesById.set(party.id, party); this.partyIdByJoinCode.set(party.joinCode, party.id); return party; } getById(partyId) { return this.partiesById.get(partyId) || null; } getByJoinCode(joinCode) { const id = this.partyIdByJoinCode.get((joinCode || '').toUpperCase()); return id ? this.getById(id) : null; } join(party, userId, userName) { const member = this._newMember(userId, userName); party.members.set(userId, member); party.lastActivityUtc = Date.now(); return member; } leave(party, userId) { party.members.delete(userId); party.lastActivityUtc = Date.now(); if (party.hostUserId === userId) { // Host left: promote whoever's left (arbitrary but deterministic-ish; // good enough for v1 - matches the old plugin's reference-fallback logic). const next = party.members.keys().next(); party.hostUserId = next.done ? null : next.value; } if (party.members.size === 0) { this.partiesById.delete(party.id); this.partyIdByJoinCode.delete(party.joinCode); } } /** All active parties - powers the "watch parties en cours" home section. */ listActive() { return Array.from(this.partiesById.values()); } removeIdleParties(idleTimeoutMs) { const cutoff = Date.now() - idleTimeoutMs; for (const party of this.partiesById.values()) { if (party.lastActivityUtc < cutoff) { this.partiesById.delete(party.id); this.partyIdByJoinCode.delete(party.joinCode); } } } /** * `viewerUserId` lets the response say "youAreHost" instead of the client * having to know its own Emby user id to figure that out itself - now that * auth is a server-side session (see embyAuth.js), the client doesn't hold * an id to compare with at all. */ toPublicState(party, viewerUserId) { const host = party.members.get(party.hostUserId); return { partyId: party.id, joinCode: party.joinCode, itemId: party.itemId, itemName: party.itemName, itemImageUrl: party.itemImageUrl, hostUserName: host ? host.userName : null, memberCount: party.members.size, youAreHost: viewerUserId != null && viewerUserId === party.hostUserId, members: Array.from(party.members.values()).map((m) => ({ userId: m.userId, userName: m.userName, isHost: m.userId === party.hostUserId, isPaused: m.isPaused, positionTicks: m.positionTicks, connected: !!m.connection })) }; } _newMember(userId, userName) { return { userId, userName, connection: null, isPaused: true, positionTicks: 0, lastUpdatedUtc: Date.now() }; } } module.exports = { PartyManager: new PartyManager() };