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 saysunless-stopped, but real crashes recover the same way anddocker killdoes NOT trigger a restart with either policy — only manualdocker startor a real crash does.) - Public ports bind to
172.17.0.1:<PORT>(docker bridge IP). Caddy proxies. Never bind to0.0.0.0. - Allocated ports:
12000icecast,12001frontend. - Env files in
config/*.envare gitignored;config/*.env.exampleis 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, notlocalhost(alpine wget/curl resolveslocalhostto::1while 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.astrois the only page. It callslistStations(LIBRARY_ROOT)server-side and passes the result to<HeroPlayer initialStations={...} client:load />.prerender = falseso this happens on each request — adding a new station folder is reflected within the cache window.src/components/HeroPlayer.tsxis 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 viaplayerStore(insrc/lib/store.ts) and runs a real WebAudioAnalyserNodewith a sine-wave fallback when paused or when WebAudio is unavailable.src/lib/stations.tswalksLIBRARY_ROOT, reads each_meta.yml, returns sortedStation[]. Server-only (usesnode:fs/promises).src/pages/api/stations.json.tsandsrc/pages/api/stations/[id]/cover.tsexpose station list + cover bytes. Both haveexport 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)returnsfloat?(option) in Liquidsoap 2.3 — unwrap withnull.get(default=-1., ...). The staticliquidsoap --checkdoes 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 torequest.durationinemit_now_playing. mksafebelongs AFTERcrossfade, so silence-fallback survives transitions.on_trackis hooked BEFOREcrossfadeso 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:
@streamregex (^/[a-z0-9-]+\.(mp3|opus)$) → icecast:12000. Forces station ids to lowercase[a-z0-9-]+for routing purposes./status-json.xsl→ icecast (CORS open).@nowplayingregex (^/now-playing/[a-z0-9-]+(\.history)?\.json$) →file_serverover/srv/denpa-radio/datawithCache-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. Usecurl -s -o /dev/null -D -. - Do NOT bind container ports to
0.0.0.0; always172.17.0.1:<PORT>:<PORT>. - Do NOT install
pgrepexpecting it insavonet/liquidsoap:v2.3.3; usepidof. - Do NOT mount config files at
/etc/icecast2/icecast.xmlfor thelibretime/icecast:2.4.4image — it expects/etc/icecast.xmland/usr/share/icecast/(no2). - 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 /qin Bash on this Windows host — see the user's globalCLAUDE.mdfor 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".