All checks were successful
Docker / build-and-push (push) Successful in 1m18s
Based on bingo-bango by f8al (MIT) — same core card-generation idea
("both facets": a square is either an exact song title or an artist,
and an artist square lights up on any of their tracks), reworked with
a real host/guest split, live multiplayer sessions, and cover-art
squares. f8al credited in the footer, README, and PROPOSAL.md.
Three ways to play: host a live session (server-side Spotify
connection, join code + QR, caller screen, auto-call from real
playback), join someone else's session (no login needed), or solo
(guest PKCE login or a pasted public playlist URL, one-off card).
Genuinely verified against a real Spotify account throughout, not
just built and assumed working — including two real bugs found and
fixed along the way (Spotify's playlist-tracks endpoint quietly
renamed to /items with a reshaped response; reading playlist tracks
needs real user auth even for public playlists, so the "no guest
login" public-playlist path routes through the host's connection
instead of a dead-end app-only token). Both Docker images built and
run together on a real network with the nginx proxy verified working
end-to-end, and auto-call confirmed detecting an actual track playing
live through Spotify Connect within one poll tick.
167 lines
6.2 KiB
JavaScript
167 lines
6.2 KiB
JavaScript
// 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<string, Session>} */
|
|
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;
|