- JavaScript 49.1%
- Rust 36.1%
- Python 6.4%
- CSS 4.3%
- Shell 2.1%
- Other 2%
| Filename | Latest commit message | Latest commit date |
|---|---|---|
|
All checks were successful
CI / check-caddy (push) Successful in 14s
CI / test-node (push) Successful in 6m23s
CI / check-liquidsoap (push) Successful in 1m25s
CI / build (., deploy/node.Dockerfile, node) (push) Successful in 43s
CI / build (deploy/caddy, deploy/caddy/Dockerfile, caddy) (push) Successful in 29s
CI / build (deploy/essentia, deploy/essentia/Dockerfile, essentia) (push) Successful in 29s
CI / build (deploy/icecast, deploy/icecast/Dockerfile, icecast) (push) Successful in 27s
CI / build (deploy/liquidsoap, deploy/liquidsoap/Dockerfile, liquidsoap) (push) Successful in 28s
CI / build (deploy/analysis, deploy/analysis/Dockerfile, analysis) (push) Successful in 6m33s
The box's pre-flight found every file 96 kbps HE-AAC (tidekeeper -q Normal), not the 320k AAC-LC the build assumed; symphonia has no SBR decoder and the native path refused all 496 playable rows. Decoder is now a wrapper: symphonia when it is exact, otherwise ffpipe::FfmpegDecoder — ffprobe for rate/duration, ffmpeg spawned lazily at the first read as f32le stereo 44.1k on a pipe, killed on drop. Seeks up to 90 s decode from the start and discard (bit-identical to the reference decode-check grades against: −300 dB); further seeks jump the demuxer 2 s early and discard the pre-roll (lag 0; SBR's noise/sinusoid indices cannot be reproduced after a jump, so only the noise realisation differs — documented in the module). Every call site (engine, analysis, verify-library, audition, render, seed) gets the fallback through Decoder::open. verify-library reports how many rows go through ffmpeg; live.rs logs each late block's lag at debug. Gated: decode-check on a real Tidal HE-AAC file from the start and at seeks of 1.3, 30 and 89.9 s (−300 dB, lag +0); short HE-AAC fixtures seeded, analysed and served for 130 s with transitions and a clean drain; workspace tests 111/0; plan --oracle 0 diffs. CUTOVER.md records the finding; the compose pins DOWNLOAD_CMD_TEMPLATE to -q Normal as the old stack ran, and notes SETUP_TOKEN is absent from the old env and that the box had no backups before 19 Sep. |
||
| .forgejo/workflows | ||
| deploy | ||
| docs | ||
| radio | ||
| server | ||
| tools | ||
| web-ui-radio | ||
| .dockerignore | ||
| .gitignore | ||
| RADIO_DESIGN.md | ||
| README.md | ||
Warehouse Radio
A self-hosted democratic radio for a workplace. Workers add songs from any phone; the shared rotation streams to the warehouse speaker as a gated web-radio station. Playback is locked by design: anyone can add to the rotation, but nobody can skip, pause, or queue during the day.
Tidal is only the catalogue and the source of files. A submitted track is downloaded to a local library and played from there, never streamed live from Tidal.
How it works
Worker phones ──▶ search Tidal ──▶ submit ──▶ auto-filters ──▶ download (tidekeeper)
│
▼
local library (FLAC)
│
fair-shuffle rotation engine
│
┌────────────────────────────────┬──────────────┴───────────┐
▼ ▼ ▼
stub (dev) MPD (LAN box) Liquidsoap → Icecast
(gated web radio)
│
┌─────────────────┴─────────────┐
▼ ▼
warehouse speaker 🔊 /listen (browser)
- Submit → filter → download. Search hits the Tidal catalogue; submissions pass auto-filters (explicit / dedupe / per-worker daily cap / blocklist), then download into the library.
- Rotation. A fair-shuffle engine picks the next track. It avoids immediate repeats and spreads plays across submitters, so nobody dominates.
- Playback. Pluggable output:
stub(dev),mpd(local speaker), orstream, where Liquidsoap pulls the rotation and pushes to Icecast.streamis the deploy target. - Auth. Workers sign in with a name and either a password or a passkey. The first
account is admin. PINs still work: they were the original credential, and the app
prompts workers to move off them rather than locking anyone out. Passkeys stay off
until you set
WEBAUTHN_RP_IDandWEBAUTHN_ORIGIN. - Listening.
/listenis the player. A signed-in worker needs nothing extra; anyone without an account needsSTREAM_KEY, which is a link you can share.
Full design rationale: RADIO_DESIGN.md.
Repo layout
| Path | What |
|---|---|
server/src/ |
Node app (Express + better-sqlite3 + ws); see the module map below |
server/db/radio.sql |
Schema: workers, library, submissions, bans, play_history |
web-ui-radio/ |
htmx worker + admin UI, "Dispatch" design system, self-hosted htmx |
deploy/ |
Docker Compose stack (Node + Liquidsoap + Icecast + Caddy) + deploy/README.md |
.forgejo/workflows/ |
Forgejo Actions: validates, then builds + pushes the six images (node, liquidsoap, icecast, caddy, essentia, analysis) |
docs/design-concepts.html |
The three UI concepts that led to "Dispatch" |
Server module map (server/src/)
| Module | Role |
|---|---|
index.js |
Bootstrap: secrets, middleware, route mounting, static UI, engine + downloader start |
config.js |
All env-driven config (single source of truth for the knobs below) |
db.js / radioDb.js |
SQLite connection + radio data access (workers, library, bans, history) |
catalog.js / tidal.js |
Tidal catalogue search (mock or live) over the Tidal Open API client |
filters.js |
Submission auto-filters (explicit / dedupe / rate-limit / blocklist) |
downloader.js |
Bounded download queue → tidekeeper (or stub); crash-recovery, retries |
rotation.js |
Fair-shuffle selection + deterministic "coming up" preview |
radioEngine.js |
Playback orchestration; drives a player or serves the stream pull model |
player.js |
Player backends: stub (simulated) and mpd (TCP) |
barcode.js |
Code 128 encoder for the now-playing barcode; the visualiser keeps its bar widths |
passkeys.js |
WebAuthn registration + assertion (@simplewebauthn/server) |
scope.js |
Rotation scope: normal / banger / everything |
enrichment.js · beatgrid.js · loops.js · automix.js |
Track analysis and the transition engine (see docs/automix-design.md) |
artLabel.js |
Dithered album art for the player's label |
bundle.js · bundleImport.js · analysisExport.js |
Offline test bundles and the analysis sidecar |
views.js / adminViews.js |
Server-rendered htmx fragments (worker / admin) |
routes/ui.js · routes/admin.js · routes/stream.js |
htmx worker UI, admin UI, Liquidsoap /internal/* |
crypto.js · sessionStore.js · metrics.js · routes/ws.js |
AES/scrypt, SQLite sessions, Prometheus, WebSocket |
Modes (all env-switchable)
Each has a dev default that runs with no external services, and a production value:
| Env | Dev | Prod | Meaning |
|---|---|---|---|
CATALOG_MODE |
mock |
live |
Search source: built-in fixture, or Tidal (needs client ID + secret) |
DOWNLOADER_MODE |
stub |
tidekeeper |
Placeholder file vs real FLAC download |
PLAYER_MODE |
stub |
stream (or mpd) |
Simulated vs Liquidsoap→Icecast web radio (or a local MPD speaker) |
Configuration
Key env vars (defaults live in server/src/config.js):
| Var | Purpose |
|---|---|
DB_PATH, LIBRARY_DIR |
SQLite file, downloaded-audio directory |
TIDAL_CLIENT_ID / _SECRET |
Tidal app for live search (overrides the admin panel) |
COUNTRY_CODE |
Tidal catalogue market (e.g. AU) |
DOWNLOAD_CMD_TEMPLATE |
tidekeeper invocation ({url} / {outdir} placeholders) |
ROTATION_NO_REPEAT, ROTATION_FAIRNESS_WINDOW |
Rotation tuning |
STREAM_INTERNAL_TOKEN |
Shared secret for Liquidsoap → /internal/* |
STREAM_KEY |
Shareable listen link; grants /listen without an account |
WEBAUTHN_RP_ID, WEBAUTHN_ORIGIN |
Passkey scope; unset means passkeys are off |
TRUST_PROXY, SESSION_COOKIE_SECURE |
Behind Caddy in production |
ENCRYPTION_KEY, SESSION_SECRET |
Set in production (else generated + stored in the DB) |
Running
Local (no external services). Requires Node ≥ 22 (better-sqlite3 13):
cd server && npm install
DB_PATH=./data/db.sqlite CATALOG_MODE=mock DOWNLOADER_MODE=stub PLAYER_MODE=stub node src/index.js
# → http://localhost:3000 (register the first account = admin)
Full stack locally (Docker). The whole web-radio pipeline in stub/mock mode:
cd deploy
docker compose -f docker-compose.yml -f docker-compose.local.yml --env-file <your.env> up -d --build
Deploy (VPS or CasaOS, images built by Forgejo Actions): see
deploy/README.md.
Note on licensing
Streaming copyrighted music to a workplace, and over a public or keyed URL, is webcasting. That normally requires licensing (in AU: APRA AMCOS / PPCA), and it is against Tidal's consumer ToS. That's an operator responsibility, not a code concern.