# 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 ┌──────────────────┬──────────┴──────────┬─────────────────┐ │ │ │ │ /.{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//tracks │ writes /now-playing/.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: ``` / # id must match [a-z0-9-]+ ├── _meta.yml # name, description, color, tags ├── cover.{jpg,png,webp} # optional; served by /api/stations//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: ===` 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 '' ``` 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 - | 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 | ssh ...` may corrupt UTF-8-interpreted bytes; use `dd if= 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:` (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 ` 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 ``. `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 `