// Live session state — deliberately in-memory, deliberately polled by // clients rather than pushed over a socket. A session is one game night; // nothing here needs to survive a restart or scale past one process. // Same "boring beats clever" precedent as keep's `watch` command and // wisp's TTL sweep elsewhere in this family. import { Router } from 'express'; import crypto from 'crypto'; import { spotifyAsHost } from './hostAuth.js'; const router = Router(); /** @type {Map} */ const sessions = new Map(); // code -> interval handle, for the now-playing auto-call poller const autoCallTimers = new Map(); function makeCode() { // Short, spoken-aloud-friendly: no 0/O/1/I ambiguity. const alphabet = 'ABCDEFGHJKLMNPQRSTUVWXYZ23456789'; let code; do { code = Array.from({ length: 5 }, () => alphabet[crypto.randomInt(alphabet.length)]).join(''); } while (sessions.has(code)); return code; } function summarizeTrack(entry) { const t = entry.item; if (!t) return null; return { id: t.id, title: t.name, artist: t.artists?.[0]?.name ?? 'Unknown artist', album: t.album?.name ?? '', image: t.album?.images?.[0]?.url ?? null, uri: t.uri, }; } async function fetchPlaylistTracks(playlistId) { const out = []; let url = `/playlists/${playlistId}/items?limit=100&fields=next,items(item(id,name,uri,artists(name),album(name,images)))`; while (url) { const page = await spotifyAsHost(url); out.push(...(page.items ?? [])); url = page.next ? page.next.replace('https://api.spotify.com/v1', '') : null; } return out.map(summarizeTrack).filter(Boolean); } // POST /api/session — body: { playlistId, playlistName } router.post('/', async (req, res) => { const { playlistId, playlistName } = req.body ?? {}; if (!playlistId) return res.status(400).json({ error: 'playlistId required' }); try { const tracks = await fetchPlaylistTracks(playlistId); if (tracks.length < 12) { return res.status(400).json({ error: `playlist has only ${tracks.length} usable tracks — need at least 12 for a 3x3 card with headroom` }); } const code = makeCode(); sessions.set(code, { code, playlistName: playlistName || 'Untitled session', tracks, calledIds: [], autoCall: false, createdAt: Date.now(), }); res.json({ code, playlistName, trackCount: tracks.length }); } catch (e) { res.status(502).json({ error: e.message }); } }); function requireSession(req, res, next) { const session = sessions.get(String(req.params.code || '').toUpperCase()); if (!session) return res.status(404).json({ error: 'session not found — check the code' }); req.session = session; next(); } // GET /api/session/:code — join payload: full pool (for client-side card // generation) + what's been called so far. router.get('/:code', requireSession, (req, res) => { const { code, playlistName, tracks, calledIds, autoCall } = req.session; res.json({ code, playlistName, tracks, calledIds, autoCall }); }); // GET /api/session/:code/state — cheap polling target, no full pool. router.get('/:code/state', requireSession, (req, res) => { const { calledIds, autoCall } = req.session; res.json({ calledIds, autoCall }); }); // POST /api/session/:code/call — body: { trackId } — manual call. router.post('/:code/call', requireSession, (req, res) => { const { trackId } = req.body ?? {}; const track = req.session.tracks.find(t => t.id === trackId); if (!track) return res.status(400).json({ error: 'trackId not in this session pool' }); if (!req.session.calledIds.includes(trackId)) req.session.calledIds.push(trackId); res.json({ calledIds: req.session.calledIds }); }); // POST /api/session/:code/call-random — classic caller draw, no replacement. router.post('/:code/call-random', requireSession, (req, res) => { const remaining = req.session.tracks.filter(t => !req.session.calledIds.includes(t.id)); if (!remaining.length) return res.status(409).json({ error: 'every track has already been called' }); const pick = remaining[crypto.randomInt(remaining.length)]; req.session.calledIds.push(pick.id); res.json({ called: pick, calledIds: req.session.calledIds }); }); // POST /api/session/:code/autocall/start — polls the host's actual // Spotify playback and auto-calls whatever's currently playing, if it's // in this session's pool. Manual /call and /call-random keep working // alongside it — this is a bonus layer, not a replacement. router.post('/:code/autocall/start', requireSession, (req, res) => { const { code } = req.session; if (autoCallTimers.has(code)) return res.json({ ok: true, already: true }); let lastSeenTrackId = null; const timer = setInterval(async () => { const session = sessions.get(code); if (!session) { clearInterval(timer); autoCallTimers.delete(code); return; } try { const playing = await spotifyAsHost('/me/player/currently-playing'); const trackId = playing?.item?.id; if (!trackId || trackId === lastSeenTrackId) return; lastSeenTrackId = trackId; if (session.tracks.some(t => t.id === trackId) && !session.calledIds.includes(trackId)) { session.calledIds.push(trackId); } } catch { // host playback unavailable this tick — try again next tick, not fatal } }, 5000); autoCallTimers.set(code, timer); req.session.autoCall = true; res.json({ ok: true }); }); router.post('/:code/autocall/stop', requireSession, (req, res) => { const { code } = req.session; const timer = autoCallTimers.get(code); if (timer) { clearInterval(timer); autoCallTimers.delete(code); } req.session.autoCall = false; res.json({ ok: true }); }); // Sessions are ephemeral by design — sweep anything older than 12h so a // forgotten session doesn't sit in memory forever. Deliberately dumb // interval, same precedent as the rest of this family. setInterval(() => { const cutoff = Date.now() - 12 * 60 * 60 * 1000; for (const [code, session] of sessions) { if (session.createdAt < cutoff) { const timer = autoCallTimers.get(code); if (timer) { clearInterval(timer); autoCallTimers.delete(code); } sessions.delete(code); } } }, 60 * 60 * 1000); export default router;