No description
  • JavaScript 80.1%
  • Python 9.1%
  • CSS 6.3%
  • Liquidsoap 2.4%
  • Shell 0.9%
  • Other 1.2%
Find a file
Repository files (latest commit first)
Filename Latest commit message Latest commit date
Kyle Phillips eafaf0e9af
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
deploy: TIDAL_REDIRECT_URI in the CasaOS compose
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.
2026-08-16 23:30:52 +10:00
.forgejo/workflows ci: let DOCKER_HOST come from a Forgejo variable 2026-08-16 22:35:14 +10:00
deploy deploy: TIDAL_REDIRECT_URI in the CasaOS compose 2026-08-16 23:30:52 +10:00
docs ci: Forgejo Actions, and drop the GitLab config 2026-08-16 21:28:45 +10:00
server feat(admin): connect a Tidal account, and use its token for search 2026-08-16 23:30:30 +10:00
tools tools: put the Tidal probe where it can actually run 2026-08-16 22:52:11 +10:00
web-ui-radio fix(deploy): one stream key, and a copyable listen link in admin 2026-08-16 18:07:28 +10:00
.dockerignore chore: finalize Warehouse Radio — rewrite README, remove legacy Tidal-sync code 2026-07-22 22:03:46 +10:00
.gitignore feat(automix): persist the beat grid the sidecar was already discarding 2026-08-14 06:38:37 +10:00
RADIO_DESIGN.md feat: rebuild as Warehouse Radio — democratic web-radio 2026-07-22 21:56:05 +10:00
README.md ci: Forgejo Actions, and drop the GitLab config 2026-08-16 21:28:45 +10:00

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), or stream (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.