commit d99c44cb9dced562cd7001bab2fd1e2a59650730 Author: Fredrik Johansson Date: Sat Jul 18 22:05:37 2026 +0200 Initial build: fullhouse, live music bingo from a real Spotify playlist 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. diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..953c203 --- /dev/null +++ b/.env.example @@ -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: / (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 diff --git a/.gitea/workflows/docker.yml b/.gitea/workflows/docker.yml new file mode 100644 index 0000000..959bbf2 --- /dev/null +++ b/.gitea/workflows/docker.yml @@ -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 }} diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..d9444d8 --- /dev/null +++ b/.gitignore @@ -0,0 +1,5 @@ +node_modules/ +dist/ +.env +server/data/ +*.log diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..b1ab1e4 --- /dev/null +++ b/Dockerfile @@ -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 diff --git a/Dockerfile.api b/Dockerfile.api new file mode 100644 index 0000000..10cc8a8 --- /dev/null +++ b/Dockerfile.api @@ -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"] diff --git a/PROPOSAL.md b/PROPOSAL.md new file mode 100644 index 0000000..e3cb7bc --- /dev/null +++ b/PROPOSAL.md @@ -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. diff --git a/README.md b/README.md new file mode 100644 index 0000000..d8c8379 --- /dev/null +++ b/README.md @@ -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. diff --git a/client/index.html b/client/index.html new file mode 100644 index 0000000..c6e47f3 --- /dev/null +++ b/client/index.html @@ -0,0 +1,24 @@ + + + + + + fullhouse + + + +
+
+ > fullhouse +

music bingo from a real playlist

+
+
+
+

+ based on bingo-bango by f8al (MIT) — reworked with live sessions, host mode, and cover-art squares +

+
+
+ + + diff --git a/client/package-lock.json b/client/package-lock.json new file mode 100644 index 0000000..234d6f8 --- /dev/null +++ b/client/package-lock.json @@ -0,0 +1,1002 @@ +{ + "name": "fullhouse-client", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "fullhouse-client", + "version": "0.1.0", + "devDependencies": { + "typescript": "^5.8.0", + "vite": "^5.4.0" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz", + "integrity": "sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.21.5.tgz", + "integrity": "sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.21.5.tgz", + "integrity": "sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.21.5.tgz", + "integrity": "sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.21.5.tgz", + "integrity": "sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.21.5.tgz", + "integrity": "sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.21.5.tgz", + "integrity": "sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.21.5.tgz", + "integrity": "sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.21.5.tgz", + "integrity": "sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.21.5.tgz", + "integrity": "sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.21.5.tgz", + "integrity": "sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.21.5.tgz", + "integrity": "sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.21.5.tgz", + "integrity": "sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.21.5.tgz", + "integrity": "sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.21.5.tgz", + "integrity": "sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.21.5.tgz", + "integrity": "sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.21.5.tgz", + "integrity": "sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.21.5.tgz", + "integrity": "sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.21.5.tgz", + "integrity": "sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.21.5.tgz", + "integrity": "sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.21.5.tgz", + "integrity": "sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.21.5.tgz", + "integrity": "sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.21.5.tgz", + "integrity": "sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.62.2.tgz", + "integrity": "sha512-6o7ZLZK+BeenkZCFNDXqpbjw9bD6nuWonvS/lwQJp7NoVVxm6p3qE7qQ5jGuBjiFsgvqjD8mZAU5oWxTmbOeOg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.62.2.tgz", + "integrity": "sha512-BaH7BllCACHoH1LguOU56UItGfUWjujlO65kS9LAodViaN4bwIKd7oeW/ZHJ/4ljr/7MIiENnNy3HJ0zXv8Zkw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.62.2.tgz", + "integrity": "sha512-v39RCCvj4He82I9sFmk+M1VZ0PLM9sfsLVikjfx2hYBNALhrrOR2D3JjQA6AhlaSOgcR+RzrKY7e1+bT6SUO/A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.62.2.tgz", + "integrity": "sha512-yl0y2vq3S3lHeuXhEdss6TWfKW8vkujImO12tn4ZkG/4oghr09LvdYm2RElVjokTQiUvDUGXLGsYeLqUMCKpGA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.62.2.tgz", + "integrity": "sha512-tT4pvt4qXD+vEoezupCWi+a1F0vvDiksiHc+PxRlYTOH1I6/X4id9jPxTP+Fg+545euaFT1jJVs4CEdHZAU1vw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.62.2.tgz", + "integrity": "sha512-6nU5F2wCW+qvCBhTn1pdIU3bzsIoF7EUwsCDRxilWGprQR6yd508YnH9+OKFCwpfS8pjZqDUmnCAr7exax0XCg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.62.2.tgz", + "integrity": "sha512-n1GJHPOvpIfhi3TmrCeh6S6URt9BFCt0KQE3qvexyGCTAKpR4Lg+eWvNZEqu7epxwus/8ElT3hacYEucm49SZg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.62.2.tgz", + "integrity": "sha512-JqgflS8wEB+UXV/vS1RpRbifGBeN4D5lz8D8oOFbFZw4vedvdOgCFAjfBmIMdW3yL10XpQQ0Ambepw6MXrhOnA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.62.2.tgz", + "integrity": "sha512-wnFJkogWvN4jm/hQRF2UBaeUmk20j5+DmHvoyWii2b8HJDyvz1MF2OU/6ynXt2KR63rbZLWkFpoytpdc/yBuSA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.62.2.tgz", + "integrity": "sha512-HVu2bp0zhvJ8xHEV9+UUs7S90VadmBSY3LcIMvozbPo4AuMGDWlz3ymHLHZPX4hR67TKTt8Qp5PJ5RBg/i+RMQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.62.2.tgz", + "integrity": "sha512-mQqqAV8QaoSgr9I2fKDLY2BAVvmKjWoGiu/cSYQonsLvtqwEn1E4QYfnCOcp5zoEqNhsDYin1s6jx/VJmrxlZg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.62.2.tgz", + "integrity": "sha512-IxKLoxCQ2IWi6bT2akyDUBGsOImDKB+sPp4EsTmwFQ/fMwpCKm8uLSSgP/Kx/QYUgKis6SEZ5/Nlhup0DIA0PQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.62.2.tgz", + "integrity": "sha512-Mk5ha2RQSgyFfmYYLkBpPnUk8D8FriBxesO1u9O75X0mHgXL1UQcH5Itl2lurWL2tj0RxV9b9tJgipac0hRY9A==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.62.2.tgz", + "integrity": "sha512-CjvEnqJL/0/TQ3TXX3OPIJ/kmBellrWd4heXUmHeJlTnmwjKpSJzoehLaL6Xk0ZnMHBu9dZuFADNOrtjF4v+2w==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.62.2.tgz", + "integrity": "sha512-1SiZbzwdkaDURsew/tSOrooKiYy7EQGT6m8ufavAi9NEyQb/6VuIxFXAL1fqa4iZe3g4NbNk4P7J32z2tw5Mgg==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.62.2.tgz", + "integrity": "sha512-nQts12zJ3NQRoE6uYljOH89v7szzLDvG2JD/vsX+vGXU8w/At1GowTZ5/7qeFQ8m7L55rpR8Okugnuo5bgjy2Q==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.62.2.tgz", + "integrity": "sha512-E9/ll019jhPIJgpzfZoIkBGhcz+kKNgVWYRY0zr9srBdPPFVpvOKW8VaJKUbeK+eZXyQF9ltME+Kk6affeaPgg==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.62.2.tgz", + "integrity": "sha512-5BqxR/pshjey51iliyzTD5Xi3EN0aLmQ2lZ3lvefVV9c82BvrLo2/6OT55iifpWBufs6kdwWbuOKS841DrmK9A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.62.2.tgz", + "integrity": "sha512-uNN83XxQrRAh/w0/pmAfibcwyb6YWt4gP+dpnQKPVJshAloQ785ii8CT8ZCIxkGg9opVsvAlGhFitSm6D1Jjpg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.62.2.tgz", + "integrity": "sha512-srjEIxSH3LRnJN6THczDHWQplqEMFiAJrTab0msUryh9kwNpkICf3Ea6q6MN/2cZwRFUNx5w+h6Hpi4QuHS6Zg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.62.2.tgz", + "integrity": "sha512-8hOJnxgbyObnCm5AlRA3A931xX19xq80RjVTKgJOvEKWqJruP/Uf12IbAOaDjjEXYRewwHLfmF0YRIdK3OwKWA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.62.2.tgz", + "integrity": "sha512-mmF4AY1i0hG/bLWUctUq59gtmgaSIRa3cu/A3JFRp/sCNEme2bgDEiDS22P9FbnJB8NJNF4jPJiSP5RHQpUTDg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.62.2.tgz", + "integrity": "sha512-DZgkknc6jhHrk46V25vbAM0zZkyP0nSDkJB8/dRkLTxv470dOmWDqGoEJl/9A0dFfS7yE3REOwNDxpHwSLSt0Q==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.62.2.tgz", + "integrity": "sha512-T6xr6ucWSFto+VGajA8YH26LdpHRuP4YLHEKAtCWvJDOlnmWcDZVCI2Jmjr+IFHDlt2zRaTAKE4tfjTaWLgJBg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.62.2.tgz", + "integrity": "sha512-BfzEnDJOt9T8M989/lA37EcJgat01wLRnoi5dQf3QzOH7jzpqTAzdDbVfRljVr5r+jzKqpbHeyOfAaXxAd0PAA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@types/estree": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", + "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/esbuild": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.21.5.tgz", + "integrity": "sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=12" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.21.5", + "@esbuild/android-arm": "0.21.5", + "@esbuild/android-arm64": "0.21.5", + "@esbuild/android-x64": "0.21.5", + "@esbuild/darwin-arm64": "0.21.5", + "@esbuild/darwin-x64": "0.21.5", + "@esbuild/freebsd-arm64": "0.21.5", + "@esbuild/freebsd-x64": "0.21.5", + "@esbuild/linux-arm": "0.21.5", + "@esbuild/linux-arm64": "0.21.5", + "@esbuild/linux-ia32": "0.21.5", + "@esbuild/linux-loong64": "0.21.5", + "@esbuild/linux-mips64el": "0.21.5", + "@esbuild/linux-ppc64": "0.21.5", + "@esbuild/linux-riscv64": "0.21.5", + "@esbuild/linux-s390x": "0.21.5", + "@esbuild/linux-x64": "0.21.5", + "@esbuild/netbsd-x64": "0.21.5", + "@esbuild/openbsd-x64": "0.21.5", + "@esbuild/sunos-x64": "0.21.5", + "@esbuild/win32-arm64": "0.21.5", + "@esbuild/win32-ia32": "0.21.5", + "@esbuild/win32-x64": "0.21.5" + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/nanoid": { + "version": "3.3.16", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.16.tgz", + "integrity": "sha512-bzlKTyNJ7+LdGIIwy8ijFpIqEQIvafahV7eYykJ8Cvh42EdJeODoJ6gUJXpQJvej1BddH8OqTXZNE/KfbWAu8Q==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/postcss": { + "version": "8.5.19", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.19.tgz", + "integrity": "sha512-Mz8SaolMd8nB+G13WkORcxQKHZ/NE4xXevtkJHVuG+guo9/wYKlIMTKAqGdEmYOXR2ijPjTYNHssizdaVSUNdQ==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.12", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/rollup": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.62.2.tgz", + "integrity": "sha512-RFnrW4lhXA3s3eqHDZvN654g8OTjzRfqpIRJYczCGB6HzphckVAi/Qh4tbPUbRuDi7s1Llv8g/NspLkttY3gTA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.9" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@rollup/rollup-android-arm-eabi": "4.62.2", + "@rollup/rollup-android-arm64": "4.62.2", + "@rollup/rollup-darwin-arm64": "4.62.2", + "@rollup/rollup-darwin-x64": "4.62.2", + "@rollup/rollup-freebsd-arm64": "4.62.2", + "@rollup/rollup-freebsd-x64": "4.62.2", + "@rollup/rollup-linux-arm-gnueabihf": "4.62.2", + "@rollup/rollup-linux-arm-musleabihf": "4.62.2", + "@rollup/rollup-linux-arm64-gnu": "4.62.2", + "@rollup/rollup-linux-arm64-musl": "4.62.2", + "@rollup/rollup-linux-loong64-gnu": "4.62.2", + "@rollup/rollup-linux-loong64-musl": "4.62.2", + "@rollup/rollup-linux-ppc64-gnu": "4.62.2", + "@rollup/rollup-linux-ppc64-musl": "4.62.2", + "@rollup/rollup-linux-riscv64-gnu": "4.62.2", + "@rollup/rollup-linux-riscv64-musl": "4.62.2", + "@rollup/rollup-linux-s390x-gnu": "4.62.2", + "@rollup/rollup-linux-x64-gnu": "4.62.2", + "@rollup/rollup-linux-x64-musl": "4.62.2", + "@rollup/rollup-openbsd-x64": "4.62.2", + "@rollup/rollup-openharmony-arm64": "4.62.2", + "@rollup/rollup-win32-arm64-msvc": "4.62.2", + "@rollup/rollup-win32-ia32-msvc": "4.62.2", + "@rollup/rollup-win32-x64-gnu": "4.62.2", + "@rollup/rollup-win32-x64-msvc": "4.62.2", + "fsevents": "~2.3.2" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/vite": { + "version": "5.4.21", + "resolved": "https://registry.npmjs.org/vite/-/vite-5.4.21.tgz", + "integrity": "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.21.3", + "postcss": "^8.4.43", + "rollup": "^4.20.0" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^18.0.0 || >=20.0.0", + "less": "*", + "lightningcss": "^1.21.0", + "sass": "*", + "sass-embedded": "*", + "stylus": "*", + "sugarss": "*", + "terser": "^5.4.0" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + } + } + } + } +} diff --git a/client/package.json b/client/package.json new file mode 100644 index 0000000..44935da --- /dev/null +++ b/client/package.json @@ -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" + } +} diff --git a/client/public/style.css b/client/public/style.css new file mode 100644 index 0000000..8135e46 --- /dev/null +++ b/client/public/style.css @@ -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; } +} diff --git a/client/src/cards/generate.ts b/client/src/cards/generate.ts new file mode 100644 index 0000000..1a266a6 --- /dev/null +++ b/client/src/cards/generate.ts @@ -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(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(); + 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): 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; +} diff --git a/client/src/lib/api.ts b/client/src/lib/api.ts new file mode 100644 index 0000000..c99ce08 --- /dev/null +++ b/client/src/lib/api.ts @@ -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(path: string, opts?: RequestInit): Promise { + 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('/api/host/playlists'), + hostPlaylistTracks: (id: string) => req(`/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' }), +}; diff --git a/client/src/lib/pkce.ts b/client/src/lib/pkce.ts new file mode 100644 index 0000000..d81e5b5 --- /dev/null +++ b/client/src/lib/pkce.ts @@ -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 { + 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 { + 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 { + 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 { + 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 { + 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; +} diff --git a/client/src/main.ts b/client/src/main.ts new file mode 100644 index 0000000..55dc923 --- /dev/null +++ b/client/src/main.ts @@ -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, '&').replace(//g, '>').replace(/"/g, '"'); +} + +// 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, interactive: boolean): string { + const facetBadge = (facet: string) => + facet === 'title' ? '' + : facet === 'artist' ? '' + : ''; + + return ` +
+ = exact song  ·  + = any track by that artist +
+
+ ${card.squares.map((sq, i) => { + const isMarked = sq.facet === 'free' || marked.has(i); + const canTap = interactive && sq.facet !== 'free'; + return ` +
+ ${sq.image ? `` : ''} + ${facetBadge(sq.facet)} + ${esc(sq.text)} +
+ `; + }).join('')} +
+ `; +} + +// ── Router ────────────────────────────────────────────────────────── +type Route = { name: string; params: Record }; + +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 = `
guest login failed: ${esc((e as Error).message)}
`; + 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 = `
${esc((e as Error).message)}
`; + } +} + +window.addEventListener('hashchange', render); +render(); + +// ── Home ──────────────────────────────────────────────────────────── +function renderHome() { + view.innerHTML = ` +
+

What are you doing?

+

Pick a mode — all three make bingo cards from real Spotify playlists, no invented data.

+
+
+

Host a live session

+

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.

+ Host a game → +
+
+

Join a session

+

Someone else is hosting — enter their code, get your own card, no login needed.

+ Join a game → +
+
+

Play solo

+

Log into your own Spotify (or paste a public playlist URL) and generate a one-off card, no session.

+ Play solo → +
+ `; +} + +// ── Host: connect + pick playlist + create session ───────────────── +async function renderHost() { + view.innerHTML = `
checking Spotify connection…
`; + const status = await api.hostStatus().catch(() => ({ connected: false })); + + if (!status.connected) { + view.innerHTML = ` +
+

Connect Spotify

+

One-time host connection — used to read your playlists and (optionally) auto-call from your live playback.

+ Connect Spotify → +
+ `; + return; + } + + view.innerHTML = `
loading your playlists…
`; + const playlists = await api.hostPlaylists(); + const usable = playlists.filter(p => p.trackCount >= 12); + + view.innerHTML = ` +
+

Pick a playlist

+

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.

+
    + ${usable.map(p => ` +
  • + ${p.image ? `` : '
    '} + ${esc(p.name)} + ${p.trackCount} tracks +
  • + `).join('')} +
+
+ `; + + view.querySelectorAll('.playlist-row').forEach(row => { + row.addEventListener('click', async () => { + const { id, name } = row.dataset; + view.innerHTML = `
creating session…
`; + try { + const session = await api.createSession(id!, name!); + location.hash = `#/caller/${session.code}`; + } catch (e) { + view.innerHTML = `
${esc((e as Error).message)}
back`; + } + }); + }); +} + +// ── Caller screen (host) ──────────────────────────────────────────── +async function renderCaller(code: string) { + view.innerHTML = `
loading session…
`; + 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 = ` +
+

Hosting: ${esc(session.playlistName)}

+
${esc(code)}
+

join at ${esc(joinUrl.replace('https://', '').replace('http://', ''))}

+
+ +
+ ${last ? ` + ${last.image ? `` : ''} +
${esc(last.title)}
+
${esc(last.artist)}
+ ` : `

nothing called yet

`} +
+ +
+
+ +
+ + +
+
+
+ ${calledIds.map(id => { + const t = trackById(id); + return t ? `${esc(t.title)}` : ''; + }).join('')} +
+
+ `; + + 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 = ` +
+

Join a session

+ + +
+
+ `; + 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 = `
joining session ${esc(code)}…
`; + let session; + try { + session = await api.joinSession(code); + } catch (e) { + view.innerHTML = `
${esc((e as Error).message)}
try another code`; + 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(); + + function paint(calledIds: string[]) { + const autoMarked = new Set(); + for (const id of calledIds) for (const i of markedIndices(card, id)) autoMarked.add(i); + const effective = new Set([...autoMarked, ...selfMarked]); + const won = hasBingo(card, effective); + + view.innerHTML = ` +
+

${esc(session!.playlistName)}

+

code: ${esc(code)}

+
+ ${renderCardGrid(card, effective, true)} +

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

+ ${won ? `
🎉 BINGO!
` : ''} + `; + + view.querySelectorAll('.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 = ` +
+

Play solo

+

Log into your own Spotify to pick a playlist, or skip that and paste any public playlist URL below.

+ +
+
+

Or use a public playlist URL

+ + +
+
+ `; + 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 = `

loading…

`; + try { + const data = await api.publicPlaylist(url); + renderSoloCard(data.tracks, data.name); + } catch (e) { + errEl.innerHTML = `
${esc((e as Error).message)}
`; + } + }); + return; + } + + view.innerHTML = `
loading your playlists…
`; + const playlists = await fetchGuestPlaylists(token.access_token).catch(() => []); + const usable = playlists.filter(p => p.trackCount >= 12); + + view.innerHTML = ` +
+

Pick a playlist

+ +
+
+
    + ${usable.map(p => ` +
  • + ${p.image ? `` : '
    '} + ${esc(p.name)} + ${p.trackCount} tracks +
  • + `).join('')} +
+
+ `; + document.getElementById('logout')?.addEventListener('click', () => { guestLogout(); render(); }); + view.querySelectorAll('.playlist-row').forEach(row => { + row.addEventListener('click', async () => { + const { id, name } = row.dataset; + view.innerHTML = `
loading tracks…
`; + const tracks = await fetchGuestPlaylistTracks(token.access_token, id!); + renderSoloCard(tracks, name!); + }); + }); +} + +function renderSoloCard(tracks: PoolTrack[], playlistName: string) { + if (tracks.length < 12) { + view.innerHTML = `
this playlist only has ${tracks.length} tracks — need at least 12.
back`; + return; + } + const seed = Math.floor(Math.random() * 2 ** 31); + const card: BingoCard = generateCard(tracks, seed, CARD_OPTS); + + const marked = new Set(); + + function paint() { + view.innerHTML = ` +
+

${esc(playlistName)}

+

tap squares yourself as tracks play — solo mode has no live sync

+
+ ${renderCardGrid(card, marked, true)} + ${hasBingo(card, marked) ? `
🎉 BINGO!
` : ''} + `; + view.querySelectorAll('.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(); +} diff --git a/client/tsconfig.json b/client/tsconfig.json new file mode 100644 index 0000000..f571014 --- /dev/null +++ b/client/tsconfig.json @@ -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"] +} diff --git a/client/vite.config.ts b/client/vite.config.ts new file mode 100644 index 0000000..16f9882 --- /dev/null +++ b/client/vite.config.ts @@ -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', + }, + }, +}); diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..12d731d --- /dev/null +++ b/docker-compose.yml @@ -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: diff --git a/nginx.conf b/nginx.conf new file mode 100644 index 0000000..56dbed6 --- /dev/null +++ b/nginx.conf @@ -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; + } +} diff --git a/server/index.js b/server/index.js new file mode 100644 index 0000000..bc87e56 --- /dev/null +++ b/server/index.js @@ -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}`); +}); diff --git a/server/package-lock.json b/server/package-lock.json new file mode 100644 index 0000000..328e1cf --- /dev/null +++ b/server/package-lock.json @@ -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" + } + } + } +} diff --git a/server/package.json b/server/package.json new file mode 100644 index 0000000..2b7c107 --- /dev/null +++ b/server/package.json @@ -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" + } +} diff --git a/server/routes/hostAuth.js b/server/routes/hostAuth.js new file mode 100644 index 0000000..e4e979b --- /dev/null +++ b/server/routes/hostAuth.js @@ -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; diff --git a/server/routes/hostPlaylists.js b/server/routes/hostPlaylists.js new file mode 100644 index 0000000..39fba94 --- /dev/null +++ b/server/routes/hostPlaylists.js @@ -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; diff --git a/server/routes/publicPlaylist.js b/server/routes/publicPlaylist.js new file mode 100644 index 0000000..9829832 --- /dev/null +++ b/server/routes/publicPlaylist.js @@ -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= +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; diff --git a/server/routes/session.js b/server/routes/session.js new file mode 100644 index 0000000..c7d20a7 --- /dev/null +++ b/server/routes/session.js @@ -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} */ +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;