From 8d00c10b9f11294785a09317b11c35aa87065318 Mon Sep 17 00:00:00 2001 From: devilreef Date: Thu, 30 Apr 2026 10:08:15 +0600 Subject: [PATCH 01/35] fix(frontend): defer date and localStorage reads to post-mount to fix hydration mismatches --- frontend/src/components/HeroPlayer.tsx | 41 ++++++++++++++++---------- 1 file changed, 26 insertions(+), 15 deletions(-) diff --git a/frontend/src/components/HeroPlayer.tsx b/frontend/src/components/HeroPlayer.tsx index bfc6fe2..3fc68cb 100644 --- a/frontend/src/components/HeroPlayer.tsx +++ b/frontend/src/components/HeroPlayer.tsx @@ -23,24 +23,30 @@ const lsSet = (k: string, v: string) => { }; export function HeroPlayer({ initialStations }: Props) { + // ssr-safe defaults; localStorage + Date reads happen post-mount to avoid + // hydration mismatches (#418, #423, #425). const [stations, setStations] = useState(initialStations); - const [selectedId, setSelectedId] = useState(() => { - const saved = lsGet('denpa:station'); - if (saved && initialStations.some((s) => s.id === saved)) return saved; - return initialStations[0]?.id ?? ''; - }); - const [format, setFormat] = useState(() => (lsGet('denpa:format') === 'opus' ? 'opus' : 'mp3')); - const [vol, setVol] = useState(() => { - const v = parseFloat(lsGet('denpa:vol') ?? '0.72'); - return isNaN(v) ? 0.72 : Math.max(0, Math.min(1, v)); - }); + const [selectedId, setSelectedId] = useState(initialStations[0]?.id ?? ''); + const [format, setFormat] = useState('mp3'); + const [vol, setVol] = useState(0.72); const [muted, setMuted] = useState(false); const [playing, setPlaying] = useState(false); const [now, setNow] = useState(null); const [listeners, setListeners] = useState(null); const [elapsed, setElapsed] = useState(0); const [copyState, setCopyState] = useState<'idle' | 'ok'>('idle'); - const [clockNow, setClockNow] = useState(() => new Date()); + const [clockText, setClockText] = useState<{ date: string; time: string }>({ date: '', time: '' }); + + // restore persisted UI state once on the client + useEffect(() => { + const savedStation = lsGet('denpa:station'); + if (savedStation && initialStations.some((s) => s.id === savedStation)) { + setSelectedId(savedStation); + } + if (lsGet('denpa:format') === 'opus') setFormat('opus'); + const savedVol = parseFloat(lsGet('denpa:vol') ?? ''); + if (!isNaN(savedVol)) setVol(Math.max(0, Math.min(1, savedVol))); + }, [initialStations]); const audioRef = useRef(null); const station = stations.find((s) => s.id === selectedId); @@ -155,9 +161,14 @@ export function HeroPlayer({ initialStations }: Props) { return () => clearInterval(id); }, [now?.started_at]); - // wall clock for header + // wall clock for header — only ticks after mount so ssr/hydrate match (empty strings) useEffect(() => { - const id = setInterval(() => setClockNow(new Date()), 1000); + const tick = () => { + const d = new Date(); + setClockText({ date: fmtJpDate(d), time: fmtJpTime(d) }); + }; + tick(); + const id = setInterval(tick, 1000); return () => clearInterval(id); }, []); @@ -238,8 +249,8 @@ export function HeroPlayer({ initialStations }: Props) { tune in.
tune out.
melt yr brain {'<3'}
-
{fmtJpDate(clockNow)}
-
{fmtJpTime(clockNow)}
+
{clockText.date || ' '}
+
{clockText.time || ' '}
[{playing ? 'REC' : 'OFF'}] {playing ? 'LIVE' : 'PAUSED'} · {listeners ?? '—'} listening
From 38394f40a6787c7dc2fd0140ba665554daee0e33 Mon Sep 17 00:00:00 2001 From: devilreef Date: Thu, 30 Apr 2026 10:55:24 +0600 Subject: [PATCH 02/35] fix: compute duration via request.duration when decoder metadata is empty --- config/liquidsoap.liq | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/config/liquidsoap.liq b/config/liquidsoap.liq index fab96fa..52e7d88 100644 --- a/config/liquidsoap.liq +++ b/config/liquidsoap.liq @@ -9,13 +9,24 @@ source_pw = environment.get(default="", "SOURCE_PW") def emit_now_playing(station, m) = filename = m["filename"] if filename != "" then + # decoder-reported metadata may be empty; fall back to request.duration which + # reads it directly from the file. -1.0 means unknown. + meta_dur = m["duration"] + dur_str = + if meta_dur != "" then + meta_dur + else + # request.duration returns float? in liquidsoap 2.3 — unwrap with default -1. + d = null.get(default=-1., request.duration(filename)) + if d > 0. then string(int_of_float(d)) else "" end + end payload = json() payload.add("station", station) payload.add("artist", m["artist"]) payload.add("title", m["title"]) payload.add("album", m["album"]) payload.add("filename", filename) - payload.add("duration", m["duration"]) + payload.add("duration", dur_str) payload.add("started_at", string(int_of_float(time()))) file.write(data=payload.stringify(), atomic=true, temp_dir="/now-playing", "/now-playing/#{station}.json") end From 9500a93fde877da3a8c89dc559d54b3e52c6a6e8 Mon Sep 17 00:00:00 2001 From: devilreef Date: Thu, 30 Apr 2026 10:57:12 +0600 Subject: [PATCH 03/35] chore: gitignore .design-prototype/ handoff bundle --- .gitignore | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.gitignore b/.gitignore index 8038817..629f70c 100644 --- a/.gitignore +++ b/.gitignore @@ -4,6 +4,9 @@ docs/superpowers/ # claude code per-project state .claude/ +# design handoff bundle from claude design — local reference only +.design-prototype/ + # generated configs and secrets config/*.env !config/*.env.example From 21d214abe9e81cbd971231e20ee51caf9da14797 Mon Sep 17 00:00:00 2001 From: devilreef Date: Thu, 30 Apr 2026 11:01:01 +0600 Subject: [PATCH 04/35] docs: add CLAUDE.md operator manual and README.md --- CLAUDE.md | 204 ++++++++++++++++++++++++++++++++++++++++++++++++++++++ README.md | 79 +++++++++++++++++++++ 2 files changed, 283 insertions(+) create mode 100644 CLAUDE.md create mode 100644 README.md 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 `