docs: add CLAUDE.md operator manual and README.md

This commit is contained in:
devilreef 2026-04-30 11:01:01 +06:00
parent 9500a93fde
commit 21d214abe9
Signed by: devilreef
SSH key fingerprint: SHA256:UZisRr4iuXx+IhkbZnR655L2RWAT6o2rgbGv5F/6m3Y
2 changed files with 283 additions and 0 deletions

204
CLAUDE.md Normal file
View file

@ -0,0 +1,204 @@
# 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/ # any decoder-supported audio (flac, mp3, opus, m4a, wav, ogg)
└── jingles/ # optional, currently unused in v1
```
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:
```bash
ssh -o IdentityAgent=none -o IdentitiesOnly=yes -i ~/.ssh/keys/devilreef summer '<cmd>'
```
For git pushes (Forgejo on summer, port 22, user `git`):
```bash
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:
```bash
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:
```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:
```liq
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:
```bash
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):**
```bash
ssh ... summer 'cd /srv/denpa-radio && docker compose up -d'
```
**Rebuild + restart frontend after a code change:**
```bash
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:**
```bash
ssh ... summer 'cd /srv/denpa-radio && docker compose logs -f --tail=100 <service>'
```
**Force a track change (when waiting for one to verify changes):**
```bash
ssh ... summer 'cd /srv/denpa-radio && docker compose restart liquidsoap'
```
**Public smoke tests:**
```bash
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".

79
README.md Normal file
View file

@ -0,0 +1,79 @@
# denpa-radio
a tiny independent multi-station internet radio. cassette-deck aesthetic, real audio, no algorithms.
live at **<https://denpa.femboy.page>**.
## what's playing
| station | description | mp3 | opus |
| --------- | ---------------------------------------- | --------------------------------------------------- | ----------------------------------------------------- |
| minecraft | C418, Lena Raine, Aaron Cherof — vol α+β | <https://denpa.femboy.page/minecraft.mp3> (192k) | <https://denpa.femboy.page/minecraft.opus> (96k) |
drop a stream URL into VLC, mpv, foobar, or any internet-radio app.
## endpoints
- `/<station>.mp3` — MP3 192 kbps stream
- `/<station>.opus` — Opus 96 kbps stream
- `/now-playing/<station>.json` — currently playing (artist, title, album, duration, started_at)
- `/now-playing/<station>.history.json` — last 50 tracks for the station
- `/api/stations.json` — list of all stations with metadata
- `/api/stations/<station>/cover` — cover art (when present)
- `/status-json.xsl` — raw Icecast status (listener counts, mount info)
CORS is open on all of the above; build your own scrobbler / dashboard / overlay.
## architecture
```
Caddy (host) ─┬─ /<station>.{mp3,opus}, /status-json.xsl → icecast (libretime/icecast)
├─ /now-playing/*.json → caddy file_server
├─ /api/* → frontend (astro 5 + react)
└─ / → frontend
└─ liquidsoap (savonet/liquidsoap)
│ reads station folders
│ writes now-playing JSON
Hetzner Storage Box (CIFS, mounted RO)
```
three docker containers (icecast, liquidsoap, frontend) and a Caddy host service. each station is a folder on the storage box with a `_meta.yml`, a `tracks/` subdir, and an optional `cover.{jpg,png,webp}`. the frontend reads `_meta.yml` files at request time, so adding a station does not require a rebuild.
## adding a station
on the storage box (mounted at `/mnt/trashbox/denpa-radio/library/` on summer):
```
mkdir <station-id>/{tracks,jingles}
cat > <station-id>/_meta.yml <<EOF
name: My Station
description: short description
color: '#5cae34'
tags: [tag1, tag2]
EOF
# drop audio files into <station-id>/tracks/
# (optional) drop cover.jpg into <station-id>/
```
then on summer:
```
# duplicate the existing minecraft station block in /srv/denpa-radio/config/liquidsoap.liq
# rename references to your new id, then:
docker compose -f /srv/denpa-radio/docker-compose.yml restart liquidsoap
```
the frontend picks up the new station within ~30s. no Caddy reload, no Icecast restart.
station ids must match `[a-z0-9-]+`.
## tags
- **v0.1.0** — radio backend live (Minecraft station, MP3 + Opus).
- **v0.2.0** — frontend live (cassette deck UI, real WebAudio spectrum, recently-played).
## working on this repo
see [`CLAUDE.md`](./CLAUDE.md) for the full operator's manual: SSH conventions, deploy quirks, frontend stack notes, Liquidsoap gotchas, and the things we've already learned the hard way.