- JavaScript 80.1%
- Python 9.1%
- CSS 6.3%
- Liquidsoap 2.4%
- Shell 0.9%
- Other 1.2%
| Filename | Latest commit message | Latest commit date |
|---|---|---|
|
All checks were successful
CI / check-caddy (push) Successful in 8s
CI / check-liquidsoap (push) Successful in 50s
CI / test-node (push) Successful in 3m35s
CI / build (deploy/essentia, deploy/essentia/Dockerfile, essentia) (push) Successful in 29s
CI / build (deploy/caddy, deploy/caddy/Dockerfile, caddy) (push) Successful in 36s
CI / build (deploy/analysis, deploy/analysis/Dockerfile, analysis) (push) Successful in 38s
CI / build (., deploy/node.Dockerfile, node) (push) Successful in 41s
CI / build (deploy/icecast, deploy/icecast/Dockerfile, icecast) (push) Successful in 22s
CI / build (deploy/liquidsoap, deploy/liquidsoap/Dockerfile, liquidsoap) (push) Successful in 23s
Missed in the previous commit — the edit anchored on STREAM_KEY, which appears twice now that it is a YAML anchor shared with caddy, so the assertion refused rather than putting a node variable into the caddy service. Anchored on the TIDAL_* keys instead, which appear once and are where it belongs. |
||
| .forgejo/workflows | ||
| deploy | ||
| docs | ||
| 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 no one can skip, pause, or queue during the day.
Tidal is used only as the catalogue + 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) ──▶ 🔊
- 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 — no immediate repeats and fairness across submitters, so no one dominates.
- Playback. Pluggable output:
stub(dev),mpd(local speaker), orstream(Liquidsoap pulls the rotation and pushes to Icecast — the deploy target). - Auth. Workers sign in with a name + PIN; the first account is admin.
Full design rationale: RADIO_DESIGN.md.
Repo layout
| Path | What |
|---|---|
server/src/ |
Node app (Express + better-sqlite3 + ws) — see 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/ |
Validates, then builds + pushes the six images to the container registry |
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 — the now-playing barcode-waveform visualiser |
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 vs 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/* |
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 via GitLab CI): see deploy/README.md.
Note on licensing
Streaming copyrighted music to a workplace — and over a public/keyed URL — is webcasting, which normally requires licensing (in AU: APRA AMCOS / PPCA) and is against Tidal's consumer ToS. That's an operator responsibility, not a code concern.