Self-hosted "guess the intro" music quiz for family game night, built on your own Navidrome library. A song's first 5 seconds play and everyone races to name it on their phones — fastest correct answer scores most. Runs on a cast display or Android TV (scoreboard + album art + audio on the big screen), or just as happily on a cast speaker — no scoreboard, phones carry the questions, the speaker carries the music. All local — your music, your network, no subscriptions.
The quiz never writes to your music files or your Navidrome library — ever. It connects with a dedicated non-admin Subsonic login, never sees your filesystem, and cuts its clips from streamed audio into its own folder. Tags, files, playlists and play counts in your library are never created, modified or deleted by any code in this repository. The mis-tag detector reports suspect titles; fixing them is always yours to do, with your own tools, if you choose to.
Before kick-off the setup screen picks the TV to play on. Half-time trivia and the
difficulty — Normal (the songs most people know), Harder (deeper cuts), or
Everything (anything in your library, ignoring the tiers) — are set once on /admin
and apply to every game after that.
- Everyone joins on their phone — scan the QR on the TV (or open the app's LAN address, a plain web page) and pick a name. Anyone already in can also share the join link from their lobby screen. Whoever started the game is the game master — only their phone gets the start / next-song / half-time controls. A 🎤 banner on every phone shows who's in charge, and the master's chair rotates each game: the final screen announces who runs the next one (if they're not playing, anyone can take over from the lobby).
- While waiting in the lobby, each player picks 3 artists they know from a wall of your library's most popular artists (freshly randomised each game) — one song per player's picks is shuffled invisibly into the rounds, so guests and kids aren't slaughtered by the host's record collection. The game won't start until everyone's locked in or skipped.
- Each round: a clip plays on the TV (an animated wave with a live progress bar — no spoilers), four choices appear on the phones (decoys from the same era, never the same artist twice), 20-second window, speed bonus, early reveal when everyone has answered. Phones buzz at each round start; a round nobody answered replays once before revealing.
- Stuck? Anyone can extend the clip (5 → 10 → 20 seconds).
- The reveal shows album art and who got it right while a "payoff" chunk of the song plays — in full: the next-song button stays locked with a countdown until the music finishes. No skipping the good bit.
- At the midpoint, the game breaks for a half-time show: every player's phone gets a music fact to read out to the table, then three quick true-or-false questions — answered on the phones, question up on the TV, +50 points each on the main scoreboard, auto-revealed once everyone's in.
- Rubbish clip (applause intro, ambient noise)? The game master's reveal screen has a 🚫 bad clip link — two taps to confirm — that bans the track forever.
- Ten rounds a game, a 🎺 fanfare on the final scores, persistent all-time leaderboard.
- Library sync — walks your whole Navidrome library over the Subsonic API into SQLite (tracks, artists, durations).
- Recognisability scoring — two signals per track: family (Navidrome play
counts + stars) and global (Last.fm listeners via
track.getInfo). Blended into difficulty tiers: your favourites are "easy"; world-famous songs you own but never play are "medium" — the sweet spot where everyone has a chance; the long tail is "hard" and "tiebreak". Note "easy" comes from your own play counts and stars, so a fresh library with no listening history won't have any — set the difficulty to Everything on/adminand the game draws from the whole playable library instead. - Clip cutting — a background job downloads originals and cuts loudness-normalised
MP3 clips with ffmpeg (5/10/20s intros + a payoff from ~40% in), working through the
library in global-popularity order. Silence-aware: if a track opens with a long
quiet stretch (rain, feedback, ambience — looking at you, metal and post-rock), the
intro clips start where the audible song does (
silencedetect, capped at 60s; re-cut existing tracks viaPOST /api/clips/recut?q=%pattern%). For music that opens with a long audible instrumental instead,CLIP_START_S=30starts every clip that far in (off by default — see docs/setup.md). ID3 tags are stripped and re-titled so a display's now-playing overlay can't leak the answer. Tracks over 12 minutes (DJ mixes) are excluded (tunable viaMAX_DURATION_S, seconds); whole albums can be banned by pattern (POST /api/ban/album). Undecodable originals retry via the music server's transcode before being banned, and a stream that returns an error document (stale index after files were renamed) is recognised rather than fed to ffmpeg. - The game engine — one websocket hub (FastAPI), phases lobby → question → reveal. Rounds are built lazily at first start so artist picks land first. Answer timing is server-side; the correct answer never ships to clients before the reveal.
- The TV board — a second web page cast to the display via DashCast
(pychromecast). The board plays the round audio itself through the Web Audio API
(one audio context for the whole game) — casting clips as media would evict the
scoreboard, because a cast device runs one app at a time. Audio is served from an
anonymous per-round endpoint so phones can't extract the track id mid-round. Android
TV (e.g. Nvidia Shield) autoplays; touch displays (Nest Hub) need one tap to unlock
sound — the board shows an overlay asking for it. While it waits for a game — and in
the lobby, for latecomers — the board shows a scan-to-join QR of the app's address,
so players point a camera at the TV instead of typing a URL (override the encoded
address with
JOIN_URLif the app is reached via a different host). Optionally setGUEST_WIFI_SSID/GUEST_WIFI_PASSWORDand a second QR joins guests to the guest WiFi first — seedocs/setup.md(NB make sure the join URL is reachable from the guest network — guest networks commonly isolate guests from the LAN or block them reaching other hosts entirely). - Half-time trivia — a curated seed pack ships in the repo (~180 read-aloud music
facts + ~215 true/false questions, deliberately Irish/UK-centric — Eurovision,
Thin Lizzy and Westlife feature) and lives in SQLite; the true/false pool tops
itself up from Open Trivia DB whenever it runs low
(
POST /api/trivia/topup, also called automatically at game start). Picks prefer never-used items and recycle oldest-first, so repeats take months. Answers never ship to phones before the reveal. Not your region? See Make your own trivia pack below. Not your thing at all? Turn it off on the setup screen and the game plays straight through, no half-time break — the choice is remembered. - Speaker-only mode — pick "no scoreboard" at game start and clips cast to a speaker via Home Assistant + Music Assistant instead; the phones do the rest. A display isn't required to play.
- Upkeep — schedule the four maintenance endpoints nightly with whatever you
like (cron, systemd timer):
POST /api/sync,/api/score/lastfm,/api/score/tiers,/api/clips/cut— the library re-syncs, new tracks get scored, tiered and clipped. Optionally addPOST /api/quality/checkas a fifth step to get told when new tracks look mis-tagged (see Notes). Clips cost ~2 MB per track. - Server control page —
/adminruns any of those maintenance actions from the browser instead: one job at a time, live progress while it runs, the run's log output, and each action's last outcome. It also shows the running game (players, scores, round — never the current song, so a playing admin can't cheat) with abandon game and change game master controls, and holds the game settings — half-time trivia on/off and the difficulty. SetADMIN_PASSWORDin.envto gate it (the page asks once); unset, it's as open as the rest of the app on your LAN. NB once set, scheduled curls need-H "X-Admin-Token: $ADMIN_PASSWORD"too.
You need: a Navidrome server, Docker, a free Last.fm API key — and at least one audio output: a cast display / Android TV, or Home Assistant + Music Assistant for a speaker. With neither, the game is silent and unplayable — the clips have to play somewhere. (Running outside Docker? Python 3.12+ and ffmpeg required.)
docker compose up -d --build
Prefer not to build? Pre-built multi-arch (amd64/arm64) images are published on each
release — docker compose pull && docker compose up -d grabs them from
ghcr.io/colfin22/intro-quiz (also on Docker Hub).
Copy .env.example to .env and fill in the required values — the file
is self-documenting and grouped into required / audio-output / optional. You'll set a
Navidrome server + a non-admin user, your Last.fm key, and at least one audio output.
⚠️ Casting'sBOARD_URLhas two hard prerequisites — get these wrong and nothing casts.
- A valid HTTPS cert on a real domain. Cast displays and Nest speakers silently refuse plain HTTP or self-signed certs — so the board must be served over TLS with a trusted cert, even though everything runs on your own LAN.
- The domain must resolve on public DNS. Google Cast devices hard-code Google's DNS (8.8.8.8) and ignore your router/Pi-hole, so a purely-internal name won't work — it has to resolve externally (pointed at your LAN IP is fine).
You don't have to expose the app to the internet for either. Full walkthrough, including a no-open-ports method: docs/https-lan.md.
Then run the one-time setup: open the server control page at
http://localhost:8000/admin (use the host's IP instead of localhost if you're on
another computer) and press Full pipeline — you can watch the progress and any
errors right on the page. Or do the same from a terminal:
curl -X POST http://localhost:8000/api/bootstrap
Either way it does everything in one go — sync, Last.fm scoring, difficulty tiers, then
clip cutting — in the background. Clip cutting is the long part (hours on a big
library, bottlenecked on the Navidrome download), so leave it running; run it again if it
stops and it picks up where it left off. It's ready when http://localhost:8000/health
shows "ready_to_play": true — then phones open http://localhost:8000 to join and the
board is at /board.
That's the whole happy path. For the rest — ongoing clip-cutting, running the setup stages individually, Navidrome user permissions, clip storage and sizing, and Windows — see docs/setup.md.
The shipped half-time pack is Irish/UK-centric. The easiest way to localise it is
the Trivia tab on /admin: type your region, press Copy prompt, paste the
prompt into ChatGPT (or any LLM), then paste its reply back into the import box —
a preview shows what will be added (and flags anything malformed) before you
commit. Imported items are played first, and duplicates are skipped, so you can
keep topping up. Fact-check what an LLM writes before your family reads it out.
Prefer a file? Drop a trivia_custom.json beside quiz.db (default ./data/) —
a flat JSON list of {"kind":"fact","text":...} and
{"kind":"tf","text":...,"answer":1|0} items, seeded automatically at the next
game start. Full format and rules: docs/trivia-pack.md.
- No sound on the first round? Browsers block audio until you've interacted with the page. If you're playing in a browser tab (e.g. AirPlaying from a laptop, or on a smart display), click or tap anywhere in the game tab once — audio then works for the rest of the session. Casting to a TV/Chromecast doesn't have this issue.
- Navidrome play counts are per-user; the family score aggregates the
annotationtable exported from Navidrome's DB and posted toPOST /api/ingest/annotations(rows of{"id", "play_count", "starred"}summed across your users). - Tests:
python -m pytest tests/(includes node-based smokes that render every phone-UI phase and the admin page — a thrown render fails CI instead of shipping a half-drawn screen). - The all-time leaderboard can be wiped from the Scores tab on
/admin(two clicks), or withPOST /api/leaderboard/reset?confirm=yes. - Mis-tag detection: junk in a track's subtitle tag ("Teenage Kicks (PMEDIA)")
breaks Last.fm matching, so the track scores zero and never gets picked.
GET /api/qualitylists tracks scoring ~no listeners while their artist is clearly popular — the tell-tale of a mangled title.POST /api/quality/checkre-runs it and, ifHA_URL/HA_TOKEN/HA_NOTIFY_SERVICEare set, pushes new suspects to your phone via Home Assistant (HA_NOTIFY_SERVICEis your notify action, e.g.notify.mobile_app_<device>); otherwise just read/api/quality. Baseline once after install with?push=falseso only future misses alert. Fix = clean the tags, rescan, re-sync (POST /api/bootstrap).
Planned (roughly in priority order):
- Jellyfin & Plex library sources (high priority) — support Jellyfin and Plex as music-library backends alongside Subsonic (behind a provider interface), so you're not limited to a Navidrome/LMS server. The most-requested feature (#51).
- Hear more after the reveal — once everyone's guessed, optionally play a longer snippet or the whole song, with playback controls to keep it going (#44).
Recently shipped:
- Scan-to-join QR — the TV board and phone lobby show a QR of the join address
(plus a share-link action), so players scan instead of typing a URL; optional
JOIN_URLoverride for reverse-proxy setups. Contributed by @Axeion — the project's first external PR (#54). - Server control page —
/adminruns the maintenance jobs from the browser with live progress and error output, shows the running game (spoiler-safe) with abandon/change-master controls, and imports localised LLM-drafted trivia with a preview (#52, #53). - Pre-built Docker images — multi-arch (amd64/arm64) images published to
ghcr.io/colfin22/intro-quizand Docker Hub on every release, so you candocker compose pullinstead of building from source (#33). - Rock-solid casting — fixed the mid-game board crashes on Chromecast/Shield. Clip audio is rebuilt on a single Web Audio context (a fresh
<audio>per clip was exhausting the receiver's decoder), and a stray between-games quit no longer interrupts back-to-back games. No setup change — the same DashCast casting, now stable (#32, #47).
Built by Colm Finn — MIT licensed.










