Architecture
Mehfil has no backend, no database at runtime, and no accounts. It is a static site that reads one JSON fileand borrows YouTube's player for the sound. Almost every interesting decision here follows from that one, and most of the work happens long before anyone visits.
What ships
- Catalogue
- 3,916 songs in a single 632 KB JSON file
- Facets
- 415 singers, 1,379 films, 66 stations, 23 composers, 12 lyricists, 12 moods
- Framework
- Next.js 16 App Router, React 19, Tailwind v4
- Server code
- One route — /api/feedback — and nothing else
- Storage
- Four localStorage keys, no cookies. Seven anonymous counts via Umami — the list is below
The catalogue is fetched once and cached forever by TanStack Query, because it only changes when the pipeline re-exports. Song lists are virtualised with react-virtuoso — 3,916 rows is more than a browser will draw without complaint.
The player lives above the pages
Playback is a YouTube iframe, and an iframe cannot survive being unmounted. So the player sits in the root layout rather than in any page: layouts persist across navigation, pages do not. Moving from a station to a singer to the full song list never interrupts the music, and that single constraint shapes most of the component tree — the bar is rendered by the layout and handed down, not owned by a route.
<Providers> // react-query
<PlayerProvider> // owns the YouTube iframe + the queue
<AppFrame>
{children} // ← only this remounts on navigation
</AppFrame>
</PlayerProvider>
</Providers>The same reasoning puts the expanded view in a portal on the body. A backdrop-blurred footer becomes a containing block for anything fixed inside it, so a “full screen” overlay would have resolved against the bar and hung off the bottom of it.
The pipeline is where the real work is
Twenty-four Python scripts turn a printed songlist into something playable. The catalogue starts as the official Carvaan Gold PDF, which is a three-column layout that naive extraction bleeds between — so it is parsed from word coordinates and sliced into columns by x-position before lines are reconstructed at all.
- 1ParseThe PDF becomes structured records: title, film, credits, and the station each entry sits under.
- 2LoadRecords go into SQLite — 12.8 MB, committed, so every stage is resumable and nothing is re-fetched.
- 3ResolveEach song is matched to a YouTube video from community data, harvested channel listings, and per-song search, cheapest source first.
- 4VerifyEvery match is checked for whether it actually embeds. A song that looks resolved and plays nothing is worse than one that is missing.
- 5ExportThe playable subset becomes the JSON the app reads. Nothing else about the database ever reaches a browser.
pdftotext -bbox-layout songlist.pdf full.xml
python3 pipeline/parse_songlist.py full.xml data/songs.json
# load into SQLite: store.ingest_catalogue(conn, songs, stations)
python3 pipeline/import_labnol.py <dir> data/carvaan.db # community
python3 pipeline/harvest_youtube.py data/carvaan.db # channels
python3 pipeline/match_videos.py data/carvaan.db
python3 pipeline/search_youtube.py data/carvaan.db # per song
python3 pipeline/verify_embeddable.py data/carvaan.db
python3 pipeline/export_catalogue.py data/carvaan.db \
web/public/catalogue.json
python3 pipeline/check_ids.py # refuses to ship a drifted catalogueSong ids come from a committed ledger and are append-only. They used to be positions in an alphabetical list, which meant adding one song renumbered nearly all of them — and since videos are stored against those ids, the next rebuild would have handed almost every song the previous song's recording. Silently. A check now refuses to load or publish a catalogue whose ids disagree with the ledger.
# before — the id was wherever the song happened to sort
for song_id, song in enumerate(sorted(catalogue), start=1):
song["id"] = song_id
# after — the id is whatever it has always been
for song in catalogue:
key = song_key(song["title"], song["film"])
if key not in ledger:
ledger[key] = next_id # a genuinely new song
next_id += 1
song["id"] = ledger[key] # everyone else keeps theirsWhat the one server route is for
Reporting a wrong recording, or sending a link for a missing song, posts to /api/feedback, which forwards to a Google Apps Script that appends a row to a sheet. It exists so the webhook URL stays on the server — in the browser it would be a public write endpoint for anyone who opened dev tools.
It refuses to say a report was saved unless the sheet confirms it.Apps Script answers with HTTP 200 even when its own handler has thrown, reporting the failure in the body, so trusting the status code told people their report was recorded when nothing had been written.
const response = await fetch(WEBHOOK, { method: "POST", … });
// 200 is not the same as "a row exists"
const replied = await response.text();
const acknowledged = JSON.parse(replied)?.ok === true;
if (!acknowledged) return error(502); // say so, do not pretendOffline, and staying current
A service worker makes the app installable and keeps the shell usable without a connection. It is network-first for everything except fingerprinted build assets, so a deploy is picked up on the next load rather than whenever a cache happens to expire. It is registered at a URL carrying the build id, because a worker is only replaced when its own bytes change — and a static worker file meant installed apps sat on a complete, working, months-old build.
What is counted
Seven events, and this list is not a description of them — it is them. The same array renders this table and gates what the tracker will send, so an event that is not written here cannot fire, and one that fires cannot go undescribed.
Everything sent is a count or a value from a fixed set — a theme name, a collection kind, “liked” or “unliked”. Nothing free-typed ever leaves the browser: searching is counted, what was typed into the box is not. There is no user id, no session id and no device fingerprint, so two plays by one person and two plays by two people are the same thing here.
What it does not do
Come and have a look
All of it is open, pipeline included — the parser, the matcher, the id ledger and the scripts that found the wrong recordings. If you spot something wrong, or want to make it better, the door is open. Corrections to the catalogue are just as welcome as code.
The repository on GitHub