docs: add CLAUDE.md operator manual and README.md
This commit is contained in:
parent
9500a93fde
commit
21d214abe9
2 changed files with 283 additions and 0 deletions
204
CLAUDE.md
Normal file
204
CLAUDE.md
Normal 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
79
README.md
Normal 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.
|
||||
Loading…
Add table
Add a link
Reference in a new issue