Initial build: fullhouse, live music bingo from a real Spotify playlist
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:
Fredrik Johansson
2026-07-18 22:05:37 +02:00
commit d99c44cb9d
26 changed files with 3778 additions and 0 deletions

12
.env.example Normal file
View File

@@ -0,0 +1,12 @@
# Used both at deploy time (docker-compose.yml) and by CI (as a repo secret).
IMAGE=repo.explewd.com/explewd/fullhouse
HOST_PORT=3080
# Spotify app credentials — developer.spotify.com/dashboard. Needs a
# registered redirect URI matching SPOTIFY_HOST_REDIRECT_URI below
# exactly (Spotify does exact string matching, trailing slash included),
# plus a separate one for the guest PKCE flow: <your domain>/ (root,
# no path — see client/src/lib/pkce.ts).
SPOTIFY_CLIENT_ID=
SPOTIFY_CLIENT_SECRET=
SPOTIFY_HOST_REDIRECT_URI=https://fullhouse.yourdomain.com/api/spotify/callback

View File

@@ -0,0 +1,72 @@
name: Docker
on:
push:
branches: [main]
workflow_dispatch:
jobs:
build-and-push:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Derive image name and tags
id: meta
shell: bash
run: |
set -euo pipefail
SERVER_URL="${GITHUB_SERVER_URL:-${GITEA_SERVER_URL:-}}"
REPO="${GITHUB_REPOSITORY:-${GITEA_REPOSITORY:-}}"
SHA="${GITHUB_SHA:-${GITEA_SHA:-}}"
if [[ -z "${SERVER_URL}" || -z "${REPO}" || -z "${SHA}" ]]; then
echo "Missing SERVER_URL/REPO/SHA env vars." >&2
exit 1
fi
HOST=$(echo "${SERVER_URL}" | sed 's|https://||;s|http://||')
IMAGE=$(echo "${HOST}/${REPO}" | tr '[:upper:]' '[:lower:]')
SHORT_SHA=$(echo "${SHA}" | cut -c1-7)
echo "host=${HOST}" >> "$GITHUB_OUTPUT"
echo "image=${IMAGE}" >> "$GITHUB_OUTPUT"
echo "short_sha=${SHORT_SHA}" >> "$GITHUB_OUTPUT"
- name: Log in to Gitea container registry
uses: docker/login-action@v3
with:
registry: ${{ steps.meta.outputs.host }}
username: ${{ github.actor }}
# Create a Gitea PAT with packages:write scope and add it as a
# repository secret named TKNTKN (Settings → Secrets)
password: ${{ secrets.TKNTKN }}
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Build and push (web)
uses: docker/build-push-action@v6
with:
context: .
file: Dockerfile
push: true
# Baked in at build time (Vite env vars are compile-time) —
# add SPOTIFY_CLIENT_ID as a repo secret too.
build-args: |
VITE_SPOTIFY_CLIENT_ID=${{ secrets.SPOTIFY_CLIENT_ID }}
tags: |
${{ steps.meta.outputs.image }}:latest
${{ steps.meta.outputs.image }}:${{ steps.meta.outputs.short_sha }}
- name: Build and push (api)
uses: docker/build-push-action@v6
with:
context: .
file: Dockerfile.api
push: true
tags: |
${{ steps.meta.outputs.image }}-api:latest
${{ steps.meta.outputs.image }}-api:${{ steps.meta.outputs.short_sha }}

5
.gitignore vendored Normal file
View File

@@ -0,0 +1,5 @@
node_modules/
dist/
.env
server/data/
*.log

24
Dockerfile Normal file
View File

@@ -0,0 +1,24 @@
# ── Stage 1: build the Vite client ────────────────────────────────────────
FROM node:22-alpine AS build
WORKDIR /app/client
COPY client/package.json client/package-lock.json* ./
RUN npm install
COPY client/ .
# Vite bakes VITE_* vars in at build time, not runtime — has to be a
# real build ARG, not just a container env var set later.
ARG VITE_SPOTIFY_CLIENT_ID
ENV VITE_SPOTIFY_CLIENT_ID=${VITE_SPOTIFY_CLIENT_ID}
# Deliberately empty: relies on nginx same-origin-proxying /api to the
# api service (see nginx.conf) instead of an absolute cross-origin URL
# — the CORS bug that broke local dev earlier in this build.
ENV VITE_API_BASE=
RUN npx tsc && npx vite build
# ── Stage 2: serve ─────────────────────────────────────────────────────────
FROM nginx:alpine
COPY --from=build /app/client/dist /usr/share/nginx/html
COPY nginx.conf /etc/nginx/conf.d/default.conf
EXPOSE 80

7
Dockerfile.api Normal file
View File

@@ -0,0 +1,7 @@
FROM node:22-alpine
WORKDIR /app
COPY server/package.json server/package-lock.json* ./
RUN npm install --omit=dev
COPY server/ .
EXPOSE 3010
CMD ["node", "index.js"]

117
PROPOSAL.md Normal file
View File

@@ -0,0 +1,117 @@
# Proposal: `fullhouse` — music bingo, live-session and self-service
**Status:** proposed, build in progress. Self-contained — written so a fresh
agent with no prior conversation context can pick this up and implement it
without anything explained first.
## Origin & attribution
Inspired by [bingo-bango](https://github.com/f8al/bingo-bango) by **f8al**
a fully client-side (Spotify PKCE, no backend) music bingo card generator.
`fullhouse` keeps the core idea (Spotify playlist → bingo cards, "both
facets" title+artist marking) and the card-generation algorithm's shape, but
changes the architecture significantly enough that it's a fresh build, not a
fork: a real server-side host mode, live multiplayer sessions, cover-art
squares, and heavier use of the Spotify API. MIT license from the original
requires the copyright notice + license text be retained wherever the
original code/algorithm is directly reused — credit f8al clearly in the
README and footer regardless of how much code ends up shared verbatim.
## Motivation
The original requires *every player* to individually log into Spotify to
pick a playlist — real friction at an actual game night where guests don't
want to OAuth their own account just to get a bingo card. The fix: a proper
host/guest split.
## Modes
Both exist simultaneously, chosen per session, not one replacing the other:
1. **Host mode** — the host (the site owner) has a real server-side Spotify
connection (same shape as `goonk`'s existing `spotify.mjs`: Authorization
Code flow, refresh token persisted server-side, no per-visitor login).
Host picks from *their own* playlists (`playlist-read-private` +
`playlist-read-collaborative` scopes) as the song pool for a session.
2. **Guest self-service mode** — the original flow, kept: a visitor with no
relationship to the host logs in via their own Spotify PKCE flow and
generates a card from their own playlist, standalone, no session/host
involved. Also supports pasting a public playlist URL with **no login at
all**, via an app-level Client Credentials token (public playlists don't
need user auth to read) — simpler than PKCE for the common "just grab a
public playlist" case, and something the original doesn't offer.
## The four differentiators (all in scope for this build)
1. **Host-curated mode** — described above. Solves the real friction.
2. **Live session mode** — host starts a session, gets a join code + QR.
Guests join via the code (no login required in a host-curated session —
the host already authorized the song pool), each gets an independently
seeded card. A "caller" screen shows the current track large, for the
host to read aloud or leave visible on a shared screen. State sync is
**polling**, not WebSockets — deliberately, matching this whole family's
precedent (`keep watch`, `wisp`'s sweep): a session isn't latency-
sensitive enough to justify the operational complexity of a socket
server. Poll every 3-5s for "what's been called."
3. **Live now-playing auto-call** — if the host is actually playing the
session's pool through Spotify Connect, poll `/me/player/currently-
playing` (same pattern as `goonk`'s `now-playing` route) and
auto-mark a track "called" the moment it starts, matched against the
pool by track ID. Falls back to a manual "call next" button (classic
random-draw-without-replacement bingo caller) for hosts not using live
Spotify Connect playback during the game — auto-call is a bonus layer
over manual, not a replacement for it.
4. **Cover-art squares** — squares can show album art (from the same
playlist-track API response, `track.album.images`, already fetched for
everything else) instead of, or alongside, text. A visual variant beyond
what the original offers.
## Architecture
**Server** (small Express app, same shape as `goonk`'s `api/`):
- Host OAuth: `/auth/login`, `/auth/callback`, refresh-token persistence —
ported near-verbatim in pattern (not code) from `goonk/api/routes/
spotify.mjs`, since that flow is already proven correct in this family.
- `GET /api/host/playlists` — the host's own playlists, cached.
- `GET /api/host/playlist/:id/tracks` — full track+artist+album-art data
for a chosen playlist, cached.
- `GET /api/public-playlist?url=` — Client Credentials app token, fetch a
*public* playlist's tracks with no user login. Client Credentials tokens
are cached in memory and refreshed on expiry, same shape as the existing
user-token cache pattern.
- Session state: `POST /api/session` (host creates one from a chosen
playlist + mode), `GET /api/session/:code` (guest join — hands back pool
metadata, NOT the host's Spotify tokens), `POST /api/session/:code/call`
(host marks a track called, or the auto-call poller does), `GET /api/
session/:code/state` (guests poll this for the called-track list).
In-memory session store is fine — sessions are ephemeral (a single game
night), no need for persistence across restarts.
- `GET /api/host/now-playing` — thin wrapper the auto-call poller uses,
same call shape as `goonk`'s existing now-playing route.
**Client**: Vite + TypeScript, no framework — matches the `typo`/`latent`
family's "plain, no heavy build" ethos more than the original's React
choice. The card-generation engine is a pure, dependency-free TS module
(same shape as the original's `src/cards/`), extended with a `coverArt`
render mode alongside the text mode.
**Guest PKCE mode** stays fully client-side (no server involvement at all)
for the self-service path — same as the original, since that's the whole
point of PKCE (no secret needed, tokens never touch a server the host
controls).
## Theming
Dark/mono/green house style shared with `typo`/`flit`/`wisp` (see any of
those `style.css` files for the exact CSS custom properties), not the
original's own visual design. Reads as part of this family, not a
standalone fork.
## Non-goals
- No accounts beyond the host's own Spotify connection — guests never
create anything, a join code is the only credential a session needs.
- No persistent session history — a session lives in memory for its game
night and is gone after.
- Not trying to be a general Spotify app — scoped tightly to bingo card
generation + live session calling.

98
README.md Normal file
View File

@@ -0,0 +1,98 @@
# fullhouse
Music bingo, generated from a real Spotify playlist. Based on
[bingo-bango](https://github.com/f8al/bingo-bango) by **f8al** (MIT
licensed) — same core idea (a playlist becomes bingo cards, a square is
either an exact song title or an artist, marking a track lights up both
the title square and any of that artist's squares), reworked with a
real host/guest split, live multiplayer sessions, and cover-art squares.
See [PROPOSAL.md](PROPOSAL.md) for the original design writeup.
## Three ways to play
1. **Host a live session** — connect your own Spotify (once, server-side),
pick a playlist, get a join code + QR. A caller screen shows what's
playing; guests' cards sync automatically, no login required on their
end at all.
2. **Join a session** — enter a host's code, get a card, watch it sync.
3. **Play solo** — log into your own Spotify (PKCE, fully client-side,
no server involvement) or paste any public playlist URL, get a one-off
card with no session.
## Real bugs found and fixed while building this
Worth documenting since they weren't obvious and would trip up anyone
extending this:
- **Spotify quietly renamed `/playlists/{id}/tracks` to
`/playlists/{id}/items`**, with a reshaped response (`entry.item`
instead of `entry.track`). The old path returns a bare 403 with no
useful error message — looks exactly like a permissions problem, isn't
one. Confirmed directly against the live API, not assumed from docs.
- **Reading playlist tracks needs real user authentication, even for
fully public playlists** — a pure Client Credentials (app-only) token
gets `401 "Valid user authentication required"` on the `/items`
endpoint. The public-playlist-by-URL feature routes through the host's
already-authenticated connection instead of a dead-end app token —
guests still never log into anything, it's just genuinely
user-authenticated on the host's behalf.
- **Algorithmic/Spotify-owned editorial playlists** (Discover Weekly,
your own "Top 100" style auto-generated lists, etc.) are restricted
from third-party API access — this is real and documented (Spotify's
Nov 2024 API policy change), not a bug. They just won't appear as
usable pools.
- **Never point the client at an absolute cross-origin API URL** — it
gets silently CORS-blocked with no server-side CORS headers configured,
and default error handling can mask that as "not connected" instead of
a clear network error. Use relative `/api/*` paths everywhere; Vite's
dev proxy and nginx's prod proxy both handle the same-origin routing.
- **Vite's dev server can bind to IPv6 loopback (`[::1]`) only**,
depending on the host's DNS resolver order — silently unreachable via
`127.0.0.1`, which matters a lot here since Spotify's redirect-URI
security policy only exempts the literal `127.0.0.1` IP, not
`localhost`. `vite.config.ts` sets `server.host` explicitly.
## Local development
```bash
# server
cd server && npm install && npm run dev # :3010
# client (separate terminal)
cd client && npm install && npm run dev # :3080 in prod, :5190 locally
```
Register two redirect URIs in the Spotify dashboard (exact string match,
trailing slash matters):
- `http://127.0.0.1:3010/api/spotify/callback` — host mode
- `http://127.0.0.1:5190/` — guest PKCE mode (root, no path)
(`127.0.0.1`, not `localhost` — Spotify's HTTPS-required-for-redirect
policy only exempts the literal loopback IP.)
## Deploying
`docker compose up` using `docker-compose.yml` / `.env` (copy
`.env.example`). Two images: `web` (nginx serving the built client,
proxying `/api/*` same-origin to the api container) and `api` (the
Express server). CI builds and pushes both on push to `main`.
`VITE_SPOTIFY_CLIENT_ID` gets baked into the client at **build time**
(Vite env vars are compile-time, not runtime) — passed as a Docker build
arg in CI, sourced from a `SPOTIFY_CLIENT_ID` repo secret.
## Status
Verified end-to-end against a real Spotify account, not just built and
assumed working: host OAuth, real playlist listing and track reading
(after finding and fixing the `/items` rename), a live two-browser
session with the guest's card syncing on poll, self-mark toggling,
guest PKCE login and playlist reading, the public-playlist-by-URL path,
and auto-call correctly detecting a real track playing through Spotify
Connect within one poll tick.
## License
MIT. Retains attribution to f8al's original bingo-bango design.

24
client/index.html Normal file
View File

@@ -0,0 +1,24 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>fullhouse</title>
<link rel="stylesheet" href="/style.css" />
</head>
<body>
<div id="app">
<header>
<a href="#/" class="brand">&gt; <span class="accent">fullhouse</span></a>
<p class="tagline">music bingo from a real playlist</p>
</header>
<main id="view"></main>
<footer>
<p class="muted">
based on <a href="https://github.com/f8al/bingo-bango" target="_blank" rel="noopener">bingo-bango</a> by f8al (MIT) — reworked with live sessions, host mode, and cover-art squares
</p>
</footer>
</div>
<script type="module" src="/src/main.ts"></script>
</body>
</html>

1002
client/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

15
client/package.json Normal file
View File

@@ -0,0 +1,15 @@
{
"name": "fullhouse-client",
"version": "0.1.0",
"private": true,
"type": "module",
"scripts": {
"dev": "vite --port 5190",
"build": "tsc && vite build",
"preview": "vite preview --port 5190"
},
"devDependencies": {
"typescript": "^5.8.0",
"vite": "^5.4.0"
}
}

226
client/public/style.css Normal file
View File

@@ -0,0 +1,226 @@
:root {
--bg: #080808;
--bg-alt: #0e0e0e;
--text: #c8c8c8;
--muted: #555;
--heading: #f0f0f0;
--accent: #00e87a;
--accent-dim: rgba(0, 232, 122, 0.12);
--accent-border:rgba(0, 232, 122, 0.25);
--border: rgba(255, 255, 255, 0.07);
--shadow: 0 0 0 1px rgba(255,255,255,0.04), 0 4px 24px rgba(0,0,0,0.6);
--error: #ff6b6b;
--error-dim: rgba(255, 107, 107, 0.14);
--mono: 'JetBrains Mono', 'Fira Code', 'Cascadia Code', ui-monospace, monospace;
--sans: 'Inter', ui-sans-serif, system-ui, -apple-system, sans-serif;
font: 16px/1.6 var(--sans);
color: var(--text);
background: var(--bg);
color-scheme: dark;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
*, *::before, *::after { box-sizing: border-box; }
body { margin: 0; }
#app {
width: 720px;
max-width: 100%;
margin: 0 auto;
padding: 32px 16px 64px;
}
h1, h2, h3 { font-family: var(--mono); color: var(--heading); line-height: 1.15; margin: 0 0 0.5em; }
p { margin: 0; }
a { color: var(--accent); text-decoration: none; }
a:hover { text-decoration: underline; }
.accent { color: var(--accent); }
.muted { color: var(--muted); font-size: 0.85em; }
header { text-align: center; margin-bottom: 28px; }
header .brand { font-family: var(--mono); font-size: 1.6em; font-weight: 700; color: var(--heading); }
header .tagline { color: var(--muted); font-size: 0.95em; margin-top: 4px; }
footer { text-align: center; margin-top: 40px; }
footer a { color: var(--muted); text-decoration: underline; }
.card {
background: var(--bg-alt);
border: 1px solid var(--border);
border-radius: 10px;
padding: 20px;
box-shadow: var(--shadow);
margin-bottom: 16px;
}
.stack { display: flex; flex-direction: column; gap: 12px; }
.row { display: flex; gap: 10px; align-items: center; flex-wrap: wrap; }
.row.between { justify-content: space-between; }
.center { text-align: center; }
button, .btn {
font: inherit;
font-family: var(--mono);
background: var(--accent-dim);
color: var(--accent);
border: 1px solid var(--accent-border);
border-radius: 8px;
padding: 10px 18px;
cursor: pointer;
display: inline-block;
}
button:hover, .btn:hover { background: rgba(0, 232, 122, 0.2); text-decoration: none; }
button:disabled { opacity: 0.4; cursor: not-allowed; }
button.secondary { background: transparent; color: var(--text); border-color: var(--border); }
button.secondary:hover { border-color: var(--muted); background: rgba(255,255,255,0.04); }
input[type="text"], select {
font: inherit;
font-family: var(--mono);
background: var(--bg-alt);
color: var(--text);
border: 1px solid var(--border);
border-radius: 8px;
padding: 10px 12px;
width: 100%;
}
input[type="text"]:focus, select:focus { outline: none; border-color: var(--accent-border); }
.playlist-list { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 6px; max-height: 400px; overflow-y: auto; }
.playlist-row {
display: flex; align-items: center; gap: 12px;
padding: 8px; border-radius: 8px; cursor: pointer;
border: 1px solid transparent;
}
.playlist-row:hover { background: var(--accent-dim); border-color: var(--accent-border); }
.playlist-row img { width: 40px; height: 40px; border-radius: 6px; object-fit: cover; background: var(--border); }
.playlist-row .name { flex: 1; font-weight: 600; color: var(--heading); }
.playlist-row .count { color: var(--muted); font-size: 0.85em; }
.session-code {
font-family: var(--mono);
font-size: 2.2em;
letter-spacing: 0.15em;
font-weight: 700;
color: var(--accent);
text-align: center;
}
/* Bingo card grid */
.bingo-grid {
display: grid;
gap: 3px;
background: var(--border);
border: 1px solid var(--border);
border-radius: 8px;
overflow: hidden;
}
.bingo-square {
background: var(--bg-alt);
aspect-ratio: 1;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
text-align: center;
padding: 6px;
font-size: 0.72em;
line-height: 1.25;
color: var(--text);
position: relative;
overflow: hidden;
}
.bingo-square.free { background: var(--accent-dim); color: var(--accent); font-weight: 700; }
.bingo-square.marked { background: var(--accent-dim); box-shadow: inset 0 0 0 2px var(--accent); color: var(--heading); }
.bingo-square img.cover { position: absolute; inset: 0; width: 100%; height: 100%; object-fit: cover; opacity: 0.85; }
/* Full-width gradient strip anchored to the bottom, not a floating pill
— a fixed-opacity pill reads fine on a dark cover but disappears
against a busy/light one. A gradient that goes fully opaque at the
very bottom guarantees contrast regardless of what's under it. */
.bingo-square .label {
position: absolute;
left: 0; right: 0; bottom: 0;
z-index: 1;
padding: 14px 6px 6px;
background: linear-gradient(to bottom, transparent, rgba(0,0,0,0.85) 55%, rgba(0,0,0,0.92));
color: #fff;
text-shadow: 0 1px 2px rgba(0,0,0,0.8);
}
.bingo-square:not(:has(img.cover)) .label {
position: static;
background: none;
padding: 0;
text-shadow: none;
color: inherit;
}
.facet-badge {
display: inline-flex;
align-items: center;
justify-content: center;
width: 16px; height: 16px;
border-radius: 50%;
font-size: 0.85em;
line-height: 1;
}
.facet-badge.facet-title { background: var(--accent-dim); color: var(--accent); }
.facet-badge.facet-artist { background: rgba(255, 200, 0, 0.15); color: #ffc800; }
.bingo-square .facet-badge {
position: absolute; top: 4px; left: 4px; z-index: 2;
box-shadow: 0 1px 3px rgba(0,0,0,0.6);
}
.card-legend { font-size: 0.8em; margin-bottom: 10px; }
.card-legend .facet-badge { vertical-align: -3px; margin: 0 2px; }
.bingo-square.tappable { cursor: pointer; }
.bingo-square.tappable:hover { outline: 1px solid var(--accent-border); outline-offset: -1px; }
.bingo-square.marked::after {
content: '✓';
position: absolute; top: 4px; right: 4px; z-index: 2;
color: #fff; font-weight: 700;
background: var(--accent);
width: 16px; height: 16px;
border-radius: 50%;
display: flex; align-items: center; justify-content: center;
font-size: 0.75em;
box-shadow: 0 1px 3px rgba(0,0,0,0.6);
}
.bingo-banner {
text-align: center; padding: 16px; margin-top: 12px;
background: var(--accent-dim); border: 1px solid var(--accent-border);
border-radius: 8px; color: var(--accent); font-family: var(--mono); font-weight: 700;
font-size: 1.3em;
}
/* Caller screen */
.caller-now {
text-align: center;
padding: 32px 16px;
}
.caller-now .track-title { font-size: 1.8em; font-weight: 700; color: var(--heading); }
.caller-now .track-artist { color: var(--muted); margin-top: 4px; }
.caller-now img { width: 160px; height: 160px; border-radius: 10px; margin: 0 auto 16px; display: block; box-shadow: var(--shadow); }
.called-history { display: flex; flex-wrap: wrap; gap: 6px; margin-top: 12px; }
.called-chip {
font-size: 0.75em; padding: 4px 8px; border-radius: 999px;
background: var(--bg); border: 1px solid var(--border); color: var(--muted);
}
.error-box {
background: var(--error-dim); border: 1px solid var(--error);
color: var(--error); border-radius: 8px; padding: 12px 14px; font-size: 0.9em;
}
.toggle-row { display: flex; align-items: center; gap: 8px; }
@media (max-width: 480px) {
.bingo-square { font-size: 0.62em; }
}

View File

@@ -0,0 +1,140 @@
// Card generation engine. The "both facets" idea — a square can hold
// either a track title or an artist name, and playing a track marks
// both the title square and any artist squares for that artist — is
// f8al's design from bingo-bango (https://github.com/f8al/bingo-bango,
// MIT licensed). This module is a fresh implementation of that shape,
// not copied code, extended with a cover-art render mode the original
// doesn't have.
//
// Pure, dependency-free, deterministic given a seed — same property
// the original's engine has, load-bearing here too: a guest's card
// has to reproduce identically from a shareable seed/URL.
export type PoolTrack = {
id: string;
title: string;
artist: string;
album: string;
image: string | null;
};
export type SquareFacet = 'title' | 'artist' | 'free';
export type CardSquare = {
facet: SquareFacet;
text: string;
image: string | null;
/** Track ids that mark this square when called. A title square has
* exactly one; an artist square can have several if the artist
* appears more than once in the pool. */
matchIds: string[];
};
export type BingoCard = {
seed: number;
size: number;
squares: CardSquare[]; // row-major, length size*size
};
// Small, fast, deterministic PRNG (mulberry32) — no crypto needed, the
// point is reproducibility from a seed, not unpredictability.
function mulberry32(seed: number) {
let a = seed;
return function () {
a |= 0;
a = (a + 0x6d2b79f5) | 0;
let t = Math.imul(a ^ (a >>> 15), 1 | a);
t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
};
}
function shuffle<T>(arr: T[], rand: () => number): T[] {
const out = arr.slice();
for (let i = out.length - 1; i > 0; i--) {
const j = Math.floor(rand() * (i + 1));
[out[i], out[j]] = [out[j], out[i]];
}
return out;
}
export type CardOptions = {
size: 3 | 4 | 5;
freeSpace: boolean;
/** Roughly how much of the grid is artist squares vs title squares —
* 0.5 matches the original's ~50/50 split. */
artistRatio?: number;
};
export function generateCard(pool: PoolTrack[], seed: number, opts: CardOptions): BingoCard {
const { size, freeSpace, artistRatio = 0.5 } = opts;
const cellCount = size * size;
const needed = cellCount - (freeSpace ? 1 : 0);
if (pool.length < needed) {
throw new Error(`pool has ${pool.length} tracks, need at least ${needed} for a ${size}x${size} card`);
}
const rand = mulberry32(seed);
const chosen = shuffle(pool, rand).slice(0, needed);
// Group by artist so an artist square can list every matching track id.
const byArtist = new Map<string, PoolTrack[]>();
for (const t of pool) {
const list = byArtist.get(t.artist) ?? [];
list.push(t);
byArtist.set(t.artist, list);
}
const squares: CardSquare[] = chosen.map((track) => {
const useArtist = rand() < artistRatio;
if (useArtist) {
const matches = byArtist.get(track.artist) ?? [track];
return {
facet: 'artist',
text: track.artist,
image: track.image,
matchIds: matches.map((m) => m.id),
};
}
return {
facet: 'title',
text: track.title,
image: track.image,
matchIds: [track.id],
};
});
if (freeSpace) {
const center = Math.floor(cellCount / 2);
squares.splice(center, 0, { facet: 'free', text: 'FREE', image: null, matchIds: [] });
}
return { seed, size, squares };
}
/** Which squares light up when a track is called. */
export function markedIndices(card: BingoCard, calledTrackId: string): number[] {
const out: number[] = [];
card.squares.forEach((sq, i) => {
if (sq.matchIds.includes(calledTrackId)) out.push(i);
});
return out;
}
/** True if any full row, column, or diagonal is fully covered. */
export function hasBingo(card: BingoCard, markedSet: Set<number>): boolean {
const { size } = card;
const at = (r: number, c: number) => r * size + c;
const isMarked = (r: number, c: number) => card.squares[at(r, c)].facet === 'free' || markedSet.has(at(r, c));
for (let r = 0; r < size; r++) {
if (Array.from({ length: size }, (_, c) => isMarked(r, c)).every(Boolean)) return true;
}
for (let c = 0; c < size; c++) {
if (Array.from({ length: size }, (_, r) => isMarked(r, c)).every(Boolean)) return true;
}
if (Array.from({ length: size }, (_, i) => isMarked(i, i)).every(Boolean)) return true;
if (Array.from({ length: size }, (_, i) => isMarked(i, size - 1 - i)).every(Boolean)) return true;
return false;
}

45
client/src/lib/api.ts Normal file
View File

@@ -0,0 +1,45 @@
// Thin wrapper over fullhouse's own server (host mode + sessions).
// Guest PKCE calls go straight to Spotify instead — see pkce.ts.
const BASE = import.meta.env.VITE_API_BASE || '';
async function req<T>(path: string, opts?: RequestInit): Promise<T> {
const res = await fetch(`${BASE}${path}`, {
headers: { 'Content-Type': 'application/json' },
...opts,
});
const data = await res.json().catch(() => ({}));
if (!res.ok) throw new Error(data.error || `${path}${res.status}`);
return data;
}
export type HostPlaylist = { id: string; name: string; trackCount: number; image: string | null };
export type PoolTrack = { id: string; title: string; artist: string; album: string; image: string | null };
export const api = {
hostStatus: () => req<{ connected: boolean }>('/api/spotify/status'),
hostPlaylists: () => req<HostPlaylist[]>('/api/host/playlists'),
hostPlaylistTracks: (id: string) => req<PoolTrack[]>(`/api/host/playlist/${id}/tracks`),
publicPlaylist: (url: string) =>
req<{ name: string; image: string | null; tracks: PoolTrack[] }>(`/api/public-playlist?url=${encodeURIComponent(url)}`),
createSession: (playlistId: string, playlistName: string) =>
req<{ code: string; playlistName: string; trackCount: number }>('/api/session', {
method: 'POST',
body: JSON.stringify({ playlistId, playlistName }),
}),
joinSession: (code: string) =>
req<{ code: string; playlistName: string; tracks: PoolTrack[]; calledIds: string[]; autoCall: boolean }>(
`/api/session/${code}`
),
sessionState: (code: string) =>
req<{ calledIds: string[]; autoCall: boolean }>(`/api/session/${code}/state`),
callTrack: (code: string, trackId: string) =>
req<{ calledIds: string[] }>(`/api/session/${code}/call`, {
method: 'POST',
body: JSON.stringify({ trackId }),
}),
callRandom: (code: string) =>
req<{ called: PoolTrack; calledIds: string[] }>(`/api/session/${code}/call-random`, { method: 'POST' }),
autoCallStart: (code: string) => req<{ ok: true }>(`/api/session/${code}/autocall/start`, { method: 'POST' }),
autoCallStop: (code: string) => req<{ ok: true }>(`/api/session/${code}/autocall/stop`, { method: 'POST' }),
};

141
client/src/lib/pkce.ts Normal file
View File

@@ -0,0 +1,141 @@
// Guest self-service mode: a visitor logs into their own Spotify account
// via Authorization Code with PKCE — no client secret, no server
// involvement, tokens never leave the browser. Same flow shape as the
// original bingo-bango (https://github.com/f8al/bingo-bango, MIT) uses
// for its only mode; this is a fresh implementation, not copied code.
const CLIENT_ID = import.meta.env.VITE_SPOTIFY_CLIENT_ID as string;
// Root URL, not a dedicated path — a single static index.html handles
// the callback via a query param (?code=...) instead of needing a
// second HTML entry point or server-side rewrite rules for a nested
// route. Simpler to deploy (plain nginx, no routing config).
const REDIRECT_URI = `${window.location.origin}/`;
const SCOPES = 'playlist-read-private playlist-read-collaborative';
const VERIFIER_KEY = 'fh:pkce_verifier';
const TOKEN_KEY = 'fh:guest_token';
function base64url(bytes: Uint8Array): string {
return btoa(String.fromCharCode(...bytes))
.replace(/\+/g, '-')
.replace(/\//g, '_')
.replace(/=+$/, '');
}
async function sha256(input: string): Promise<Uint8Array> {
const data = new TextEncoder().encode(input);
const digest = await crypto.subtle.digest('SHA-256', data);
return new Uint8Array(digest);
}
function randomVerifier(): string {
const bytes = crypto.getRandomValues(new Uint8Array(64));
return base64url(bytes);
}
export async function startGuestLogin(): Promise<void> {
const verifier = randomVerifier();
sessionStorage.setItem(VERIFIER_KEY, verifier);
const challenge = base64url(await sha256(verifier));
const url = new URL('https://accounts.spotify.com/authorize');
url.search = new URLSearchParams({
client_id: CLIENT_ID,
response_type: 'code',
redirect_uri: REDIRECT_URI,
code_challenge_method: 'S256',
code_challenge: challenge,
scope: SCOPES,
}).toString();
window.location.href = url.toString();
}
export type GuestToken = { access_token: string; expires_at: number };
export async function completeGuestLogin(code: string): Promise<GuestToken> {
const verifier = sessionStorage.getItem(VERIFIER_KEY);
if (!verifier) throw new Error('missing PKCE verifier — start login again');
const res = await fetch('https://accounts.spotify.com/api/token', {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: new URLSearchParams({
client_id: CLIENT_ID,
grant_type: 'authorization_code',
code,
redirect_uri: REDIRECT_URI,
code_verifier: verifier,
}).toString(),
});
if (!res.ok) throw new Error(`token exchange failed: ${res.status}`);
const data = await res.json();
const token: GuestToken = {
access_token: data.access_token,
expires_at: Date.now() + (data.expires_in - 60) * 1000,
};
sessionStorage.setItem(TOKEN_KEY, JSON.stringify(token));
sessionStorage.removeItem(VERIFIER_KEY);
return token;
}
export function getStoredGuestToken(): GuestToken | null {
const raw = sessionStorage.getItem(TOKEN_KEY);
if (!raw) return null;
const token: GuestToken = JSON.parse(raw);
if (Date.now() >= token.expires_at) {
sessionStorage.removeItem(TOKEN_KEY);
return null;
}
return token;
}
export function guestLogout(): void {
sessionStorage.removeItem(TOKEN_KEY);
}
// Direct-to-Spotify calls — no server involvement in guest mode, by
// design (the whole point of PKCE is no backend holding the token).
async function guestFetch(path: string, token: string) {
const res = await fetch(`https://api.spotify.com/v1${path}`, {
headers: { Authorization: `Bearer ${token}` },
});
if (!res.ok) throw new Error(`Spotify ${path}${res.status}`);
return res.json();
}
export type GuestPlaylist = { id: string; name: string; trackCount: number; image: string | null };
export async function fetchGuestPlaylists(token: string): Promise<GuestPlaylist[]> {
const out: GuestPlaylist[] = [];
let url = '/me/playlists?limit=50';
while (url) {
const page = await guestFetch(url, token);
for (const p of page.items ?? []) {
out.push({ id: p.id, name: p.name, trackCount: p.items?.total ?? p.tracks?.total ?? 0, image: p.images?.[0]?.url ?? null });
}
url = page.next ? page.next.replace('https://api.spotify.com/v1', '') : '';
}
return out;
}
export type GuestTrack = { id: string; title: string; artist: string; album: string; image: string | null };
export async function fetchGuestPlaylistTracks(token: string, playlistId: string): Promise<GuestTrack[]> {
const out: GuestTrack[] = [];
let url = `/playlists/${playlistId}/items?limit=100&fields=next,items(item(id,name,artists(name),album(name,images)))`;
while (url) {
const page = await guestFetch(url, token);
for (const entry of page.items ?? []) {
const t = entry.item;
if (!t) continue;
out.push({
id: t.id,
title: t.name,
artist: t.artists?.[0]?.name ?? 'Unknown artist',
album: t.album?.name ?? '',
image: t.album?.images?.[0]?.url ?? null,
});
}
url = page.next ? page.next.replace('https://api.spotify.com/v1', '') : '';
}
return out;
}

407
client/src/main.ts Normal file
View File

@@ -0,0 +1,407 @@
import { api, type PoolTrack } from './lib/api';
import { startGuestLogin, completeGuestLogin, getStoredGuestToken, fetchGuestPlaylists, fetchGuestPlaylistTracks, guestLogout } from './lib/pkce';
import { generateCard, markedIndices, hasBingo, type BingoCard, type CardOptions } from './cards/generate';
const view = document.getElementById('view')!;
function esc(s: unknown): string {
return String(s ?? '').replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
}
// Shared by the live session view and solo mode — the "both facets"
// mechanic (a square is either an exact song title or an artist, and
// an artist square lights up on ANY of their tracks) is f8al's design
// from bingo-bango; nothing in the UI explained it, so every square now
// carries a badge + a one-line legend up top, and the two facets get
// visually distinct badge colors, not just different text.
function renderCardGrid(card: BingoCard, marked: Set<number>, interactive: boolean): string {
const facetBadge = (facet: string) =>
facet === 'title' ? '<span class="facet-badge facet-title" title="song title — lights up on this exact track">♪</span>'
: facet === 'artist' ? '<span class="facet-badge facet-artist" title="artist — lights up on any track by them">☺</span>'
: '';
return `
<div class="card-legend muted center">
<span class="facet-badge facet-title">♪</span> = exact song &nbsp;·&nbsp;
<span class="facet-badge facet-artist">☺</span> = any track by that artist
</div>
<div class="bingo-grid" style="grid-template-columns: repeat(${card.size}, 1fr);">
${card.squares.map((sq, i) => {
const isMarked = sq.facet === 'free' || marked.has(i);
const canTap = interactive && sq.facet !== 'free';
return `
<div class="bingo-square ${sq.facet} ${isMarked ? 'marked' : ''} ${canTap ? 'tappable' : ''}" data-i="${i}">
${sq.image ? `<img class="cover" src="${esc(sq.image)}" alt="">` : ''}
${facetBadge(sq.facet)}
<span class="label">${esc(sq.text)}</span>
</div>
`;
}).join('')}
</div>
`;
}
// ── Router ──────────────────────────────────────────────────────────
type Route = { name: string; params: Record<string, string> };
function parseRoute(): Route {
const hash = location.hash.replace(/^#\/?/, '');
const [name, ...rest] = hash.split('/');
if (!name) return { name: 'home', params: {} };
if (name === 'caller') return { name: 'caller', params: { code: rest[0] } };
if (name === 'play') return { name: 'play', params: { code: rest[0] } };
return { name, params: {} };
}
async function render() {
// Guest PKCE callback lands at the root with ?code=...&state=... —
// handle it before anything else, then strip the query string.
const params = new URLSearchParams(location.search);
if (params.has('code')) {
const code = params.get('code')!;
history.replaceState({}, '', location.pathname + location.hash);
try {
await completeGuestLogin(code);
} catch (e) {
view.innerHTML = `<div class="error-box">guest login failed: ${esc((e as Error).message)}</div>`;
return;
}
location.hash = '#/solo';
}
const route = parseRoute();
try {
if (route.name === 'home') return renderHome();
if (route.name === 'host') return renderHost();
if (route.name === 'caller') return renderCaller(route.params.code);
if (route.name === 'join') return renderJoin();
if (route.name === 'play') return renderPlay(route.params.code);
if (route.name === 'solo') return renderSolo();
return renderHome();
} catch (e) {
view.innerHTML = `<div class="error-box">${esc((e as Error).message)}</div>`;
}
}
window.addEventListener('hashchange', render);
render();
// ── Home ────────────────────────────────────────────────────────────
function renderHome() {
view.innerHTML = `
<div class="card center stack">
<h2>What are you doing?</h2>
<p class="muted">Pick a mode — all three make bingo cards from real Spotify playlists, no invented data.</p>
</div>
<div class="card stack">
<h3>Host a live session</h3>
<p class="muted">Connect your own Spotify, pick a playlist, get a join code + QR for a room full of people. Live "caller" screen, auto-call from real playback.</p>
<a href="#/host" class="btn">Host a game →</a>
</div>
<div class="card stack">
<h3>Join a session</h3>
<p class="muted">Someone else is hosting — enter their code, get your own card, no login needed.</p>
<a href="#/join" class="btn">Join a game →</a>
</div>
<div class="card stack">
<h3>Play solo</h3>
<p class="muted">Log into your own Spotify (or paste a public playlist URL) and generate a one-off card, no session.</p>
<a href="#/solo" class="btn">Play solo →</a>
</div>
`;
}
// ── Host: connect + pick playlist + create session ─────────────────
async function renderHost() {
view.innerHTML = `<div class="card center">checking Spotify connection…</div>`;
const status = await api.hostStatus().catch(() => ({ connected: false }));
if (!status.connected) {
view.innerHTML = `
<div class="card stack center">
<h2>Connect Spotify</h2>
<p class="muted">One-time host connection — used to read your playlists and (optionally) auto-call from your live playback.</p>
<a href="/api/spotify/login" class="btn">Connect Spotify →</a>
</div>
`;
return;
}
view.innerHTML = `<div class="card center">loading your playlists…</div>`;
const playlists = await api.hostPlaylists();
const usable = playlists.filter(p => p.trackCount >= 12);
view.innerHTML = `
<div class="card stack">
<h2>Pick a playlist</h2>
<p class="muted">Only playlists with 12+ tracks shown (need enough headroom for a 3×3 card). Algorithmic/editorial Spotify playlists (Discover Weekly etc.) aren't readable via the API and won't appear here.</p>
<ul class="playlist-list">
${usable.map(p => `
<li class="playlist-row" data-id="${esc(p.id)}" data-name="${esc(p.name)}">
${p.image ? `<img src="${esc(p.image)}" alt="">` : '<div style="width:40px;height:40px;border-radius:6px;background:var(--border);"></div>'}
<span class="name">${esc(p.name)}</span>
<span class="count">${p.trackCount} tracks</span>
</li>
`).join('')}
</ul>
</div>
`;
view.querySelectorAll<HTMLElement>('.playlist-row').forEach(row => {
row.addEventListener('click', async () => {
const { id, name } = row.dataset;
view.innerHTML = `<div class="card center">creating session…</div>`;
try {
const session = await api.createSession(id!, name!);
location.hash = `#/caller/${session.code}`;
} catch (e) {
view.innerHTML = `<div class="error-box">${esc((e as Error).message)}</div><a href="#/host" class="btn secondary">back</a>`;
}
});
});
}
// ── Caller screen (host) ────────────────────────────────────────────
async function renderCaller(code: string) {
view.innerHTML = `<div class="card center">loading session…</div>`;
const session = await api.joinSession(code);
const joinUrl = `${location.origin}/#/play/${code}`;
function trackById(id: string): PoolTrack | undefined {
return session.tracks.find(t => t.id === id);
}
function paint(calledIds: string[], autoCall: boolean) {
const lastId = calledIds[calledIds.length - 1];
const last = lastId ? trackById(lastId) : null;
const remaining = session.tracks.length - calledIds.length;
view.innerHTML = `
<div class="card stack center">
<h2>Hosting: ${esc(session.playlistName)}</h2>
<div class="session-code">${esc(code)}</div>
<p class="muted">join at <a href="${esc(joinUrl)}">${esc(joinUrl.replace('https://', '').replace('http://', ''))}</a></p>
</div>
<div class="card caller-now">
${last ? `
${last.image ? `<img src="${esc(last.image)}" alt="">` : ''}
<div class="track-title">${esc(last.title)}</div>
<div class="track-artist">${esc(last.artist)}</div>
` : `<p class="muted">nothing called yet</p>`}
</div>
<div class="card stack">
<div class="row between">
<button id="call-random" ${remaining === 0 ? 'disabled' : ''}>call next (${remaining} left)</button>
<div class="toggle-row">
<label class="muted">auto-call from live playback</label>
<input type="checkbox" id="auto-call" ${autoCall ? 'checked' : ''}>
</div>
</div>
<div class="called-history">
${calledIds.map(id => {
const t = trackById(id);
return t ? `<span class="called-chip">${esc(t.title)}</span>` : '';
}).join('')}
</div>
</div>
`;
document.getElementById('call-random')?.addEventListener('click', async () => {
const res = await api.callRandom(code).catch((e) => { alert(e.message); return null; });
if (res) paint(res.calledIds, autoCall);
});
document.getElementById('auto-call')?.addEventListener('change', async (e) => {
const on = (e.target as HTMLInputElement).checked;
if (on) await api.autoCallStart(code); else await api.autoCallStop(code);
});
}
paint(session.calledIds, session.autoCall);
// Deliberately dumb polling, not a socket — matches this whole
// family's precedent (keep watch, wisp's sweep). A caller screen
// doesn't need sub-second latency.
const poll = setInterval(async () => {
if (parseRoute().name !== 'caller') { clearInterval(poll); return; }
const state = await api.sessionState(code).catch(() => null);
if (state) paint(state.calledIds, state.autoCall);
}, 4000);
}
// ── Join (guest enters a code) ──────────────────────────────────────
function renderJoin() {
view.innerHTML = `
<div class="card stack">
<h2>Join a session</h2>
<input type="text" id="code-input" placeholder="5-letter code" maxlength="5" style="text-transform:uppercase; font-size:1.4em; text-align:center; letter-spacing:0.2em;">
<button id="join-btn">join →</button>
<div id="join-error"></div>
</div>
`;
const go = () => {
const code = (document.getElementById('code-input') as HTMLInputElement).value.trim().toUpperCase();
if (code) location.hash = `#/play/${code}`;
};
document.getElementById('join-btn')?.addEventListener('click', go);
document.getElementById('code-input')?.addEventListener('keydown', (e) => { if ((e as KeyboardEvent).key === 'Enter') go(); });
}
// ── Play (guest card view, live-synced to a session) ────────────────
const CARD_OPTS: CardOptions = { size: 3, freeSpace: true, artistRatio: 0.5 };
async function renderPlay(code: string) {
view.innerHTML = `<div class="card center">joining session ${esc(code)}…</div>`;
let session;
try {
session = await api.joinSession(code);
} catch (e) {
view.innerHTML = `<div class="error-box">${esc((e as Error).message)}</div><a href="#/join" class="btn secondary">try another code</a>`;
return;
}
const seed = Math.floor(Math.random() * 2 ** 31);
const card = generateCard(session.tracks, seed, CARD_OPTS);
// Marking is normally fully automatic (the host's calls, synced via
// polling) — but a guest can't tell "was that really the track that
// just played?" from the host's side alone, and might want to mark a
// square they're confident about before the sync catches up, or the
// host is calling manually and slowly. Self-marks are local-only
// (never sent to the server, never affect other players) and only
// apply to squares the auto-sync hasn't already confirmed — once the
// server confirms a square, it's locked in and no longer tappable.
const selfMarked = new Set<number>();
function paint(calledIds: string[]) {
const autoMarked = new Set<number>();
for (const id of calledIds) for (const i of markedIndices(card, id)) autoMarked.add(i);
const effective = new Set<number>([...autoMarked, ...selfMarked]);
const won = hasBingo(card, effective);
view.innerHTML = `
<div class="card center">
<h2>${esc(session!.playlistName)}</h2>
<p class="muted">code: ${esc(code)}</p>
</div>
${renderCardGrid(card, effective, true)}
<p class="muted center" style="margin-top:8px;">squares mark themselves as the host calls tracks — tap one yourself if you're sure it just played and the sync hasn't caught up</p>
${won ? `<div class="bingo-banner">🎉 BINGO!</div>` : ''}
`;
view.querySelectorAll<HTMLElement>('.bingo-square.tappable').forEach(sq => {
const i = Number(sq.dataset.i);
if (autoMarked.has(i)) return; // server-confirmed, not toggleable
sq.addEventListener('click', () => {
if (selfMarked.has(i)) selfMarked.delete(i); else selfMarked.add(i);
paint(calledIds);
});
});
}
paint(session.calledIds);
const poll = setInterval(async () => {
if (parseRoute().name !== 'play') { clearInterval(poll); return; }
const state = await api.sessionState(code).catch(() => null);
if (state) paint(state.calledIds);
}, 4000);
}
// ── Solo (guest PKCE, own playlist or a pasted public URL) ──────────
async function renderSolo() {
const token = getStoredGuestToken();
if (!token) {
view.innerHTML = `
<div class="card stack center">
<h2>Play solo</h2>
<p class="muted">Log into your own Spotify to pick a playlist, or skip that and paste any public playlist URL below.</p>
<button id="guest-login">Log into Spotify →</button>
</div>
<div class="card stack">
<h3>Or use a public playlist URL</h3>
<input type="text" id="public-url" placeholder="https://open.spotify.com/playlist/...">
<button id="public-go" class="secondary">generate card →</button>
<div id="public-error"></div>
</div>
`;
document.getElementById('guest-login')?.addEventListener('click', () => startGuestLogin());
document.getElementById('public-go')?.addEventListener('click', async () => {
const url = (document.getElementById('public-url') as HTMLInputElement).value.trim();
const errEl = document.getElementById('public-error')!;
if (!url) return;
errEl.innerHTML = `<p class="muted">loading…</p>`;
try {
const data = await api.publicPlaylist(url);
renderSoloCard(data.tracks, data.name);
} catch (e) {
errEl.innerHTML = `<div class="error-box">${esc((e as Error).message)}</div>`;
}
});
return;
}
view.innerHTML = `<div class="card center">loading your playlists…</div>`;
const playlists = await fetchGuestPlaylists(token.access_token).catch(() => []);
const usable = playlists.filter(p => p.trackCount >= 12);
view.innerHTML = `
<div class="card row between">
<h2 style="margin:0;">Pick a playlist</h2>
<button class="secondary" id="logout">log out</button>
</div>
<div class="card">
<ul class="playlist-list">
${usable.map(p => `
<li class="playlist-row" data-id="${esc(p.id)}" data-name="${esc(p.name)}">
${p.image ? `<img src="${esc(p.image)}" alt="">` : '<div style="width:40px;height:40px;border-radius:6px;background:var(--border);"></div>'}
<span class="name">${esc(p.name)}</span>
<span class="count">${p.trackCount} tracks</span>
</li>
`).join('')}
</ul>
</div>
`;
document.getElementById('logout')?.addEventListener('click', () => { guestLogout(); render(); });
view.querySelectorAll<HTMLElement>('.playlist-row').forEach(row => {
row.addEventListener('click', async () => {
const { id, name } = row.dataset;
view.innerHTML = `<div class="card center">loading tracks…</div>`;
const tracks = await fetchGuestPlaylistTracks(token.access_token, id!);
renderSoloCard(tracks, name!);
});
});
}
function renderSoloCard(tracks: PoolTrack[], playlistName: string) {
if (tracks.length < 12) {
view.innerHTML = `<div class="error-box">this playlist only has ${tracks.length} tracks — need at least 12.</div><a href="#/solo" class="btn secondary">back</a>`;
return;
}
const seed = Math.floor(Math.random() * 2 ** 31);
const card: BingoCard = generateCard(tracks, seed, CARD_OPTS);
const marked = new Set<number>();
function paint() {
view.innerHTML = `
<div class="card center">
<h2>${esc(playlistName)}</h2>
<p class="muted">tap squares yourself as tracks play — solo mode has no live sync</p>
</div>
${renderCardGrid(card, marked, true)}
${hasBingo(card, marked) ? `<div class="bingo-banner">🎉 BINGO!</div>` : ''}
`;
view.querySelectorAll<HTMLElement>('.bingo-square.tappable').forEach(sq => {
sq.addEventListener('click', () => {
const i = Number(sq.dataset.i);
if (marked.has(i)) marked.delete(i); else marked.add(i);
paint();
});
});
}
paint();
}

18
client/tsconfig.json Normal file
View File

@@ -0,0 +1,18 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "ESNext",
"moduleResolution": "bundler",
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"skipLibCheck": true,
"esModuleInterop": true,
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"types": ["vite/client"]
},
"include": ["src"]
}

15
client/vite.config.ts Normal file
View File

@@ -0,0 +1,15 @@
import { defineConfig } from 'vite';
export default defineConfig({
server: {
// Explicit 127.0.0.1, not the default 'localhost' — Vite's default
// can resolve to IPv6 loopback only ([::1]) depending on the host's
// resolver order, which won't match Spotify's registered redirect
// URI (127.0.0.1 specifically — Spotify's HTTPS-required-for-
// non-loopback policy only exempts the literal IP, not 'localhost').
host: '127.0.0.1',
proxy: {
'/api': 'http://127.0.0.1:3010',
},
},
});

29
docker-compose.yml Normal file
View File

@@ -0,0 +1,29 @@
services:
web:
image: ${IMAGE}
container_name: fullhouse-web
restart: unless-stopped
pull_policy: always
ports:
- "${HOST_PORT:-3080}:80"
depends_on:
- api
api:
image: ${IMAGE}-api
container_name: fullhouse-api
restart: unless-stopped
pull_policy: always
environment:
PORT: 3010
SPOTIFY_CLIENT_ID: ${SPOTIFY_CLIENT_ID}
SPOTIFY_CLIENT_SECRET: ${SPOTIFY_CLIENT_SECRET}
SPOTIFY_HOST_REDIRECT_URI: ${SPOTIFY_HOST_REDIRECT_URI}
volumes:
# Named volume — holds the host's Spotify refresh token. Without
# it, every redeploy wipes the host connection and the one-time
# OAuth consent has to be redone.
- fullhouse-api-data:/app/data
volumes:
fullhouse-api-data:

29
nginx.conf Normal file
View File

@@ -0,0 +1,29 @@
server {
listen 80;
server_name _;
root /usr/share/nginx/html;
index index.html;
# Same-origin proxy to the api container — deliberately, not an
# absolute cross-origin URL. Pointing the client straight at the api
# container's own origin broke CORS during local dev (no CORS headers
# on the server, and adding them would be more moving parts than just
# not needing them).
location /api/ {
proxy_pass http://api:3010;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
# Hash-based client routing (#/host, #/play/CODE, ...) — the fragment
# never reaches the server, so plain static serving is enough; no SPA
# history-API rewrite rule needed for any route, including the guest
# OAuth callback (?code=... lands on the root path itself).
location / {
try_files $uri $uri/ /index.html;
}
}

23
server/index.js Normal file
View File

@@ -0,0 +1,23 @@
import 'dotenv/config';
import express from 'express';
import cookieParser from 'cookie-parser';
import hostAuth from './routes/hostAuth.js';
import hostPlaylists from './routes/hostPlaylists.js';
import publicPlaylist from './routes/publicPlaylist.js';
import session from './routes/session.js';
const app = express();
app.use(cookieParser());
app.use(express.json());
app.get('/api/health', (req, res) => res.json({ ok: true }));
app.use('/api/spotify', hostAuth);
app.use('/api/host', hostPlaylists);
app.use('/api/public-playlist', publicPlaylist);
app.use('/api/session', session);
const PORT = process.env.PORT || 3010;
app.listen(PORT, () => {
console.log(`fullhouse API listening on :${PORT}`);
});

855
server/package-lock.json generated Normal file
View File

@@ -0,0 +1,855 @@
{
"name": "fullhouse-server",
"version": "0.1.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "fullhouse-server",
"version": "0.1.0",
"dependencies": {
"cookie-parser": "^1.4.7",
"dotenv": "^16.6.1",
"express": "^4.19.2"
}
},
"node_modules/accepts": {
"version": "1.3.8",
"resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz",
"integrity": "sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==",
"license": "MIT",
"dependencies": {
"mime-types": "~2.1.34",
"negotiator": "0.6.3"
},
"engines": {
"node": ">= 0.6"
}
},
"node_modules/array-flatten": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz",
"integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==",
"license": "MIT"
},
"node_modules/body-parser": {
"version": "1.20.6",
"resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.6.tgz",
"integrity": "sha512-p5tAzS57i5MV9fZFDj9LeIiTZEufbSe2eDozP+ElheSUq1m74CRq1jI4mYNDdVs9vQztXFLuk/Gd6BWTdwRJ5g==",
"license": "MIT",
"dependencies": {
"bytes": "~3.1.2",
"content-type": "~1.0.5",
"debug": "2.6.9",
"depd": "2.0.0",
"destroy": "~1.2.0",
"http-errors": "~2.0.1",
"iconv-lite": "~0.4.24",
"on-finished": "~2.4.1",
"qs": "~6.15.1",
"raw-body": "~2.5.3",
"type-is": "~1.6.18",
"unpipe": "~1.0.0"
},
"engines": {
"node": ">= 0.8",
"npm": "1.2.8000 || >= 1.4.16"
}
},
"node_modules/bytes": {
"version": "3.1.2",
"resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz",
"integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==",
"license": "MIT",
"engines": {
"node": ">= 0.8"
}
},
"node_modules/call-bind-apply-helpers": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz",
"integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==",
"license": "MIT",
"dependencies": {
"es-errors": "^1.3.0",
"function-bind": "^1.1.2"
},
"engines": {
"node": ">= 0.4"
}
},
"node_modules/call-bound": {
"version": "1.0.4",
"resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz",
"integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==",
"license": "MIT",
"dependencies": {
"call-bind-apply-helpers": "^1.0.2",
"get-intrinsic": "^1.3.0"
},
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/content-disposition": {
"version": "0.5.4",
"resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz",
"integrity": "sha512-FveZTNuGw04cxlAiWbzi6zTAL/lhehaWbTtgluJh4/E95DqMwTmha3KZN1aAWA8cFIhHzMZUvLevkw5Rqk+tSQ==",
"license": "MIT",
"dependencies": {
"safe-buffer": "5.2.1"
},
"engines": {
"node": ">= 0.6"
}
},
"node_modules/content-type": {
"version": "1.0.5",
"resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz",
"integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==",
"license": "MIT",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/cookie": {
"version": "0.7.2",
"resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz",
"integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==",
"license": "MIT",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/cookie-parser": {
"version": "1.4.7",
"resolved": "https://registry.npmjs.org/cookie-parser/-/cookie-parser-1.4.7.tgz",
"integrity": "sha512-nGUvgXnotP3BsjiLX2ypbQnWoGUPIIfHQNZkkC668ntrzGWEZVW70HDEB1qnNGMicPje6EttlIgzo51YSwNQGw==",
"license": "MIT",
"dependencies": {
"cookie": "0.7.2",
"cookie-signature": "1.0.6"
},
"engines": {
"node": ">= 0.8.0"
}
},
"node_modules/cookie-signature": {
"version": "1.0.6",
"resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.6.tgz",
"integrity": "sha512-QADzlaHc8icV8I7vbaJXJwod9HWYp8uCqf1xa4OfNu1T7JVxQIrUgOWtHdNDtPiywmFbiS12VjotIXLrKM3orQ==",
"license": "MIT"
},
"node_modules/debug": {
"version": "2.6.9",
"resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz",
"integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==",
"license": "MIT",
"dependencies": {
"ms": "2.0.0"
}
},
"node_modules/depd": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz",
"integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==",
"license": "MIT",
"engines": {
"node": ">= 0.8"
}
},
"node_modules/destroy": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/destroy/-/destroy-1.2.0.tgz",
"integrity": "sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg==",
"license": "MIT",
"engines": {
"node": ">= 0.8",
"npm": "1.2.8000 || >= 1.4.16"
}
},
"node_modules/dotenv": {
"version": "16.6.1",
"resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.6.1.tgz",
"integrity": "sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow==",
"license": "BSD-2-Clause",
"engines": {
"node": ">=12"
},
"funding": {
"url": "https://dotenvx.com"
}
},
"node_modules/dunder-proto": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",
"integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==",
"license": "MIT",
"dependencies": {
"call-bind-apply-helpers": "^1.0.1",
"es-errors": "^1.3.0",
"gopd": "^1.2.0"
},
"engines": {
"node": ">= 0.4"
}
},
"node_modules/ee-first": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz",
"integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==",
"license": "MIT"
},
"node_modules/encodeurl": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz",
"integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==",
"license": "MIT",
"engines": {
"node": ">= 0.8"
}
},
"node_modules/es-define-property": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz",
"integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==",
"license": "MIT",
"engines": {
"node": ">= 0.4"
}
},
"node_modules/es-errors": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz",
"integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==",
"license": "MIT",
"engines": {
"node": ">= 0.4"
}
},
"node_modules/es-object-atoms": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.2.tgz",
"integrity": "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==",
"license": "MIT",
"dependencies": {
"es-errors": "^1.3.0"
},
"engines": {
"node": ">= 0.4"
}
},
"node_modules/escape-html": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz",
"integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==",
"license": "MIT"
},
"node_modules/etag": {
"version": "1.8.1",
"resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz",
"integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==",
"license": "MIT",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/express": {
"version": "4.22.2",
"resolved": "https://registry.npmjs.org/express/-/express-4.22.2.tgz",
"integrity": "sha512-IuL+Elrou2ZvCFHs18/CIzy2Nzvo25nZ1/D2eIZlz7c+QUayAcYoiM2BthCjs+EBHVpjYjcuLDAiCWgeIX3X1Q==",
"license": "MIT",
"dependencies": {
"accepts": "~1.3.8",
"array-flatten": "1.1.1",
"body-parser": "~1.20.5",
"content-disposition": "~0.5.4",
"content-type": "~1.0.4",
"cookie": "~0.7.1",
"cookie-signature": "~1.0.6",
"debug": "2.6.9",
"depd": "2.0.0",
"encodeurl": "~2.0.0",
"escape-html": "~1.0.3",
"etag": "~1.8.1",
"finalhandler": "~1.3.1",
"fresh": "~0.5.2",
"http-errors": "~2.0.0",
"merge-descriptors": "1.0.3",
"methods": "~1.1.2",
"on-finished": "~2.4.1",
"parseurl": "~1.3.3",
"path-to-regexp": "~0.1.12",
"proxy-addr": "~2.0.7",
"qs": "~6.15.1",
"range-parser": "~1.2.1",
"safe-buffer": "5.2.1",
"send": "~0.19.0",
"serve-static": "~1.16.2",
"setprototypeof": "1.2.0",
"statuses": "~2.0.1",
"type-is": "~1.6.18",
"utils-merge": "1.0.1",
"vary": "~1.1.2"
},
"engines": {
"node": ">= 0.10.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/express"
}
},
"node_modules/finalhandler": {
"version": "1.3.2",
"resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.3.2.tgz",
"integrity": "sha512-aA4RyPcd3badbdABGDuTXCMTtOneUCAYH/gxoYRTZlIJdF0YPWuGqiAsIrhNnnqdXGswYk6dGujem4w80UJFhg==",
"license": "MIT",
"dependencies": {
"debug": "2.6.9",
"encodeurl": "~2.0.0",
"escape-html": "~1.0.3",
"on-finished": "~2.4.1",
"parseurl": "~1.3.3",
"statuses": "~2.0.2",
"unpipe": "~1.0.0"
},
"engines": {
"node": ">= 0.8"
}
},
"node_modules/forwarded": {
"version": "0.2.0",
"resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz",
"integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==",
"license": "MIT",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/fresh": {
"version": "0.5.2",
"resolved": "https://registry.npmjs.org/fresh/-/fresh-0.5.2.tgz",
"integrity": "sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q==",
"license": "MIT",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/function-bind": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz",
"integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==",
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/get-intrinsic": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz",
"integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==",
"license": "MIT",
"dependencies": {
"call-bind-apply-helpers": "^1.0.2",
"es-define-property": "^1.0.1",
"es-errors": "^1.3.0",
"es-object-atoms": "^1.1.1",
"function-bind": "^1.1.2",
"get-proto": "^1.0.1",
"gopd": "^1.2.0",
"has-symbols": "^1.1.0",
"hasown": "^2.0.2",
"math-intrinsics": "^1.1.0"
},
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/get-proto": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz",
"integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==",
"license": "MIT",
"dependencies": {
"dunder-proto": "^1.0.1",
"es-object-atoms": "^1.0.0"
},
"engines": {
"node": ">= 0.4"
}
},
"node_modules/gopd": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz",
"integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==",
"license": "MIT",
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/has-symbols": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz",
"integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==",
"license": "MIT",
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/hasown": {
"version": "2.0.4",
"resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz",
"integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==",
"license": "MIT",
"dependencies": {
"function-bind": "^1.1.2"
},
"engines": {
"node": ">= 0.4"
}
},
"node_modules/http-errors": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz",
"integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==",
"license": "MIT",
"dependencies": {
"depd": "~2.0.0",
"inherits": "~2.0.4",
"setprototypeof": "~1.2.0",
"statuses": "~2.0.2",
"toidentifier": "~1.0.1"
},
"engines": {
"node": ">= 0.8"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/express"
}
},
"node_modules/iconv-lite": {
"version": "0.4.24",
"resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz",
"integrity": "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==",
"license": "MIT",
"dependencies": {
"safer-buffer": ">= 2.1.2 < 3"
},
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/inherits": {
"version": "2.0.4",
"resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz",
"integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==",
"license": "ISC"
},
"node_modules/ipaddr.js": {
"version": "1.9.1",
"resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz",
"integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==",
"license": "MIT",
"engines": {
"node": ">= 0.10"
}
},
"node_modules/math-intrinsics": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
"integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==",
"license": "MIT",
"engines": {
"node": ">= 0.4"
}
},
"node_modules/media-typer": {
"version": "0.3.0",
"resolved": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz",
"integrity": "sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==",
"license": "MIT",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/merge-descriptors": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-1.0.3.tgz",
"integrity": "sha512-gaNvAS7TZ897/rVaZ0nMtAyxNyi/pdbjbAwUpFQpN70GqnVfOiXpeUUMKRBmzXaSQ8DdTX4/0ms62r2K+hE6mQ==",
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/methods": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/methods/-/methods-1.1.2.tgz",
"integrity": "sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w==",
"license": "MIT",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/mime": {
"version": "1.6.0",
"resolved": "https://registry.npmjs.org/mime/-/mime-1.6.0.tgz",
"integrity": "sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==",
"license": "MIT",
"bin": {
"mime": "cli.js"
},
"engines": {
"node": ">=4"
}
},
"node_modules/mime-db": {
"version": "1.52.0",
"resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz",
"integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==",
"license": "MIT",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/mime-types": {
"version": "2.1.35",
"resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz",
"integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==",
"license": "MIT",
"dependencies": {
"mime-db": "1.52.0"
},
"engines": {
"node": ">= 0.6"
}
},
"node_modules/ms": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz",
"integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==",
"license": "MIT"
},
"node_modules/negotiator": {
"version": "0.6.3",
"resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz",
"integrity": "sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==",
"license": "MIT",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/object-inspect": {
"version": "1.13.4",
"resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz",
"integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==",
"license": "MIT",
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/on-finished": {
"version": "2.4.1",
"resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz",
"integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==",
"license": "MIT",
"dependencies": {
"ee-first": "1.1.1"
},
"engines": {
"node": ">= 0.8"
}
},
"node_modules/parseurl": {
"version": "1.3.3",
"resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz",
"integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==",
"license": "MIT",
"engines": {
"node": ">= 0.8"
}
},
"node_modules/path-to-regexp": {
"version": "0.1.13",
"resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.13.tgz",
"integrity": "sha512-A/AGNMFN3c8bOlvV9RreMdrv7jsmF9XIfDeCd87+I8RNg6s78BhJxMu69NEMHBSJFxKidViTEdruRwEk/WIKqA==",
"license": "MIT"
},
"node_modules/proxy-addr": {
"version": "2.0.7",
"resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz",
"integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==",
"license": "MIT",
"dependencies": {
"forwarded": "0.2.0",
"ipaddr.js": "1.9.1"
},
"engines": {
"node": ">= 0.10"
}
},
"node_modules/qs": {
"version": "6.15.3",
"resolved": "https://registry.npmjs.org/qs/-/qs-6.15.3.tgz",
"integrity": "sha512-O9gl3zCl5h5blw1KGUzQKhA5oUXSl8rwUIM5o0S3nCXMliSvy5Dzx7/DJcI+SwgICv+IneSZwhBh1oSyEHA71A==",
"license": "BSD-3-Clause",
"dependencies": {
"es-define-property": "^1.0.1",
"side-channel": "^1.1.1"
},
"engines": {
"node": ">=0.6"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/range-parser": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz",
"integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==",
"license": "MIT",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/raw-body": {
"version": "2.5.3",
"resolved": "https://registry.npmjs.org/raw-body/-/raw-body-2.5.3.tgz",
"integrity": "sha512-s4VSOf6yN0rvbRZGxs8Om5CWj6seneMwK3oDb4lWDH0UPhWcxwOWw5+qk24bxq87szX1ydrwylIOp2uG1ojUpA==",
"license": "MIT",
"dependencies": {
"bytes": "~3.1.2",
"http-errors": "~2.0.1",
"iconv-lite": "~0.4.24",
"unpipe": "~1.0.0"
},
"engines": {
"node": ">= 0.8"
}
},
"node_modules/safe-buffer": {
"version": "5.2.1",
"resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz",
"integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/feross"
},
{
"type": "patreon",
"url": "https://www.patreon.com/feross"
},
{
"type": "consulting",
"url": "https://feross.org/support"
}
],
"license": "MIT"
},
"node_modules/safer-buffer": {
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz",
"integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==",
"license": "MIT"
},
"node_modules/send": {
"version": "0.19.2",
"resolved": "https://registry.npmjs.org/send/-/send-0.19.2.tgz",
"integrity": "sha512-VMbMxbDeehAxpOtWJXlcUS5E8iXh6QmN+BkRX1GARS3wRaXEEgzCcB10gTQazO42tpNIya8xIyNx8fll1OFPrg==",
"license": "MIT",
"dependencies": {
"debug": "2.6.9",
"depd": "2.0.0",
"destroy": "1.2.0",
"encodeurl": "~2.0.0",
"escape-html": "~1.0.3",
"etag": "~1.8.1",
"fresh": "~0.5.2",
"http-errors": "~2.0.1",
"mime": "1.6.0",
"ms": "2.1.3",
"on-finished": "~2.4.1",
"range-parser": "~1.2.1",
"statuses": "~2.0.2"
},
"engines": {
"node": ">= 0.8.0"
}
},
"node_modules/send/node_modules/ms": {
"version": "2.1.3",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
"license": "MIT"
},
"node_modules/serve-static": {
"version": "1.16.3",
"resolved": "https://registry.npmjs.org/serve-static/-/serve-static-1.16.3.tgz",
"integrity": "sha512-x0RTqQel6g5SY7Lg6ZreMmsOzncHFU7nhnRWkKgWuMTu5NN0DR5oruckMqRvacAN9d5w6ARnRBXl9xhDCgfMeA==",
"license": "MIT",
"dependencies": {
"encodeurl": "~2.0.0",
"escape-html": "~1.0.3",
"parseurl": "~1.3.3",
"send": "~0.19.1"
},
"engines": {
"node": ">= 0.8.0"
}
},
"node_modules/setprototypeof": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz",
"integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==",
"license": "ISC"
},
"node_modules/side-channel": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.1.tgz",
"integrity": "sha512-6x6dK6zJdpTzF4sQeNYxwtvBzf6Eg4GtlesS94HOvTudUeyK2WXAaIfmDgsyslYrRBeFIlsi54AYsFGUuhmvrQ==",
"license": "MIT",
"dependencies": {
"es-errors": "^1.3.0",
"object-inspect": "^1.13.4",
"side-channel-list": "^1.0.1",
"side-channel-map": "^1.0.1",
"side-channel-weakmap": "^1.0.2"
},
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/side-channel-list": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.1.tgz",
"integrity": "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==",
"license": "MIT",
"dependencies": {
"es-errors": "^1.3.0",
"object-inspect": "^1.13.4"
},
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/side-channel-map": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz",
"integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==",
"license": "MIT",
"dependencies": {
"call-bound": "^1.0.2",
"es-errors": "^1.3.0",
"get-intrinsic": "^1.2.5",
"object-inspect": "^1.13.3"
},
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/side-channel-weakmap": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz",
"integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==",
"license": "MIT",
"dependencies": {
"call-bound": "^1.0.2",
"es-errors": "^1.3.0",
"get-intrinsic": "^1.2.5",
"object-inspect": "^1.13.3",
"side-channel-map": "^1.0.1"
},
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/statuses": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz",
"integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==",
"license": "MIT",
"engines": {
"node": ">= 0.8"
}
},
"node_modules/toidentifier": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz",
"integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==",
"license": "MIT",
"engines": {
"node": ">=0.6"
}
},
"node_modules/type-is": {
"version": "1.6.18",
"resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz",
"integrity": "sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==",
"license": "MIT",
"dependencies": {
"media-typer": "0.3.0",
"mime-types": "~2.1.24"
},
"engines": {
"node": ">= 0.6"
}
},
"node_modules/unpipe": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz",
"integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==",
"license": "MIT",
"engines": {
"node": ">= 0.8"
}
},
"node_modules/utils-merge": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz",
"integrity": "sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==",
"license": "MIT",
"engines": {
"node": ">= 0.4.0"
}
},
"node_modules/vary": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz",
"integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==",
"license": "MIT",
"engines": {
"node": ">= 0.8"
}
}
}
}

15
server/package.json Normal file
View File

@@ -0,0 +1,15 @@
{
"name": "fullhouse-server",
"version": "0.1.0",
"private": true,
"type": "module",
"scripts": {
"start": "node index.js",
"dev": "node --watch index.js"
},
"dependencies": {
"cookie-parser": "^1.4.7",
"dotenv": "^16.6.1",
"express": "^4.19.2"
}
}

122
server/routes/hostAuth.js Normal file
View 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;

View 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;

View 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
View 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;