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:
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;
|
||||
Reference in New Issue
Block a user