Initial build: fullhouse, live music bingo from a real Spotify playlist
All checks were successful
Docker / build-and-push (push) Successful in 1m18s
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.
This commit is contained in:
122
server/routes/hostAuth.js
Normal file
122
server/routes/hostAuth.js
Normal file
@@ -0,0 +1,122 @@
|
||||
// Host-side Spotify connection — Authorization Code flow, refresh token
|
||||
// persisted server-side. Same shape as goonk's api/routes/spotify.mjs
|
||||
// (a proven pattern in this family), not the guest PKCE flow used
|
||||
// elsewhere in this app — this is a single, long-lived connection to
|
||||
// the host's own account, not a per-visitor login.
|
||||
import { Router } from 'express';
|
||||
import { readFile, writeFile, mkdir } from 'fs/promises';
|
||||
import { join, dirname } from 'path';
|
||||
import { fileURLToPath } from 'url';
|
||||
import crypto from 'crypto';
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
const router = Router();
|
||||
|
||||
const CLIENT_ID = process.env.SPOTIFY_CLIENT_ID;
|
||||
const CLIENT_SECRET = process.env.SPOTIFY_CLIENT_SECRET;
|
||||
const REDIRECT_URI = process.env.SPOTIFY_HOST_REDIRECT_URI || 'http://127.0.0.1:3010/api/spotify/callback';
|
||||
// playlist-read-*: list/read the host's own playlists as a song pool.
|
||||
// user-read-currently-playing + user-read-playback-state: the live
|
||||
// now-playing auto-call poller.
|
||||
const SCOPES = 'playlist-read-private playlist-read-collaborative user-read-currently-playing user-read-playback-state';
|
||||
|
||||
const TOKEN_FILE = join(__dirname, '..', 'data', 'host-token.json');
|
||||
|
||||
let accessToken = null;
|
||||
let accessTokenExpiresAt = 0;
|
||||
let refreshToken = null;
|
||||
|
||||
async function loadRefreshToken() {
|
||||
if (refreshToken) return refreshToken;
|
||||
try {
|
||||
const raw = await readFile(TOKEN_FILE, 'utf8');
|
||||
refreshToken = JSON.parse(raw).refresh_token;
|
||||
} catch {
|
||||
refreshToken = null;
|
||||
}
|
||||
return refreshToken;
|
||||
}
|
||||
|
||||
async function saveRefreshToken(token) {
|
||||
refreshToken = token;
|
||||
await mkdir(dirname(TOKEN_FILE), { recursive: true });
|
||||
await writeFile(TOKEN_FILE, JSON.stringify({ refresh_token: token }, null, 2), 'utf8');
|
||||
}
|
||||
|
||||
async function tokenRequest(params) {
|
||||
const res = await fetch('https://accounts.spotify.com/api/token', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/x-www-form-urlencoded',
|
||||
Authorization: 'Basic ' + Buffer.from(`${CLIENT_ID}:${CLIENT_SECRET}`).toString('base64'),
|
||||
},
|
||||
body: new URLSearchParams(params).toString(),
|
||||
});
|
||||
if (!res.ok) throw new Error(`Spotify token request → ${res.status}: ${await res.text()}`);
|
||||
return res.json();
|
||||
}
|
||||
|
||||
export async function getHostAccessToken() {
|
||||
if (accessToken && Date.now() < accessTokenExpiresAt) return accessToken;
|
||||
|
||||
const rt = await loadRefreshToken();
|
||||
if (!rt) throw new Error('host not connected — visit /api/spotify/login first');
|
||||
|
||||
const data = await tokenRequest({ grant_type: 'refresh_token', refresh_token: rt });
|
||||
accessToken = data.access_token;
|
||||
accessTokenExpiresAt = Date.now() + (data.expires_in - 60) * 1000;
|
||||
if (data.refresh_token) await saveRefreshToken(data.refresh_token);
|
||||
return accessToken;
|
||||
}
|
||||
|
||||
export async function isHostConnected() {
|
||||
return Boolean(await loadRefreshToken());
|
||||
}
|
||||
|
||||
export async function spotifyAsHost(path) {
|
||||
const token = await getHostAccessToken();
|
||||
const res = await fetch(`https://api.spotify.com/v1${path}`, {
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
});
|
||||
if (res.status === 204) return null;
|
||||
if (!res.ok) throw new Error(`Spotify ${path} → ${res.status}`);
|
||||
return res.json();
|
||||
}
|
||||
|
||||
router.get('/login', (req, res) => {
|
||||
const state = crypto.randomBytes(16).toString('hex');
|
||||
res.cookie('fh_host_auth_state', state, { httpOnly: true, maxAge: 5 * 60 * 1000 });
|
||||
const url = new URL('https://accounts.spotify.com/authorize');
|
||||
url.search = new URLSearchParams({
|
||||
response_type: 'code',
|
||||
client_id: CLIENT_ID,
|
||||
scope: SCOPES,
|
||||
redirect_uri: REDIRECT_URI,
|
||||
state,
|
||||
}).toString();
|
||||
res.redirect(url.toString());
|
||||
});
|
||||
|
||||
router.get('/callback', async (req, res) => {
|
||||
const { code, state, error } = req.query;
|
||||
if (error) return res.status(400).send(`Spotify auth error: ${error}`);
|
||||
if (!state || state !== req.cookies?.fh_host_auth_state) {
|
||||
return res.status(400).send('State mismatch — try /api/spotify/login again');
|
||||
}
|
||||
try {
|
||||
const data = await tokenRequest({ grant_type: 'authorization_code', code, redirect_uri: REDIRECT_URI });
|
||||
await saveRefreshToken(data.refresh_token);
|
||||
accessToken = data.access_token;
|
||||
accessTokenExpiresAt = Date.now() + (data.expires_in - 60) * 1000;
|
||||
res.clearCookie('fh_host_auth_state');
|
||||
res.send('Host Spotify connected — you can close this tab and start a session.');
|
||||
} catch (e) {
|
||||
res.status(502).send(`Token exchange failed: ${e.message}`);
|
||||
}
|
||||
});
|
||||
|
||||
router.get('/status', async (req, res) => {
|
||||
res.json({ connected: await isHostConnected() });
|
||||
});
|
||||
|
||||
export default router;
|
||||
88
server/routes/hostPlaylists.js
Normal file
88
server/routes/hostPlaylists.js
Normal file
@@ -0,0 +1,88 @@
|
||||
import { Router } from 'express';
|
||||
import { spotifyAsHost } from './hostAuth.js';
|
||||
|
||||
const router = Router();
|
||||
|
||||
// Simple in-memory TTL cache — playlists/tracks don't need to be fresher
|
||||
// than this, and it keeps a session-start from hammering Spotify's API.
|
||||
const CACHE_TTL_MS = 5 * 60 * 1000;
|
||||
const cache = new Map();
|
||||
function cached(key, fn) {
|
||||
const hit = cache.get(key);
|
||||
if (hit && Date.now() - hit.ts < CACHE_TTL_MS) return Promise.resolve(hit.data);
|
||||
return fn().then(data => { cache.set(key, { data, ts: Date.now() }); return data; });
|
||||
}
|
||||
|
||||
function summarizePlaylist(p) {
|
||||
return {
|
||||
id: p.id,
|
||||
name: p.name,
|
||||
trackCount: p.items?.total ?? p.tracks?.total ?? 0, // Spotify's field name for this varies by endpoint/version; checked the real response directly
|
||||
image: p.images?.[0]?.url ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
// Both facets of the card, plus cover art — track title, primary artist,
|
||||
// and album art, everything a square could render as.
|
||||
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,
|
||||
};
|
||||
}
|
||||
|
||||
// GET /api/host/playlists — the host's own playlists as candidate song pools.
|
||||
router.get('/playlists', async (req, res) => {
|
||||
try {
|
||||
const data = await cached('host-playlists', async () => {
|
||||
const out = [];
|
||||
let url = '/me/playlists?limit=50';
|
||||
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(summarizePlaylist);
|
||||
});
|
||||
res.json(data);
|
||||
} catch (e) {
|
||||
res.status(502).json({ error: e.message });
|
||||
}
|
||||
});
|
||||
|
||||
// GET /api/host/playlist/:id/tracks — full track pool for one playlist.
|
||||
router.get('/playlist/:id/tracks', async (req, res) => {
|
||||
try {
|
||||
const data = await cached(`host-tracks:${req.params.id}`, async () => {
|
||||
const out = [];
|
||||
let url = `/playlists/${req.params.id}/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);
|
||||
});
|
||||
res.json(data);
|
||||
} catch (e) {
|
||||
res.status(502).json({ error: e.message });
|
||||
}
|
||||
});
|
||||
|
||||
// GET /api/host/now-playing — thin wrapper for the auto-call poller.
|
||||
router.get('/now-playing', async (req, res) => {
|
||||
try {
|
||||
const data = await spotifyAsHost('/me/player/currently-playing');
|
||||
res.json(data ?? { playing: false });
|
||||
} catch (e) {
|
||||
res.status(502).json({ error: e.message });
|
||||
}
|
||||
});
|
||||
|
||||
export default router;
|
||||
83
server/routes/publicPlaylist.js
Normal file
83
server/routes/publicPlaylist.js
Normal file
@@ -0,0 +1,83 @@
|
||||
// Public-playlist-by-URL lookup — a guest pastes any public Spotify
|
||||
// playlist URL and gets a card, with no login of their own required.
|
||||
//
|
||||
// This originally used a Client Credentials (app-only) token so it
|
||||
// wouldn't need anyone's login at all — but Spotify's playlist-items
|
||||
// endpoint returns 401 "Valid user authentication required" even for
|
||||
// fully public playlists under a pure app token (confirmed directly,
|
||||
// not assumed). Routes through the host's already-authenticated
|
||||
// connection instead: still genuinely user-authenticated (satisfies
|
||||
// Spotify's requirement), and guests still never log into anything —
|
||||
// the host's session is what's doing the asking on their behalf.
|
||||
import { Router } from 'express';
|
||||
import { getHostAccessToken } from './hostAuth.js';
|
||||
|
||||
const router = Router();
|
||||
|
||||
function extractPlaylistId(input) {
|
||||
const trimmed = input.trim();
|
||||
const urlMatch = trimmed.match(/playlist[/:]([a-zA-Z0-9]+)/);
|
||||
if (urlMatch) return urlMatch[1];
|
||||
if (/^[a-zA-Z0-9]{10,30}$/.test(trimmed)) return trimmed; // bare ID
|
||||
return null;
|
||||
}
|
||||
|
||||
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,
|
||||
};
|
||||
}
|
||||
|
||||
// GET /api/public-playlist?url=<spotify playlist URL or ID>
|
||||
router.get('/', async (req, res) => {
|
||||
const id = extractPlaylistId(String(req.query.url || ''));
|
||||
if (!id) return res.status(400).json({ error: 'could not find a playlist id in that URL' });
|
||||
|
||||
try {
|
||||
const token = await getHostAccessToken();
|
||||
const authed = (path) => fetch(`https://api.spotify.com/v1${path}`, {
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
});
|
||||
|
||||
const metaRes = await authed(`/playlists/${id}?fields=name,images,public`);
|
||||
if (!metaRes.ok) {
|
||||
const status = metaRes.status;
|
||||
return res.status(status === 404 ? 404 : 502).json({
|
||||
error: status === 404 ? 'playlist not found' : `Spotify → ${status}`,
|
||||
});
|
||||
}
|
||||
const meta = await metaRes.json();
|
||||
if (meta.public === false) {
|
||||
return res.status(403).json({ error: 'this playlist is private — only the playlist owner can use it as a song pool' });
|
||||
}
|
||||
|
||||
const tracks = [];
|
||||
let url = `/playlists/${id}/items?limit=100&fields=next,items(item(id,name,uri,artists(name),album(name,images)))`;
|
||||
while (url) {
|
||||
const pageRes = await authed(url);
|
||||
if (!pageRes.ok) {
|
||||
return res.status(502).json({ error: `Spotify → ${pageRes.status} while reading tracks (page may be an algorithmic/editorial playlist, which Spotify restricts third-party access to)` });
|
||||
}
|
||||
const page = await pageRes.json();
|
||||
tracks.push(...(page.items ?? []));
|
||||
url = page.next ? page.next.replace('https://api.spotify.com/v1', '') : null;
|
||||
}
|
||||
|
||||
res.json({
|
||||
name: meta.name,
|
||||
image: meta.images?.[0]?.url ?? null,
|
||||
tracks: tracks.map(summarizeTrack).filter(Boolean),
|
||||
});
|
||||
} catch (e) {
|
||||
res.status(502).json({ error: e.message });
|
||||
}
|
||||
});
|
||||
|
||||
export default router;
|
||||
166
server/routes/session.js
Normal file
166
server/routes/session.js
Normal file
@@ -0,0 +1,166 @@
|
||||
// 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;
|
||||
Reference in New Issue
Block a user