No description
  • JavaScript 86.4%
  • CSS 9.4%
  • Shell 3.5%
  • Dockerfile 0.7%
Find a file
Repository files (latest commit first)
Filename Latest commit message Latest commit date
elphiene dac3008485 Fix the crushed, unaligned station rows on a phone
A6 gave the directory row four controls — air pill, pause, skip, Manage — and
.row-acts is `flex: 1 1 auto` sitting next to `.row-main { flex: 1 }`. The two
split the width, the actions took the ~340px they need, and the text was left
with ~100px, wrapping ONE WORD PER LINE: "156 / tracks / (2 / removed / by /
hand)". There was no phone override for .row at all, which is how a row built
for a desktop matrix ended up on a 375px screen unchanged.

THREE BLOCKS, ONE LEFT EDGE. The text takes the full width, then the pill, then
the controls. The old `justify-content: flex-end` is what made this read as
unaligned even when it fitted — the actions floated right while everything above
them was left, so nothing lined up with anything.

The pill keeps its own line because it cannot be shortened. "idle — nobody
listening" is the state AND the reason, and CLAUDE.md records that conflating
the three kinds of quiet is what let a station sit silent for ten hours. At
375px the pill is ~200px and three 44px targets with gaps are ~148px, which
does not fit in the ~319px available, so it is two lines or an abbreviation.

TWO THINGS I GOT WRONG ON THE WAY, both caught by rendering it rather than
reasoning about it:

- `flex-basis: 100%` puts the pill on its own line and also makes it FILL that
  line, so a status chip rendered as a full-width banner. The actions are a
  grid on phone now; a grid row lets it own the line at content width.
- The alignment was an INLINE style, which no media query can override without
  !important — so the pill and buttons stayed pinned right through two attempts
  at fixing exactly that. It is a .station-acts class now, with both the desktop
  right-align and the phone left-align in the stylesheet where the cascade
  works normally.

Vertical padding and the row gap come down a step to pay for the extra line.
2026-09-08 07:32:29 +10:00
deploy The first fifteen seconds of a listen page 2026-09-03 14:01:44 +10:00
docs Fix the white page on a themed station, move notices to their control, thin the phone layout 2026-09-08 07:10:36 +10:00
server Fix the crushed, unaligned station rows on a phone 2026-09-08 07:32:29 +10:00
web-ui Fix the crushed, unaligned station rows on a phone 2026-09-08 07:32:29 +10:00
.dockerignore Tidal Radio: self-hosted multi-station live radio 2026-08-26 12:53:32 +10:00
.gitignore Tidal Radio: self-hosted multi-station live radio 2026-08-26 12:53:32 +10:00
README.md Fix a four-day outage: the limiter must not sit upstream of the safety net 2026-08-31 09:20:54 +10:00

Tidal Radio

A self-hosted, multi-station live radio. People link their Tidal playlists to a station, point it at one or more artists, or submit individual songs; each station downloads the audio into a shared library and streams a fair-shuffle rotation live, continuously, as its own Icecast mount.

A station can also be defined by genre rather than by a link — pick tags (indie, folk, electronic…) and it draws from what has already been downloaded. That kind never downloads anything itself; it is a filter over the existing library, and new downloads join it automatically once they are tagged.

Tidal is used only as the catalogue + source of files — a submitted track is downloaded once into the shared library and played from there, never streamed live from Tidal itself (its API has no streaming surface).

Sync is fan-in only. Tidal Radio never writes to a Tidal playlist. Linking a playlist to a station cannot alter that playlist, by construction — there is no write path in the Tidal client.

Status: running in production with five stations and a 450-track library, plus a 15,711-file local collection indexed and matched on demand. Phase 1 (the source model and recurring playlist sync) is complete, and so is a first pass of the auto-DJ layer: per-track loudness normalisation, an output limiter, dead-air trimming, beat-grid measurement, energy-continuity weighting in rotation, and idling when nobody is tuned in.

docs/ROADMAP.md is the live plan — "At a glance" for a scannable list, then the same items with the reasoning and the measured numbers behind them.

Running (dev, no external services)

All three mode switches default to their fake side, so this needs no credentials, no containers, and no Docker:

cd server
npm install
cp .env.example .env
npm run dev        # http://localhost:3000, node --watch

First registered account becomes admin automatically; anyone after that queues for admin approval. Creating that first account from a non-loopback address requires SETUP_TOKEN to be set — see the comment in server/.env.example.

Running (full stack)

Four containers: node (this app), liquidsoap (pulls each active station's rotation, pushes to Icecast), icecast (one mount per station), caddy (TLS + routing).

cd deploy
cp .env.example .env    # fill in the secrets the comments call for
docker compose -p tidal-radio up -d --build

Leave DOMAIN unset for plain HTTP on CADDY_HTTP_PORT. See deploy/README.md for how a station change reaches Liquidsoap (it watches the generated radio.liq and restarts itself, draining first), and docs/SETUP.md for a walkthrough that proves the audio pipeline before any Tidal credentials exist.

Modes (env-switchable, see server/.env.example)

Mode Dev default Prod value Meaning
CATALOG_MODE mock live Catalog search — built-in fixture vs Tidal Open API v2
DOWNLOADER_MODE stub tidekeeper Placeholder tone file vs real download
PLAYER_MODE stub stream Simulated playback vs Liquidsoap → Icecast

LASTFM_API_KEY is a fourth, softer switch. It turns on genre/mood tagging, which is what genre stations filter on — Tidal exposes no genre field anywhere, so Last.fm is the only source. It is an enrichment: with no key the tagging pass simply does not run and every station keeps working. Last.fm's terms require attribution wherever tags are shown.

Tags are fetched in two layers: per track where Last.fm has them (about half the library), falling back to per artist. The fallback matters because per-track coverage is limited by how obscure the song is, not the artist — but where per-track tags exist they are far more precise, and they are the only way one artist's ballad and their banger get different tags. library.tags_source records which layer answered each row.

COUNTRY_CODE must match the Tidal account's region — a wrong value region-gates the catalogue, and individual tracks 404 while playlists still read fine.

How playback works

In stream mode the audio path is a pull: the app never feeds Liquidsoap. Liquidsoap asks for the next track over an internal HTTP API, and reports back what actually went on air.

liquidsoap  --GET  /internal/:slug/next-->  rotation picks a track
                                        <-- one line: annotate:…:<abs path>
liquidsoap  --POST /internal/:slug/playing-->  play history + WebSocket push

/internal/* lives on the private Docker network, is gated by STREAM_INTERNAL_TOKEN, and fails closed — with no token configured it serves 503, which in stream mode means silence rather than an open door.

What happens to the audio

Each file is measured once, on arrival, and the results are applied per track via the annotate: URI. Nothing is analysed at playback time.

  • Loudness. Integrated LUFS via ffmpeg's ebur128, stored as one static gain, clamped against true peak so it cannot clip. Deliberately not a reactive normaliser — one that follows the signal pushes a fade-out up and then blasts the next track in.
  • Dead air. An RMS pass finds where the audio really starts and ends, and the silence is trimmed with liq_cue_in / liq_cue_out. 95% of this library was carrying trailing silence, which meant the crossfade used to begin inside it.
  • Peak control. An alimiter on the output, because a static gain plus a summing crossfade can still exceed full scale.
  • Crossfades use a log curve — the only near-constant-power shape Liquidsoap offers. lin and sin both dip ~3 dB in the middle of a blend, which is what makes a crossfade sound like a hole.
  • Beat grids are measured with aubio and stored, but nothing aligns audio to them yet — grid phase is unmeasured, and an uncorrected grid moves audio confidently to the wrong place.

Anything unmeasured simply plays unchanged, so a half-analysed library is never half-broken.

Rotation, and idling

Track selection prefers a candidate that follows the last one well — close in tempo and in loudness range. It is a preference and never a filter: every playable track still gets its turn in each cycle, so nobody's song can be starved by an analyser.

Stations stop pulling tracks when nobody is listening, and resume when somebody tunes in. The Icecast mount deliberately stays up serving silence — a dead mount cannot be tuned into, so nobody could ever become the first listener. It fails open: if the listener count cannot be read, the station plays.

Rotation is a rolling weighted deck per station: every playable track gets a turn before any track repeats, with anti-repeat and same-artist windows and a weighting that spreads plays across contributors. server/src/rotation.js documents the rules and the determinism contract behind "Coming up".

Layout

server/src/        the app — Express, better-sqlite3, ws; no build step
server/src/routes/ auth, admin API, server-rendered UI, /internal, /ws
server/tools/      operator CLIs: backups, password reset, analysis backfills
web-ui/            htmx + one stylesheet; the server renders every fragment
deploy/            docker compose, Caddyfile, Icecast + Liquidsoap images
docs/              ROADMAP.md, SETUP.md, fixes/ (notes + test suites)

Tests

Plain node scripts — no test runner. Each suite wipes its own scratch database, so they can run in any order. Run them from server/:

cd server
node ../docs/fixes/rotation-fairness.test.js

There are 24 suites / 773 assertions. Run them all with a glob rather than a hand-kept list — the list had already drifted behind the suites that existed, so a "full run" was quietly a subset:

cd server && for t in ../docs/fixes/*.test.js; do node "$t" || echo "FAILED: $t"; done

Read the header comments — each one names the bug it was written for, and several document a trap that has already cost somebody real time. docs/fixes/rotation-fixes.e2e.sh is separate and needs a running server.

Operator commands

cd deploy
./backup-db.sh                 # verified DB backup (also runs nightly via cron)
./backup-db.sh --list

docker compose -p tidal-radio exec node node tools/measure-loudness.js --dry-run
docker compose -p tidal-radio exec node node tools/measure-cues.js --survey
docker compose -p tidal-radio exec node node tools/measure-beats.js --survey
docker compose -p tidal-radio exec node node tools/measure-tags.js --survey   # genre coverage + the vibe vocabulary
docker compose -p tidal-radio exec node node tools/measure-tags.js --retag    # re-derive from cache, no requests
docker compose -p tidal-radio exec node node tools/recount-plays.js --dry-run
docker compose -p tidal-radio exec node node tools/set-password.js <name> '<pw>'

Long analysis passes should be run detached inside the container (exec -d) — a backgrounded docker compose exec dies with the shell that launched it and leaves the backfill half-done.

License

MIT.