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>
app/layout.tsx — the player is a sibling of every page, never a child of one

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.

  1. 1ParseThe PDF becomes structured records: title, film, credits, and the station each entry sits under.
  2. 2LoadRecords go into SQLite — 12.8 MB, committed, so every stage is resumable and nothing is re-fetched.
  3. 3ResolveEach song is matched to a YouTube video from community data, harvested channel listings, and per-song search, cheapest source first.
  4. 4VerifyEvery match is checked for whether it actually embeds. A song that looks resolved and plays nothing is worse than one that is missing.
  5. 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 catalogue
the shape of it — the full sequence, including three more resolvers and the portrait pass, is in the README

Song 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 theirs
pipeline/songids.py — an id is now a name tag, not a line number

What 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 pretend
app/api/feedback/route.ts — the body is the answer, not the status

Offline, 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

  • Host any music. Every track plays through YouTube's own embedded player.
  • Store anything about you anywhere but your own browser.
  • Ask you to sign in, or have anywhere to sign in to.
  • Build any picture of you. Umami is cookieless and sets no identifier that survives the page. Your IP reaches its servers as it would any host; Umami's stated design is to hash rather than keep it, which is their word rather than something this code can prove.
  • 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