diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..230d8b3 --- /dev/null +++ b/CLAUDE.md @@ -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 + ┌──────────────────┬──────────┴──────────┬─────────────────┐ + │ │ │ │ + /.{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 `