Afterhours FM

Web radio that
knows what's playing.

A self-hosted player for 25 Ibiza and European dance stations, with live track identification, album art, persistent play history, and one-click saving to Spotify.

Track names come from each station's own broadcast metadata where it exists, and from audio fingerprinting where it doesn't — no paid recognition API in either path.

FastAPI async SQLAlchemy PostgreSQL Docker nginx Vanilla JS — no build step 64 tests, two database engines MIT

How track identification works

Two sources, tried cheapest first

Most web radio players show you the station name and stop there. The interesting problem is that broadcasters are wildly inconsistent about what they announce — so this uses a cascade rather than a single method.

1

ICY in-band metadata

Icecast and SHOUTcast streams interleave a small text block into the audio every icy-metaint bytes, carrying StreamTitle='Artist - Title'. The backend opens the stream with Icy-MetaData: 1, reads exactly one metadata cycle, and disconnects. It never downloads or transcodes audio.

Cost: one HTTP read, milliseconds · Cached: 15s per station · Accuracy: exact — it's the broadcaster's own announcement

2

Shazam fingerprinting

Only reached when step 1 returns nothing usable — some stations send an empty title, their own station name, or a raw JSON blob. The backend then captures ~10 seconds of live audio and identifies it via vibra, a C++ client for Shazam's fingerprint endpoint.

Cost: ~10–15s round trip · Cached: 45s, negative results included · Accuracy: a good guess from a short sample

Anything identified by fingerprinting is labelled "via Shazam" in the UI. A match from a ten-second sample is a guess; the broadcaster's own metadata is not. Showing the difference is more useful than pretending both are equally certain — and negative results are cached too, so a station that simply doesn't match well isn't re-fingerprinted on every poll.

The app

What it looks like running

No framework and no build step — the entire frontend is one HTML file with inline JS, served straight off disk by nginx.

The Afterhours FM player: a full-bleed hero photo with the station name, the currently playing track with album art, a play/pause bar, and a grid of 25 station buttons.
A live Shazam match — note the “via Shazam” tag under the title, and the album art that arrived with the fingerprint result.
The info panel below the player, showing the Now Playing block with an Add to Spotify button, and a Stream Info grid with station, format, bitrate, sample rate, listeners and on-air time.
Stream stats are read from each station's own response headers — format, bitrate and sample rate for any station, plus listener counts where an Icecast mount could be confidently matched.

Architecture

Where the work happens

There is no background polling task. A stream read happens only when a browser asks for one, and is cached per station. Audio itself never passes through the server — the browser plays each station directly.

Afterhours FM request flow The browser loads the page from nginx and requests now-playing data, which nginx proxies to FastAPI. FastAPI reads ICY metadata from radio stations, falls back to Shazam only when that is empty, queries Spotify for cover art, and persists tracks to PostgreSQL. Audio streams from the stations directly to the browser, bypassing the server. Browser vanilla JS DOCKER — PROD STACK nginx :80 · gzip · cache FastAPI :8000 · async PostgreSQL play history static/ HTML · CSS · WebP EXTERNAL Radio stations ICY / Icecast Shazam via vibra Spotify API art · save track GET /api/… proxy asyncpg Icy-MetaData: 1 only if ICY empty cover art audio streams straight to the browser — never through the server

Details worth knowing

Built against real streams, not assumptions

Most of what follows exists because a live station behaved differently from the spec.

Broadcasters disagree on headers

icy-audio-info vs ice-audio-info, prefixed vs bare parameter names, and a bitrate header that sometimes arrives duplicated as "256, 256". All three were found by hitting real stations.

Redirects to edge nodes

Several stations 302 to a geo-nearest node. httpx doesn't follow redirects by default — unlike curl — so without follow_redirects you silently parse the redirect instead of the stream.

Station names, never URLs

The API takes a station name and resolves the URL server-side from a registry. Accepting a URL from the client would let a caller point the backend at internal addresses it can reach but they can't.

Tested on both database engines

CI runs the full suite against SQLite and PostgreSQL. asyncpg rejects timezone-aware datetimes where SQLite quietly accepts them — a gap that once shipped a bug that every local check passed.

Honest about empty metadata

Some stations announce their own name forever, or a JSON blob instead of a title. Those are filtered out and reported as no track rather than displayed as one.

Listener counts only when certain

Icecast counts are shown for the three stations whose mount could be matched with confidence. One station's port serves four mounts and none map cleanly — it shows a dash rather than a plausible wrong number.

Run it yourself

Two commands

Playback, track identification and history need no configuration at all. The Spotify variables are optional and only affect the save button and cover art for metadata-sourced tracks.

# dev — hot reload, SQLite, http://localhost:8000
git clone https://github.com/kureka77/afterhours-fm.git
cd afterhours-fm
make dev

# prod — PostgreSQL + nginx, http://localhost
make prod
There's no hosted demo, and that's deliberate. The recognition pipeline is the point of the project, and it can't run in a browser: reading ICY metadata cross-origin is blocked by CORS, fingerprinting needs a native binary, and the Spotify integration holds credentials that must never reach a client. A static demo would be the shell without the substance — so it runs on your own machine instead.

Stack

LayerChoice
BackendPython 3.12, FastAPI, Uvicorn
DatabasePostgreSQL 16 in prod, SQLite locally — async SQLAlchemy over asyncpg / aiosqlite
Reverse proxynginx — static files from disk, gzip, long-lived image caching
RecognitionICY in-band metadata over httpx, with vibra/Shazam as fallback
FrontendVanilla HTML, CSS and JS — no framework, no bundler
Testspytest + pytest-asyncio, Vitest — 64 backend tests across two engines