denpa-radio/CLAUDE.md

12 KiB

CLAUDE.md

This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.

What this is

Self-hosted multi-station internet radio at https://denpa.femboy.page. Three docker services (Icecast, Liquidsoap, Astro frontend) plus a Caddy host service handle streaming, now-playing metadata, history, and the SPA. Each station is a folder on a Hetzner Storage Box with a _meta.yml and audio tracks; adding a station does not require a frontend rebuild.

Architecture

                     Caddy (host service, /etc/caddy/Caddyfile)
                                     │ denpa.femboy.page
       ┌──────────────────┬──────────┴──────────┬─────────────────┐
       │                  │                     │                 │
   /<id>.{mp3,opus}    /api/*               /now-playing/*       /
       │                  │                     │                 │
   icecast:12000       frontend:12001        caddy file_server   frontend:12001
   (libretime/         (astro hybrid SSR     on /srv/denpa-      (astro static)
    icecast:2.4.4)      via node adapter)    radio/data
       ▲
       │ source protocol on the compose network
   liquidsoap (savonet/liquidsoap:v2.3.3)
       │ reads /library/<station>/tracks
       │ writes /now-playing/<station>.json + .history.json
       ▼
   /mnt/trashbox/denpa-radio/library/   ← Hetzner Storage Box (CIFS, RO bind)

Why /now-playing/*.json is served by Caddy directly (not the frontend): liquidsoap writes the files atomically, they are tiny static JSON, and serving them through Caddy means a frontend outage does not break now-playing readers (frontend itself fetches them client-side).

Library layout (the "by station" promise)

On the Storage Box, mounted at /mnt/trashbox/denpa-radio/library/ on summer:

<station-id>/                  # id must match [a-z0-9-]+
├── _meta.yml                  # name, description, color, tags
├── cover.{jpg,png,webp}       # optional; served by /api/stations/<id>/cover
├── tracks/                    # audio files; subdirs supported (e.g. tracks/Volume Alpha/, tracks/Singles/)
└── jingles/                   # optional, currently unused in v1

Liquidsoap's playlist() recurses into subdirectories of tracks/ by default — organize by album/source however you like. Any decoder-supported audio works (flac, mp3, opus, m4a, wav, ogg). Non-audio files in tracks/ will be picked up and fail to decode (FFmpeg returns no audio stream); keep cover art and other non-audio at the station root, NOT inside tracks/.

Adding a station: mkdir + _meta.yml + tracks → edit config/liquidsoap.liq to add a new station block following the # === station: <id> === pattern → tar-pipe the script and docker compose restart liquidsoap on summer. The frontend's /api/stations.json picks up the new entry within ~30s (Cache-Control max-age) without a rebuild. No Caddy reload, no Icecast restart.

Deployment target ("summer")

  • Host: summer.node.femboy.page (port 32020 for SSH; Forgejo git on 22).
  • Stack lives at /srv/denpa-radio/.
  • Caddy v2.10 is a system service, NOT containerized; config at /etc/caddy/Caddyfile.
  • Disk constraint: ~33 GB free root; the library never lives on summer (it is on the Storage Box).

SSH

The summer host alias is in ~/.ssh/config (port 32020, user root) but does NOT specify an IdentityFile. Always pass the key explicitly:

ssh -o IdentityAgent=none -o IdentitiesOnly=yes -i ~/.ssh/keys/devilreef summer '<cmd>'

For git pushes (Forgejo on summer, port 22, user git):

GIT_SSH_COMMAND="ssh -i ~/.ssh/keys/devilreef -o IdentityAgent=none -o IdentitiesOnly=yes" git push ...

Transfer (no rsync on Windows Git Bash)

Use a tar-pipe instead:

cd /c/Users/user/Documents/Projects/personal/denpa-radio
tar -cf - <files> | ssh -o IdentityAgent=none -o IdentitiesOnly=yes -i ~/.ssh/keys/devilreef summer 'cd /srv/denpa-radio && tar -xf -'

When piping large binaries through Git Bash, cat <file> | ssh ... may corrupt UTF-8-interpreted bytes; use dd if=<file> bs=1M | ssh ... if you see that.

Compose conventions (summer-server style guide)

  • All services have restart: always. (The original style guide says unless-stopped, but real crashes recover the same way and docker kill does NOT trigger a restart with either policy — only manual docker start or a real crash does.)
  • Public ports bind to 172.17.0.1:<PORT> (docker bridge IP). Caddy proxies. Never bind to 0.0.0.0.
  • Allocated ports: 12000 icecast, 12001 frontend.
  • Env files in config/*.env are gitignored; config/*.env.example is committed and copied + edited on summer at deploy time.
  • Image pinning: databases/streaming infra to patch + distro (e.g. libretime/icecast:2.4.4, savonet/liquidsoap:v2.3.3).
  • Healthchecks inside containers must use 127.0.0.1, not localhost (alpine wget/curl resolves localhost to ::1 while Node binds IPv4 only).

Frontend (frontend/)

Astro 5 with output: 'static' + @astrojs/node standalone adapter — Astro 5 merged "hybrid" into static, with per-route SSR enabled by export const prerender = false. Two React islands; everything else is .astro (zero JS).

Dependencies are NOT installed on the dev machine via Astro CLI — install with npm install in frontend/. Most CLI invocations in this shell go through the direct binary because npx <bin> may fail in the host's Bash:

cd frontend
./node_modules/.bin/astro check
./node_modules/.bin/astro build
./node_modules/.bin/astro dev --host 127.0.0.1 --port 4321
./node_modules/.bin/vitest run                    # all tests
./node_modules/.bin/vitest run src/tests/format.test.ts
./node_modules/.bin/eslint src

Path aliases @lib/*, @components/*, @styles/* are resolved by both tsconfig.json (for editor + Astro) and vite.config.ts (for Vitest).

Component layout

  • src/pages/index.astro is the only page. It calls listStations(LIBRARY_ROOT) server-side and passes the result to <HeroPlayer initialStations={...} client:load />. prerender = false so this happens on each request — adding a new station folder is reflected within the cache window.
  • src/components/HeroPlayer.tsx is the only large interactive island. Owns the <audio crossorigin="anonymous"> element, all player state, polling, keyboard shortcuts, and localStorage persistence.
  • src/components/Spectrum.tsx (client:visible) connects to the audio element via playerStore (in src/lib/store.ts) and runs a real WebAudio AnalyserNode with a sine-wave fallback when paused or when WebAudio is unavailable.
  • src/lib/stations.ts walks LIBRARY_ROOT, reads each _meta.yml, returns sorted Station[]. Server-only (uses node:fs/promises).
  • src/pages/api/stations.json.ts and src/pages/api/stations/[id]/cover.ts expose station list + cover bytes. Both have export const prerender = false.
  • Local fixture library at src/tests/fixtures/library/ lets the dev server run without the Storage Box: LIBRARY_ROOT=$(pwd)/src/tests/fixtures/library ./node_modules/.bin/astro dev.

SSR hydration discipline

Anything time-sensitive or persisted (current Date, localStorage reads) is initialized to a deterministic SSR-safe default and updated in useEffect. Initializing state with new Date() or localStorage.getItem(...) in useState(() => ...) produces hydration mismatches (#418/#423/#425).

Liquidsoap script (config/liquidsoap.liq)

One station block per station:

minecraft = playlist(reload_mode="watch", mode="randomize", "/library/minecraft/tracks")
minecraft.on_track(fun (m) -> begin
  emit_now_playing("minecraft", m)
  append_history("minecraft", m)
end)
minecraft = crossfade(minecraft)
minecraft = mksafe(minecraft)
output.icecast(%mp3(bitrate=192, stereo=true), host="icecast", port=8000, password=source_pw, mount="/minecraft.mp3", ..., minecraft)
output.icecast(%opus(bitrate=96, vbr="constrained", samplerate=48000, channels=2), ...)

Notes that have already burned us once:

  • request.duration(filename) returns float? (option) in Liquidsoap 2.3 — unwrap with null.get(default=-1., ...). The static liquidsoap --check does NOT catch this; it surfaces as a runtime crash and a restart loop.
  • Decoder-reported m["duration"] is empty for FLAC files with embedded cover art; we fall back to request.duration in emit_now_playing.
  • mksafe belongs AFTER crossfade, so silence-fallback survives transitions.
  • on_track is hooked BEFORE crossfade so it fires per source track, not per fade segment.
  • Validate after every change: tar -cf - config/liquidsoap.liq | ssh ... summer 'cd /srv/denpa-radio && tar -xf -' && ssh ... summer 'docker exec denpa-radio-liquidsoap-1 liquidsoap --check /script.liq'.

Caddy (host)

/etc/caddy/Caddyfile block for denpa.femboy.page does:

  • @stream regex (^/[a-z0-9-]+\.(mp3|opus)$) → icecast :12000. Forces station ids to lowercase [a-z0-9-]+ for routing purposes.
  • /status-json.xsl → icecast (CORS open).
  • @nowplaying regex (^/now-playing/[a-z0-9-]+(\.history)?\.json$) → file_server over /srv/denpa-radio/data with Cache-Control: no-store.
  • everything else → frontend :12001.

Always validate + reload after edits:

ssh ... summer 'caddy validate --config /etc/caddy/Caddyfile && systemctl reload caddy'

Common workflows

Bring up the stack on summer (first deploy or after a teardown):

ssh ... summer 'cd /srv/denpa-radio && docker compose up -d'

Rebuild + restart frontend after a code change:

cd /c/Users/user/Documents/Projects/personal/denpa-radio
tar -cf - --exclude='node_modules' --exclude='dist' --exclude='.astro' frontend | \
    ssh ... summer 'cd /srv/denpa-radio && tar -xf -'
ssh ... summer 'cd /srv/denpa-radio && docker compose build frontend && docker compose up -d frontend'

Tail logs:

ssh ... summer 'cd /srv/denpa-radio && docker compose logs -f --tail=100 <service>'

Force a track change (when waiting for one to verify changes):

ssh ... summer 'cd /srv/denpa-radio && docker compose restart liquidsoap'

Public smoke tests:

curl -s -o /dev/null -D - https://denpa.femboy.page/minecraft.mp3 | head -3   # use GET; HEAD returns 400 from icecast
curl -s https://denpa.femboy.page/api/stations.json | python -m json.tool
curl -s https://denpa.femboy.page/now-playing/minecraft.json

Things to avoid (already learned)

  • Do NOT use curl -sI (HEAD) on *.{mp3,opus} mounts. Icecast 2.4.4 returns 400 for HEAD on stream mounts. Use curl -s -o /dev/null -D -.
  • Do NOT bind container ports to 0.0.0.0; always 172.17.0.1:<PORT>:<PORT>.
  • Do NOT install pgrep expecting it in savonet/liquidsoap:v2.3.3; use pidof.
  • Do NOT mount config files at /etc/icecast2/icecast.xml for the libretime/icecast:2.4.4 image — it expects /etc/icecast.xml and /usr/share/icecast/ (no 2).
  • Do NOT commit anything under docs/superpowers/, .claude/, or .design-prototype/ — those are local-only working notes/handoff bundles (already gitignored).
  • Do NOT use Windows-style 2>nul, del, rmdir /s /q in Bash on this Windows host — see the user's global CLAUDE.md for shell-safety rules.

Tags

  • v0.1.0 — radio backend live (Minecraft station, MP3+Opus).
  • v0.2.0 — frontend live (cassette deck UI, real WebAudio spectrum, recently-played).

Specs and plans

docs/superpowers/specs/ and docs/superpowers/plans/ (gitignored) hold the design docs and implementation plans used to build this. They are not authoritative — the code is — but they are useful when investigating "why did we do it this way".