Compare commits

...

48 commits
v0.1.0 ... main

Author SHA1 Message Date
67beae5f6f
chore(streamer): drop cosmetic swscaler deprecated-format spam from logs 2026-04-30 19:31:29 +06:00
bdecc8c819
fix(streamer): explicit scale filter to fully silence swscaler 2026-04-30 19:30:20 +06:00
a544649a22
fix(streamer): tag mjpeg input as full-range to silence swscaler 2026-04-30 19:29:27 +06:00
2cd7cd3eab
fix(streamer): handle stdin EPIPE on ffmpeg exit, raise queue size, color_range tv 2026-04-30 19:28:10 +06:00
a83d063814
feat(liquidsoap): filename-based fallback when title/album tags are empty 2026-04-30 19:24:46 +06:00
1c4c875c10
fix(streamer): db-scaled spectrum so bars match frontend visibility 2026-04-30 19:18:03 +06:00
9eaa16d818
feat(streamer): BRANDING env to hide denpa.fm refs, drop station pill 2026-04-30 19:12:50 +06:00
3b4ce1ce39
fix(streamer): drop fonts-vt323 (not in bookworm) and use node fetch for healthcheck
- fonts-vt323 doesn't exist in debian bookworm; vt323 is loaded via google fonts cdn anyway.
- node:22-bookworm-slim has no wget; switch healthcheck to node -e fetch.
2026-04-30 15:41:17 +06:00
ed76fbd379
feat(compose): add streamer-minecraft-tg service 2026-04-30 15:32:11 +06:00
0ff5ecd9d0
feat(streamer): supervisor wiring all subprocesses 2026-04-30 15:30:45 +06:00
7cdc8f0d44
feat(streamer): ffmpeg rtmp encoder spawn 2026-04-30 15:28:35 +06:00
baef915561
feat(streamer): chromium cdp screencast driver 2026-04-30 15:28:31 +06:00
e0d93b03ae
feat(streamer): react views ported from prototype with sse hooks 2026-04-30 15:26:57 +06:00
870a41a2f7
feat(streamer): icecast listener-count poller 2026-04-30 15:21:49 +06:00
6bcf18c1bf
feat(streamer): page http server with sse + cover proxy 2026-04-30 15:21:42 +06:00
40ac86717a
feat(streamer): pcm tcp tap with reconnect backoff 2026-04-30 15:21:32 +06:00
8bca1767bd
feat(streamer): fft + log-bin spectrum analyzer 2026-04-30 15:19:41 +06:00
48955634af
feat(streamer): now-playing watcher with cover resolver 2026-04-30 15:17:37 +06:00
23d98320df
feat(streamer): env config parsing + validation 2026-04-30 15:14:48 +06:00
cc1f278fb8
feat(streamer): scaffold package, tsconfig, dockerfile 2026-04-30 15:12:42 +06:00
08f3f6e7da
feat(liquidsoap): raw-pcm tcp tap on :9100 for streamer 2026-04-30 15:09:25 +06:00
83df3b4602
feat(liquidsoap): emit cover_path + up_next in now-playing json
also fix path.extension -> file.extension (path module has no .extension in 2.3.3)
2026-04-30 15:04:25 +06:00
68a02ff250
fix(liquidsoap): deck filters audio, guards empty, avoids cross-deck repeat 2026-04-30 14:59:17 +06:00
fcb783ffca
feat(liquidsoap): deck-shuffle playlist with peek-ahead 2026-04-30 14:56:38 +06:00
f6ce00a6b1
fix(scripts): quote remote glob, drop UUOC in cover upload 2026-04-30 14:55:37 +06:00
703d7532f7
chore: mark restore-album-covers.sh executable 2026-04-30 14:54:28 +06:00
824192b856
chore: script to restore per-album covers from archives 2026-04-30 14:53:26 +06:00
d3e919d4c8
chore: gitignore .design-prototype-stream/ 2026-04-30 14:51:06 +06:00
52c6750a51
fix(liquidsoap): switch append_history to let json.parse with typed record
old json.parse(default=...) silently failed on every track change so
recent-played always showed only one entry. modern let-form parses
the existing history file correctly.
2026-04-30 11:22:50 +06:00
abca97b4ac
chore(frontend): trim ticker copy, set default volume to 67% 2026-04-30 11:17:55 +06:00
922f3d9e10
docs: note that tracks/ supports nested album subdirectories 2026-04-30 11:15:28 +06:00
21d214abe9
docs: add CLAUDE.md operator manual and README.md 2026-04-30 11:01:01 +06:00
9500a93fde
chore: gitignore .design-prototype/ handoff bundle 2026-04-30 10:57:12 +06:00
38394f40a6
fix: compute duration via request.duration when decoder metadata is empty 2026-04-30 10:55:24 +06:00
8d00c10b9f
fix(frontend): defer date and localStorage reads to post-mount to fix hydration mismatches 2026-04-30 10:08:15 +06:00
9f9c2148b4
chore: deploy frontend to summer; fix healthcheck localhost→127.0.0.1
alpine wget resolves localhost to ::1 (IPv6) but node only binds IPv4;
switched healthcheck URL to 127.0.0.1 to fix unhealthy container.
2026-04-30 09:52:03 +06:00
eb701bbb8a
feat: add frontend service + dockerfile + env example 2026-04-30 09:45:18 +06:00
04835f31b6
feat(frontend): add static shell + index page 2026-04-30 09:44:14 +06:00
01c281ca80
feat(frontend): add HistoryCard + HistoryList 2026-04-30 09:42:16 +06:00
65d88be032
feat(frontend): add HeroPlayer interactive island 2026-04-30 09:41:07 +06:00
7ab3c850bf
feat(frontend): add player store + WebAudio Spectrum 2026-04-30 09:36:35 +06:00
f069eef8a8
feat(frontend): add Tape presentational component 2026-04-30 09:34:49 +06:00
d16bc80ac8
feat(frontend): add /api/stations.json and /api/stations/:id/cover 2026-04-30 09:33:37 +06:00
a7256bc13c
feat(frontend): add stations library reader with tdd 2026-04-30 09:31:59 +06:00
a1e24c6a81
feat(frontend): add types + format helpers with tdd 2026-04-30 09:29:52 +06:00
3b8400b2ed
feat(frontend): add tokens, global css, self-hosted fonts 2026-04-30 09:28:05 +06:00
f1a0d1ddef
chore: scaffold astro frontend with static output 2026-04-30 09:24:40 +06:00
6bec95a563
feat: emit duration + per-station history in liquidsoap 2026-04-30 09:18:18 +06:00
83 changed files with 18362 additions and 10 deletions

8
.gitignore vendored
View file

@ -4,6 +4,10 @@ docs/superpowers/
# claude code per-project state
.claude/
# design handoff bundle from claude design — local reference only
.design-prototype/
.design-prototype-stream/
# generated configs and secrets
config/*.env
!config/*.env.example
@ -12,6 +16,10 @@ config/icecast.xml
# runtime data
data/
# streamer build artifacts
streamer/node_modules/
streamer/dist/
# OS junk
.DS_Store
Thumbs.db

206
CLAUDE.md Normal file
View file

@ -0,0 +1,206 @@
# 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/ # audio files; subdirs supported (e.g. tracks/Volume Alpha/, tracks/Singles/)
└── jingles/ # optional, currently unused in v1
```
Liquidsoap's `playlist()` recurses into subdirectories of `tracks/` by default — organize by album/source however you like. Any decoder-supported audio works (flac, mp3, opus, m4a, wav, ogg). Non-audio files in `tracks/` will be picked up and fail to decode (FFmpeg returns no audio stream); keep cover art and other non-audio at the station root, NOT inside `tracks/`.
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".

80
README.md Normal file
View file

@ -0,0 +1,80 @@
# 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 -p <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/
# subdirs supported: tracks/Volume Alpha/, tracks/Singles/, etc — Liquidsoap recurses
# (optional) drop cover.jpg into <station-id>/ (NOT inside tracks/ — would fail to decode)
```
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.

View file

@ -0,0 +1,5 @@
NODE_ENV=production
HOST=0.0.0.0
PORT=3000
LIBRARY_ROOT=/library
DATA_ROOT=/now-playing

View file

@ -6,27 +6,190 @@ settings.log.level.set(3)
source_pw = environment.get(default="", "SOURCE_PW")
def emit_now_playing(station, m) =
# deck-shuffle: full shuffle once, hand out in order, reshuffle on exhaustion.
# returns a record with .source (request.dynamic) and .peek (returns next N filenames).
def make_deck(dir) =
files = ref([])
cursor = ref(0)
last_played = ref("")
def reload_and_shuffle() =
all = file.ls(dir, recursive=true, absolute=true)
audio_exts = ["flac", "mp3", "opus", "m4a", "wav", "ogg"]
audio = list.filter(
fun(f) -> list.mem(string.case(lower=true, file.extension(leading_dot=false, f)), audio_exts),
all
)
files := list.shuffle(audio)
if !last_played != "" and list.length(!files) > 1 and list.nth(default="", !files, 0) == !last_played then
files := list.append(
[list.nth(default="", !files, 1), list.nth(default="", !files, 0)],
list.tl(list.tl(!files))
)
end
cursor := 0
end
def next_file() =
if !cursor >= list.length(!files) then reload_and_shuffle() end
if list.length(!files) == 0 then "" else
f = list.nth(default="", !files, !cursor)
cursor := !cursor + 1
last_played := f
f
end
end
def next_request() =
f = next_file()
if f == "" then null() else request.create(f) end
end
def peek(n) =
list.init(
min(n, list.length(!files) - !cursor),
fun(i) -> list.nth(default="", !files, !cursor + i)
)
end
reload_and_shuffle()
{ source = request.dynamic(next_request), peek = peek }
end
# filename-based fallbacks for tracks with empty embedded tags.
# strips leading "08. " / "12 - " etc. from the file stem.
def filename_title(filename) =
base = path.basename(filename)
ext = file.extension(leading_dot=true, base)
stem =
if ext != "" then
string.sub(base, start=0, length=string.length(base) - string.length(ext))
else base end
re = regexp("^[0-9]+[. \\-]+ *")
re.replace(fun(_) -> "", stem)
end
def filename_album(filename) =
path.basename(path.dirname(filename))
end
def or_else(s, fallback) =
if s == "" then fallback else s end
end
# walk up from the audio file to find cover.{jpg,png,webp}.
# falls back to <station>/cover.* (one level above tracks/).
def cover_for(filename) =
def find_in(dir) =
candidates = ["#{dir}/cover.jpg", "#{dir}/cover.png", "#{dir}/cover.webp"]
list.fold(
fun(found, c) -> if found == "" and file.exists(c) then c else found end,
"",
candidates
)
end
album_dir = path.dirname(filename)
c1 = find_in(album_dir)
if c1 != "" then c1 else
tracks_dir = path.dirname(album_dir)
station_root = path.dirname(tracks_dir)
find_in(station_root)
end
end
# build a json object for one upcoming track from its filename.
# reads tags via request.metadata; uses request.duration for length.
def track_meta_json(filename) =
r = request.create(filename)
ignore(request.resolve(r))
m = request.metadata(r)
request.destroy(r)
d = null.get(default=-1., request.duration(filename))
obj = json()
obj.add("title", or_else(m["title"], filename_title(filename)))
obj.add("artist", m["artist"])
obj.add("album", or_else(m["album"], filename_album(filename)))
obj.add("duration", if d > 0. then string(int_of_float(d)) else "" end)
obj.add("cover_path", cover_for(filename))
obj
end
def emit_now_playing(station, m, upcoming) =
filename = m["filename"]
if filename != "" then
meta_dur = m["duration"]
dur_str =
if meta_dur != "" then
meta_dur
else
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("title", or_else(m["title"], filename_title(filename)))
payload.add("album", or_else(m["album"], filename_album(filename)))
payload.add("filename", filename)
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")
payload.add("cover_path", cover_for(filename))
up_arr = list.map(track_meta_json, upcoming)
payload.add("up_next", up_arr)
file.write(
data=payload.stringify(),
atomic=true,
temp_dir="/now-playing",
"/now-playing/#{station}.json"
)
end
end
def append_history(station, m) =
filename = m["filename"]
if filename != "" then
history_path = "/now-playing/#{station}.history.json"
entry = {
title = m["title"],
artist = m["artist"],
album = m["album"],
filename = filename,
started_at = string(int_of_float(time()))
}
arr =
if file.exists(history_path) then
existing = file.contents(history_path)
try
let json.parse (parsed : [{
title: string,
artist: string,
album: string,
filename: string,
started_at: string
}]) = existing
parsed
catch _ do
[]
end
else
[]
end
new_arr = list.add(entry, list.prefix(49, arr))
out_str = json.stringify(new_arr)
file.write(data=out_str, atomic=true, temp_dir="/now-playing", history_path)
end
end
# === station: minecraft ===
minecraft = playlist(
reload_mode="watch",
mode="randomize",
"/library/minecraft/tracks"
)
minecraft.on_track(fun (m) -> emit_now_playing("minecraft", m))
minecraft_deck = make_deck("/library/minecraft/tracks")
minecraft = minecraft_deck.source
minecraft.on_track(fun (m) -> begin
emit_now_playing("minecraft", m, minecraft_deck.peek(4))
append_history("minecraft", m)
end)
minecraft = crossfade(minecraft)
minecraft = mksafe(minecraft)
@ -51,3 +214,9 @@ output.icecast(
url="https://denpa.femboy.page",
minecraft
)
output.url(
%ffmpeg(format="s16le", %audio(codec="pcm_s16le", ac=2, ar=48000)),
url="tcp://0.0.0.0:9100?listen=1",
minecraft
)

View file

@ -0,0 +1,25 @@
# streamer for the minecraft station, target = telegram rtmp ingest.
# copy to streamer-minecraft-tg.env on summer, fill in RTMP_URL.
STATION=minecraft
RTMP_URL=rtmps://dc4-1.rtmp.t.me/s/REPLACE_WITH_KEY
LIQUIDSOAP_PCM=tcp://liquidsoap:9100
NOW_PLAYING_DIR=/now-playing
LIBRARY_DIR=/library
STYLE=denpa
TZ=Asia/Tokyo
VIDEO_BITRATE=4500k
AUDIO_BITRATE=160k
FRAMERATE=30
RESOLUTION=1920x1080
STATION_TUNE_IN_URL=denpa.femboy.page
# set to false to hide all "denpa.fm" branding + links in the overlay
BRANDING=true
ICECAST_STATUS_URL=http://icecast:8000/status-json.xsl
LOG_LEVEL=info
HEALTH_PORT=12010

View file

@ -45,3 +45,53 @@ services:
timeout: 5s
retries: 3
start_period: 30s
frontend:
build: ./frontend
image: denpa-radio-frontend:latest
restart: always
env_file: config/frontend.env
ports:
- '172.17.0.1:12001:3000'
volumes:
- type: bind
source: /mnt/trashbox/denpa-radio/library
target: /library
read_only: true
- type: bind
source: ./data/now-playing
target: /now-playing
read_only: true
healthcheck:
test: ['CMD', 'wget', '-q', '-O', '-', 'http://127.0.0.1:3000/api/stations.json']
interval: 30s
timeout: 5s
retries: 3
start_period: 15s
streamer-minecraft-tg:
build: ./streamer
image: denpa-radio-streamer:latest
restart: always
env_file: config/streamer-minecraft-tg.env
depends_on:
liquidsoap:
condition: service_healthy
volumes:
- type: bind
source: /mnt/trashbox/denpa-radio/library
target: /library
read_only: true
- type: bind
source: ./data/now-playing
target: /now-playing
read_only: true
shm_size: '1gb'
ports:
- '172.17.0.1:12010:12010'
healthcheck:
test: ['CMD', 'node', '-e', "fetch('http://127.0.0.1:12010/health').then(r=>r.json()).then(j=>{if(!j.ok)process.exit(1)}).catch(()=>process.exit(1))"]
interval: 30s
timeout: 5s
retries: 3
start_period: 60s

6
frontend/.gitignore vendored Normal file
View file

@ -0,0 +1,6 @@
node_modules/
dist/
.astro/
.env
.env.local
*.log

20
frontend/Dockerfile Normal file
View file

@ -0,0 +1,20 @@
FROM node:22-alpine AS deps
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci
FROM node:22-alpine AS build
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY . .
RUN npm run build
FROM node:22-alpine AS runtime
WORKDIR /app
ENV NODE_ENV=production HOST=0.0.0.0 PORT=3000
COPY --from=build /app/dist ./dist
COPY --from=build /app/node_modules ./node_modules
COPY --from=build /app/package.json ./
USER node
EXPOSE 3000
CMD ["node", "./dist/server/entry.mjs"]

19
frontend/astro.config.mjs Normal file
View file

@ -0,0 +1,19 @@
// @ts-check
import { defineConfig } from 'astro/config';
import node from '@astrojs/node';
import react from '@astrojs/react';
// astro 5 merged 'hybrid' into 'static' + per-route `export const prerender = false`.
// the node adapter is required so api endpoints can actually run on demand.
export default defineConfig({
output: 'static',
adapter: node({ mode: 'standalone' }),
integrations: [react()],
server: {
host: process.env.HOST || '0.0.0.0',
port: Number(process.env.PORT) || 3000,
},
vite: {
server: { fs: { strict: true } },
},
});

13
frontend/eslint.config.js Normal file
View file

@ -0,0 +1,13 @@
import tseslint from 'typescript-eslint';
export default tseslint.config(
{ ignores: ['dist', 'node_modules', '.astro'] },
...tseslint.configs.recommended,
{
rules: {
'@typescript-eslint/no-unused-vars': ['warn', { argsIgnorePattern: '^_' }],
'@typescript-eslint/no-explicit-any': 'error',
'no-console': ['warn', { allow: ['warn', 'error'] }],
},
},
);

10169
frontend/package-lock.json generated Normal file

File diff suppressed because it is too large Load diff

37
frontend/package.json Normal file
View file

@ -0,0 +1,37 @@
{
"name": "denpa-frontend",
"version": "0.1.0",
"private": true,
"type": "module",
"scripts": {
"dev": "astro dev",
"build": "astro build",
"start": "node ./dist/server/entry.mjs",
"preview": "astro preview",
"check": "astro check",
"lint": "eslint src",
"test": "vitest run",
"test:watch": "vitest"
},
"dependencies": {
"@astrojs/node": "^9.0.0",
"@astrojs/react": "^4.0.0",
"astro": "^5.0.0",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"yaml": "^2.5.0"
},
"devDependencies": {
"@astrojs/check": "^0.9.9",
"@testing-library/react": "^16.0.0",
"@types/node": "^25.6.0",
"@types/react": "^18.3.0",
"@types/react-dom": "^18.3.0",
"@vitest/ui": "^2.0.0",
"eslint": "^9.0.0",
"happy-dom": "^15.0.0",
"typescript": "^5.9.3",
"typescript-eslint": "^8.0.0",
"vitest": "^2.0.0"
}
}

Binary file not shown.

Binary file not shown.

Binary file not shown.

View file

@ -0,0 +1,8 @@
---
---
<footer class="footer">
<div class="footer-bot">
<span>denpa.fm — a tiny independent radio · est. 2026</span>
<span><a href="https://denpa.femboy.page/status-json.xsl">icecast status</a></span>
</div>
</footer>

View file

@ -0,0 +1,335 @@
import { useEffect, useRef, useState, useCallback } from 'react';
import type { Station, NowPlaying, IcecastStatusJson } from '@lib/types';
import { Tape } from './Tape';
import { Spectrum } from './Spectrum';
import { playerStore } from '@lib/store';
import { fmtMs, fmtJpDate, fmtJpTime } from '@lib/format';
const PUBLIC_ORIGIN = 'https://denpa.femboy.page';
const POLL_NOW_MS = 5_000;
const POLL_LISTENERS_MS = 30_000;
interface Props {
initialStations: Station[];
}
type Format = 'mp3' | 'opus';
const lsGet = (k: string): string | null => {
try { return localStorage.getItem(k); } catch { return null; }
};
const lsSet = (k: string, v: string) => {
try { localStorage.setItem(k, v); } catch { /* ignore */ }
};
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<Station[]>(initialStations);
const [selectedId, setSelectedId] = useState<string>(initialStations[0]?.id ?? '');
const [format, setFormat] = useState<Format>('mp3');
const [vol, setVol] = useState<number>(0.67);
const [muted, setMuted] = useState(false);
const [playing, setPlaying] = useState(false);
const [now, setNow] = useState<NowPlaying | null>(null);
const [listeners, setListeners] = useState<number | null>(null);
const [elapsed, setElapsed] = useState(0);
const [copyState, setCopyState] = useState<'idle' | 'ok'>('idle');
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<HTMLAudioElement | null>(null);
const station = stations.find((s) => s.id === selectedId);
const streamUrl = station ? `${PUBLIC_ORIGIN}${station.mounts[format]}` : '';
// refresh stations periodically (cheap; backend caches)
useEffect(() => {
let aborted = false;
const refresh = async () => {
try {
const r = await fetch('/api/stations.json');
if (!r.ok) return;
const ss = await r.json() as Station[];
if (!aborted && ss.length) {
setStations(ss);
if (!ss.some((s) => s.id === selectedId)) {
const first = ss[0];
if (first) setSelectedId(first.id);
}
}
} catch { /* ignore */ }
};
const id = setInterval(refresh, 60_000);
return () => { aborted = true; clearInterval(id); };
}, [selectedId]);
// share audio element with Spectrum
useEffect(() => {
playerStore.setAudio(audioRef.current);
return () => playerStore.setAudio(null);
}, []);
// wire <audio> events
useEffect(() => {
const a = audioRef.current;
if (!a) return;
const onPlay = () => setPlaying(true);
const onPause = () => setPlaying(false);
const onError = () => setPlaying(false);
a.addEventListener('play', onPlay);
a.addEventListener('pause', onPause);
a.addEventListener('error', onError);
return () => {
a.removeEventListener('play', onPlay);
a.removeEventListener('pause', onPause);
a.removeEventListener('error', onError);
};
}, []);
// volume sync
useEffect(() => {
const a = audioRef.current;
if (a) a.volume = muted ? 0 : vol;
lsSet('denpa:vol', String(vol));
}, [vol, muted]);
// persist selections
useEffect(() => { lsSet('denpa:station', selectedId); }, [selectedId]);
useEffect(() => { lsSet('denpa:format', format); }, [format]);
// poll now-playing
useEffect(() => {
if (!selectedId) return;
const ctrl = new AbortController();
let timer: ReturnType<typeof setInterval> | null = null;
const fetchNow = async () => {
try {
const r = await fetch(`/now-playing/${selectedId}.json`, { signal: ctrl.signal });
if (!r.ok) { setNow(null); return; }
const np = await r.json() as NowPlaying;
setNow(np);
} catch (err) {
if ((err as Error).name !== 'AbortError') setNow(null);
}
};
void fetchNow();
timer = setInterval(fetchNow, POLL_NOW_MS);
return () => { ctrl.abort(); if (timer) clearInterval(timer); };
}, [selectedId]);
// poll listeners
useEffect(() => {
if (!selectedId) return;
const ctrl = new AbortController();
let timer: ReturnType<typeof setInterval> | null = null;
const fetchListeners = async () => {
try {
const r = await fetch('/status-json.xsl', { signal: ctrl.signal });
if (!r.ok) return;
const j = await r.json() as IcecastStatusJson;
const sources = j?.icestats?.source;
const arr = Array.isArray(sources) ? sources : sources ? [sources] : [];
const want = `/${selectedId}.${format}`;
const m = arr.find((s) => s.listenurl?.endsWith(want));
if (m && typeof m.listeners === 'number') setListeners(m.listeners);
} catch (err) {
if ((err as Error).name !== 'AbortError') setListeners(null);
}
};
void fetchListeners();
timer = setInterval(fetchListeners, POLL_LISTENERS_MS);
return () => { ctrl.abort(); if (timer) clearInterval(timer); };
}, [selectedId, format]);
// elapsed ticker
useEffect(() => {
if (!now?.started_at) { setElapsed(0); return; }
const start = parseInt(now.started_at, 10) * 1000;
const update = () => setElapsed(Math.max(0, Math.floor((Date.now() - start) / 1000)));
update();
const id = setInterval(update, 1000);
return () => clearInterval(id);
}, [now?.started_at]);
// wall clock for header — only ticks after mount so ssr/hydrate match (empty strings)
useEffect(() => {
const tick = () => {
const d = new Date();
setClockText({ date: fmtJpDate(d), time: fmtJpTime(d) });
};
tick();
const id = setInterval(tick, 1000);
return () => clearInterval(id);
}, []);
// keyboard shortcuts
const togglePlay = useCallback(() => {
const a = audioRef.current;
if (!a) return;
if (a.paused) a.play().catch(() => { /* ignore — autoplay etc. */ });
else a.pause();
}, []);
const cycleStation = useCallback((dir: 1 | -1) => {
if (!stations.length) return;
const i = stations.findIndex((s) => s.id === selectedId);
const next = stations[(i + dir + stations.length) % stations.length];
if (next) setSelectedId(next.id);
}, [stations, selectedId]);
useEffect(() => {
const onKey = (e: KeyboardEvent) => {
const tgt = e.target as HTMLElement | null;
if (tgt && /^(INPUT|TEXTAREA|SELECT)$/.test(tgt.tagName)) return;
switch (e.key) {
case ' ': e.preventDefault(); togglePlay(); break;
case 'ArrowUp': e.preventDefault(); setVol((v) => Math.min(1, v + 0.05)); break;
case 'ArrowDown': e.preventDefault(); setVol((v) => Math.max(0, v - 0.05)); break;
case 'ArrowLeft': e.preventDefault(); cycleStation(-1); break;
case 'ArrowRight': e.preventDefault(); cycleStation(1); break;
case 'm': case 'M': e.preventDefault(); setMuted((m) => !m); break;
}
};
document.addEventListener('keydown', onKey);
return () => document.removeEventListener('keydown', onKey);
}, [togglePlay, cycleStation]);
// copy URL
const copyUrl = async () => {
try {
await navigator.clipboard.writeText(streamUrl);
setCopyState('ok');
} catch {
const ta = document.createElement('textarea');
ta.value = streamUrl;
document.body.appendChild(ta);
ta.select();
document.execCommand('copy');
document.body.removeChild(ta);
setCopyState('ok');
}
setTimeout(() => setCopyState('idle'), 1500);
};
// when station/format changes, reload + maybe play
useEffect(() => {
const a = audioRef.current;
if (!a || !station) return;
const wasPlaying = !a.paused;
a.src = `${station.mounts[format]}`;
a.load();
if (wasPlaying) a.play().catch(() => { /* ignore */ });
}, [selectedId, format]);
if (!station) {
return <section className="hero"><p>no stations available add a station folder under <code>library/</code> on the storage box.</p></section>;
}
const dur = parseInt(now?.duration ?? '', 10);
const hasDur = !isNaN(dur) && dur > 0;
const pct = hasDur ? Math.min(100, (elapsed / dur) * 100) : 0;
return (
<section className="hero">
<div className="hero-header">
<div className="hero-wordmark">
<div className="eyebrow"> TRANSMISSION</div>
<div className="big">denpa.fm</div>
</div>
<div className="hero-tagline">
tune in.<br/>tune out.<br/>melt yr brain {'<3'}
</div>
<div className="hero-status">
<div>{clockText.date || ' '}</div>
<div>{clockText.time || ' '}</div>
<div className="live">[<span className="blink">{playing ? 'REC' : 'OFF'}</span>] {playing ? 'LIVE' : 'PAUSED'} · {listeners ?? '—'} listening</div>
</div>
</div>
<div className="hero-body">
<aside className="hero-stations">
<div className="hero-stations-label">&gt;&gt; / STATIONS</div>
{stations.map((s, i) => (
<Tape
key={s.id}
station={s}
index={i}
active={s.id === selectedId}
onSelect={setSelectedId}
listeners={s.id === selectedId ? listeners : null}
/>
))}
</aside>
<div className="hero-deck-wrap">
<audio ref={audioRef} crossOrigin="anonymous" preload="none" />
<div className="hero-deck">
<div className="deck-screen">
<div className="deck-screen-row">
<span>&gt;&gt; NOW PLAYING</span>
<span>{format === 'mp3' ? '192k MP3' : '96k OPUS'}</span>
</div>
<div className="deck-now">{now?.title || '—'}</div>
<div className="deck-artist">{now?.artist || '—'}</div>
<div className="deck-screen-row">
<span>album · {now?.album || '—'}</span>
<span>{station.tags.join(' · ')}</span>
</div>
<div className="deck-progress-row">
<span className="deck-time">{fmtMs(elapsed)}</span>
<div className="deck-progress">
{hasDur ? <div className="deck-progress-fill" style={{ width: pct + '%' }} /> : null}
</div>
<span className="deck-time">{hasDur ? fmtMs(dur) : '—'}</span>
</div>
</div>
<div className="deck-reels">
<div className={`reel ${playing ? 'spinning' : ''}`} />
<div className="reel-strip" />
<div className={`reel ${playing ? 'spinning' : ''}`} />
</div>
<Spectrum />
<div className="deck-controls">
<button className="deck-btn play" onClick={togglePlay} type="button">
{playing ? '|| PAUSE' : '>> PLAY'}
</button>
<button className="deck-btn format" data-active={format === 'mp3'} onClick={() => setFormat('mp3')} type="button">MP3</button>
<button className="deck-btn format" data-active={format === 'opus'} onClick={() => setFormat('opus')} type="button">OPUS</button>
<div className="deck-vol">
<label>VOL</label>
<input
type="range" min={0} max={1} step={0.01} value={muted ? 0 : vol}
onChange={(e) => { setVol(parseFloat(e.target.value)); setMuted(false); }}
/>
<span className="deck-vol-pct">{Math.round((muted ? 0 : vol) * 100)}%</span>
</div>
</div>
</div>
<div className="hero-footer">
<button className="share-btn" onClick={copyUrl} type="button">
{copyState === 'ok' ? '[ok] copied!' : '[ ] copy stream URL'}
</button>
<div className="ticker">
<div className="ticker-inner">
no ads · no algorithms · just signal space=play =switch =vol m=mute
</div>
</div>
</div>
</div>
</div>
</section>
);
}

View file

@ -0,0 +1,12 @@
---
import { HistoryList } from './HistoryList';
interface Props { station: string; }
const { station } = Astro.props;
---
<div class="card history">
<div class="full-secthead">
<div class="full-secthead-jp">再生履歴</div>
<div class="full-secthead-en">// recently played</div>
</div>
<HistoryList station={station} client:visible />
</div>

View file

@ -0,0 +1,48 @@
import { useEffect, useState } from 'react';
import type { HistoryEntry } from '@lib/types';
import { fmtRelative } from '@lib/format';
interface Props {
station: string;
}
export function HistoryList({ station }: Props) {
const [entries, setEntries] = useState<HistoryEntry[] | null>(null);
const [now, setNow] = useState(() => new Date());
useEffect(() => {
const ctrl = new AbortController();
const load = async () => {
try {
const r = await fetch(`/now-playing/${station}.history.json`, { signal: ctrl.signal });
if (!r.ok) { setEntries([]); return; }
const arr = await r.json() as HistoryEntry[];
setEntries(Array.isArray(arr) ? arr : []);
} catch (err) {
if ((err as Error).name !== 'AbortError') setEntries([]);
}
};
void load();
const id = setInterval(load, 15_000);
const tick = setInterval(() => setNow(new Date()), 30_000);
return () => { ctrl.abort(); clearInterval(id); clearInterval(tick); };
}, [station]);
if (entries === null) return <div className="history-empty">loading</div>;
if (!entries.length) return <div className="history-empty">no recent tracks</div>;
return (
<div className="history-list">
{entries.slice(0, 10).map((e, i) => {
const start = new Date(parseInt(e.started_at, 10) * 1000);
return (
<div key={`${e.started_at}-${i}`} className="history-row">
<span className="t">{fmtRelative(start, now)}</span>
<span className="title">{e.title || '—'}</span>
<span className="artist">{e.artist || '—'}</span>
</div>
);
})}
</div>
);
}

View file

@ -0,0 +1,28 @@
---
const ORIGIN = 'https://denpa.femboy.page';
interface Props { stationId: string; }
const { stationId } = Astro.props;
---
<div class="card listen-ways">
<div class="full-secthead">
<div class="full-secthead-jp">他の聴き方</div>
<div class="full-secthead-en">// other ways to listen</div>
</div>
<div class="listen-ways-list">
<div class="listen-way">
<div class="lw-label">DIRECT STREAM (MP3)</div>
<code class="lw-url">{ORIGIN}/{stationId}.mp3</code>
<div class="lw-hint">drop into VLC, mpv, foobar, etc.</div>
</div>
<div class="listen-way">
<div class="lw-label">DIRECT STREAM (OPUS)</div>
<code class="lw-url">{ORIGIN}/{stationId}.opus</code>
<div class="lw-hint">smaller, better quality. modern players only.</div>
</div>
<div class="listen-way">
<div class="lw-label">METADATA JSON</div>
<code class="lw-url">{ORIGIN}/now-playing/{stationId}.json</code>
<div class="lw-hint">build your own scrobbler.</div>
</div>
</div>
</div>

View file

@ -0,0 +1,104 @@
import { useEffect, useRef } from 'react';
import { playerStore } from '@lib/store';
const BARS = 36;
export function Spectrum() {
const containerRef = useRef<HTMLDivElement | null>(null);
const barRefs = useRef<HTMLDivElement[]>([]);
useEffect(() => {
let raf = 0;
let ctx: AudioContext | null = null;
let analyser: AnalyserNode | null = null;
let buf: Uint8Array<ArrayBuffer> | null = null;
let connected = false;
const tryConnect = () => {
if (connected) return;
const audio = playerStore.getAudio();
if (!audio) return;
try {
if (!ctx) {
const Ctor = window.AudioContext
?? (window as unknown as { webkitAudioContext?: typeof AudioContext }).webkitAudioContext;
if (!Ctor) throw new Error('no AudioContext');
ctx = new Ctor();
}
const src = ctx.createMediaElementSource(audio);
analyser = ctx.createAnalyser();
analyser.fftSize = 128;
src.connect(analyser);
analyser.connect(ctx.destination);
buf = new Uint8Array(analyser.frequencyBinCount) as Uint8Array<ArrayBuffer>;
connected = true;
} catch (err) {
console.warn('[spectrum] webaudio unavailable, using fake', err);
}
};
const fakeFrame = (t: number) => {
for (let i = 0; i < BARS; i++) {
const a = Math.sin(t * 0.0021 + i * 0.7) * 0.5 + 0.5;
const f = i / BARS;
const env = 0.4 + 0.6 * Math.exp(-f * 2.2);
const v = a * env * 0.3;
const el = barRefs.current[i];
if (el) el.style.height = `${Math.max(4, v * 100)}%`;
}
};
const realFrame = () => {
if (!analyser || !buf) return;
analyser.getByteFrequencyData(buf);
for (let i = 0; i < BARS; i++) {
const v = (buf[i] ?? 0) / 255;
const el = barRefs.current[i];
if (el) el.style.height = `${Math.max(4, v * 100)}%`;
}
};
const tick = (t: number) => {
tryConnect();
const audio = playerStore.getAudio();
if (connected && audio && !audio.paused) {
if (ctx?.state === 'suspended') {
void ctx.resume();
}
realFrame();
} else {
fakeFrame(t);
}
raf = requestAnimationFrame(tick);
};
raf = requestAnimationFrame(tick);
const unsub = playerStore.subscribe(() => {
tryConnect();
});
return () => {
cancelAnimationFrame(raf);
unsub();
try {
void ctx?.close();
} catch {
// ignore
}
};
}, []);
return (
<div className="deck-spectrum" ref={containerRef}>
{Array.from({ length: BARS }).map((_, i) => (
<div
key={i}
className="spec-bar"
ref={(el) => {
if (el) barRefs.current[i] = el;
}}
/>
))}
</div>
);
}

View file

@ -0,0 +1,30 @@
import type { Station } from '@lib/types';
interface Props {
station: Station;
index: number;
active: boolean;
onSelect: (id: string) => void;
listeners?: number | null;
}
export function Tape({ station, index, active, onSelect, listeners }: Props) {
return (
<button
className={`tape ${active ? 'active' : ''}`}
onClick={() => onSelect(station.id)}
type="button"
>
<div className="tape-num">CH.{String(index + 1).padStart(2, '0')}</div>
<div className="tape-name">{station.name}</div>
{station.tags.length > 0 ? (
<div className="tape-jp">{station.tags.slice(0, 2).join(' · ')}</div>
) : null}
<div className="tape-holes"><span /><span /></div>
<div className="tape-meta">
<span>{station.mounts.mp3.replace(/^\//, '')}</span>
<span>{listeners == null ? '— ppl' : `${listeners} ppl`}</span>
</div>
</button>
);
}

View file

@ -0,0 +1,9 @@
---
---
<nav class="topnav">
<span class="topnav-brand">電波 / denpa.fm</span>
<div class="topnav-links">
<a href="#listen">listen</a>
<a href="#about">about</a>
</div>
</nav>

9
frontend/src/env.d.ts vendored Normal file
View file

@ -0,0 +1,9 @@
/// <reference types="astro/client" />
declare namespace NodeJS {
interface ProcessEnv {
LIBRARY_ROOT?: string;
DATA_ROOT?: string;
PUBLIC_ORIGIN?: string;
}
}

View file

@ -0,0 +1,28 @@
export function fmtMs(seconds: number): string {
const s = Math.max(0, Math.floor(seconds));
const m = Math.floor(s / 60);
const r = s % 60;
return `${m}:${String(r).padStart(2, '0')}`;
}
export function fmtRelative(d: Date, now: Date = new Date()): string {
const ms = now.getTime() - d.getTime();
if (ms < 5_000) return 'now';
const s = Math.floor(ms / 1000);
if (s < 60) return `${s}s`;
const m = Math.floor(s / 60);
if (m < 60) return `${m}m`;
const h = Math.floor(m / 60);
if (h < 24) return `${h}h`;
const days = Math.floor(h / 24);
return `${days}d`;
}
export function fmtJpDate(d: Date): string {
return `${d.getFullYear()}${d.getMonth() + 1}${d.getDate()}`;
}
export function fmtJpTime(d: Date): string {
const p = (n: number) => String(n).padStart(2, '0');
return `${p(d.getHours())}:${p(d.getMinutes())}:${p(d.getSeconds())}`;
}

View file

@ -0,0 +1,86 @@
import { readdir, readFile, stat, access } from 'node:fs/promises';
import path from 'node:path';
import { parse as parseYaml } from 'yaml';
import type { Station } from './types.ts';
const ID_RE = /^[a-z0-9-]+$/;
const COVER_NAMES = ['cover.jpg', 'cover.png', 'cover.webp'];
async function fileExists(p: string): Promise<boolean> {
try {
await access(p);
return true;
} catch {
return false;
}
}
export async function readMeta(libraryRoot: string, id: string): Promise<Station | null> {
if (!ID_RE.test(id)) return null;
const dir = path.join(libraryRoot, id);
let raw: string;
try {
raw = await readFile(path.join(dir, '_meta.yml'), 'utf8');
} catch {
return null;
}
let parsed: unknown;
try {
parsed = parseYaml(raw);
} catch (err) {
console.warn(`[stations] malformed _meta.yml for ${id}:`, (err as Error).message);
return null;
}
if (!parsed || typeof parsed !== 'object') {
console.warn(`[stations] _meta.yml for ${id} is not an object`);
return null;
}
const m = parsed as Record<string, unknown>;
const name = typeof m.name === 'string' ? m.name : null;
const description = typeof m.description === 'string' ? m.description : '';
const color = typeof m.color === 'string' ? m.color : '#888';
const tags = Array.isArray(m.tags) ? m.tags.filter((t): t is string => typeof t === 'string') : [];
if (!name) {
console.warn(`[stations] _meta.yml for ${id} missing 'name'`);
return null;
}
let cover: string | null = null;
for (const c of COVER_NAMES) {
if (await fileExists(path.join(dir, c))) {
cover = `/api/stations/${id}/cover`;
break;
}
}
return {
id,
name,
description,
color,
tags,
mounts: { mp3: `/${id}.mp3`, opus: `/${id}.opus` },
cover,
};
}
export async function listStations(libraryRoot: string): Promise<Station[]> {
let entries: string[];
try {
entries = await readdir(libraryRoot);
} catch {
return [];
}
const out: Station[] = [];
for (const id of entries.sort()) {
const full = path.join(libraryRoot, id);
let s;
try {
s = await stat(full);
} catch {
continue;
}
if (!s.isDirectory()) continue;
const meta = await readMeta(libraryRoot, id);
if (meta) out.push(meta);
}
return out;
}

18
frontend/src/lib/store.ts Normal file
View file

@ -0,0 +1,18 @@
let audioRef: HTMLAudioElement | null = null;
const subs = new Set<() => void>();
export const playerStore = {
setAudio(el: HTMLAudioElement | null) {
audioRef = el;
subs.forEach((fn) => fn());
},
getAudio(): HTMLAudioElement | null {
return audioRef;
},
subscribe(fn: () => void): () => void {
subs.add(fn);
return () => {
subs.delete(fn);
};
},
};

41
frontend/src/lib/types.ts Normal file
View file

@ -0,0 +1,41 @@
export interface Station {
id: string;
name: string;
description: string;
color: string;
tags: string[];
mounts: { mp3: string; opus: string };
cover: string | null;
}
export interface NowPlaying {
station: string;
artist: string;
title: string;
album: string;
filename: string;
duration: string;
started_at: string;
}
export interface HistoryEntry {
title: string;
artist: string;
album: string;
filename: string;
started_at: string;
}
export interface IcecastSourceJson {
listenurl: string;
server_name?: string;
server_type?: string;
listeners?: number;
}
export interface IcecastStatusJson {
icestats: {
host?: string;
source?: IcecastSourceJson | IcecastSourceJson[];
};
}

View file

@ -0,0 +1,17 @@
import type { APIRoute } from 'astro';
import { listStations } from '@lib/stations';
export const prerender = false;
export const GET: APIRoute = async () => {
const root = process.env.LIBRARY_ROOT ?? '/library';
const stations = await listStations(root);
return new Response(JSON.stringify(stations), {
status: 200,
headers: {
'Content-Type': 'application/json; charset=utf-8',
'Cache-Control': 'max-age=30, public',
'Access-Control-Allow-Origin': '*',
},
});
};

View file

@ -0,0 +1,37 @@
import type { APIRoute } from 'astro';
import { access, readFile } from 'node:fs/promises';
import path from 'node:path';
export const prerender = false;
const ID_RE = /^[a-z0-9-]+$/;
const CANDIDATES: Array<[string, string]> = [
['cover.jpg', 'image/jpeg'],
['cover.png', 'image/png'],
['cover.webp', 'image/webp'],
];
export const GET: APIRoute = async ({ params }) => {
const id = params.id ?? '';
if (!ID_RE.test(id)) {
return new Response('not found', { status: 404 });
}
const root = process.env.LIBRARY_ROOT ?? '/library';
for (const [name, mime] of CANDIDATES) {
const p = path.join(root, id, name);
try {
await access(p);
const buf = await readFile(p);
return new Response(new Uint8Array(buf), {
status: 200,
headers: {
'Content-Type': mime,
'Cache-Control': 'max-age=300, public',
},
});
} catch {
// try next
}
}
return new Response('not found', { status: 404 });
};

View file

@ -0,0 +1,44 @@
---
import '@styles/tokens.css';
import '@styles/global.css';
import '@styles/components/hero.css';
import '@styles/components/deck.css';
import '@styles/components/spectrum.css';
import '@styles/components/tape.css';
import '@styles/components/history-card.css';
import '@styles/components/listen-ways-card.css';
import '@styles/components/footer.css';
import TopNav from '@components/TopNav.astro';
import HistoryCard from '@components/HistoryCard.astro';
import ListenWaysCard from '@components/ListenWaysCard.astro';
import FooterStrip from '@components/FooterStrip.astro';
import { HeroPlayer } from '@components/HeroPlayer';
import { listStations } from '@lib/stations';
export const prerender = false;
const root = process.env.LIBRARY_ROOT ?? '/library';
const initialStations = await listStations(root);
const firstStation = initialStations[0]?.id ?? '';
---
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width,initial-scale=1" />
<title>denpa.fm // 電波</title>
<meta name="description" content="denpa.fm — a tiny independent radio." />
</head>
<body>
<div class="page">
<TopNav />
<HeroPlayer initialStations={initialStations} client:load />
<div id="listen" class="row-grid two">
{firstStation ? <HistoryCard station={firstStation} /> : <div />}
{firstStation ? <ListenWaysCard stationId={firstStation} /> : <div />}
</div>
<FooterStrip />
</div>
</body>
</html>

View file

@ -0,0 +1,95 @@
.hero-deck {
background: linear-gradient(180deg, var(--cream) 0%, #f5e0c4 100%);
color: var(--ink);
padding: 24px;
border: 3px solid var(--ink);
box-shadow: 8px 8px 0 var(--pink), 16px 16px 0 var(--cyan);
position: relative;
}
.hero-deck::before {
content: ""; position: absolute; inset: 5px;
border: 1px dashed #0a041044; pointer-events: none;
}
.deck-screen {
background: var(--ink); color: var(--green);
font-family: var(--f-mono); font-size: 14px;
padding: 14px 18px;
border: 2px inset var(--ink);
position: relative; overflow: hidden;
}
.deck-screen::after {
content: ""; position: absolute; inset: 0; pointer-events: none;
background: repeating-linear-gradient(0deg, rgba(94,255,155,0.08) 0 1px, transparent 1px 3px);
}
.deck-screen-row { display: flex; justify-content: space-between; opacity: 0.75; font-size: 12px; }
.deck-now {
font-family: var(--f-pixel); font-size: 28px; color: var(--cream);
line-height: 1.1; margin: 6px 0 2px;
text-shadow: 0 0 10px #5eff9b80;
}
.deck-artist { font-size: 16px; color: var(--cyan); margin-bottom: 8px; }
.deck-progress-row {
display: flex; align-items: center; gap: 10px;
margin-top: 12px;
}
.deck-time { font-size: 12px; color: var(--cyan); min-width: 36px; }
.deck-progress { flex: 1; height: 4px; background: #5eff9b22; border: 1px solid #5eff9b55; }
.deck-progress-fill { height: 100%; background: var(--green); transition: width 200ms linear; }
.deck-reels {
display: flex; align-items: center; justify-content: space-around;
gap: 14px; margin-top: 18px;
padding: 14px; background: var(--ink); border: 2px solid var(--ink);
}
.reel {
width: 110px; height: 110px; border-radius: 50%;
background:
radial-gradient(circle, var(--cream) 0 16px, transparent 16px),
conic-gradient(from 0deg, var(--plum-2) 0 25%, var(--plum) 25% 50%, var(--plum-2) 50% 75%, var(--plum) 75%);
border: 2px solid var(--cream);
position: relative;
}
.reel::before {
content: ""; position: absolute; inset: 7px; border-radius: 50%;
border: 1px dashed #fff4e833;
}
.reel.spinning { animation: spin 1.6s linear infinite; }
@keyframes spin { to { transform: rotate(360deg); } }
.reel-strip {
flex: 1; height: 7px;
background: linear-gradient(90deg, var(--cream) 0%, #d4a578 50%, var(--cream) 100%);
border-top: 1px solid var(--ink); border-bottom: 1px solid var(--ink);
}
.deck-controls {
display: flex; align-items: center; gap: 10px; margin-top: 18px;
flex-wrap: wrap;
}
.deck-btn {
font-family: var(--f-pixel); font-size: 16px;
background: var(--pink); color: var(--cream);
border: 2px solid var(--ink); padding: 10px 14px;
cursor: pointer; box-shadow: 3px 3px 0 var(--ink);
letter-spacing: 1px;
transition: transform 100ms, box-shadow 100ms;
}
.deck-btn:hover { transform: translate(-1px,-1px); box-shadow: 4px 4px 0 var(--ink); }
.deck-btn:active { transform: translate(2px,2px); box-shadow: 1px 1px 0 var(--ink); }
.deck-btn.play { font-size: 20px; padding: 16px 24px; background: var(--cyan); color: var(--ink); }
.deck-btn.format { background: var(--cream); color: var(--ink); }
.deck-btn.format[data-active=true] { background: var(--lemon); }
.deck-vol {
flex: 1; min-width: 200px;
display: flex; align-items: center; gap: 10px;
background: var(--ink); padding: 10px 14px; border: 2px solid var(--ink);
}
.deck-vol label { font-family: var(--f-pixel); font-size: 13px; color: var(--lemon); letter-spacing: 1.5px; }
.deck-vol input { flex: 1; accent-color: var(--pink); }
.deck-vol-pct { font-family: var(--f-mono); font-size: 13px; color: var(--cyan); min-width: 38px; text-align: right; }
@media (max-width: 640px) {
.reel { width: 80px; height: 80px; }
.deck-vol { min-width: 100%; }
}

View file

@ -0,0 +1,9 @@
.footer {
margin-top: 12px; padding: 30px 0 24px;
border-top: 2px dashed #ff3ea566;
}
.footer-bot {
display: flex; justify-content: space-between; gap: 14px;
font-family: var(--f-mono); font-size: 12px; color: #fff4e8aa;
flex-wrap: wrap;
}

View file

@ -0,0 +1,106 @@
.topnav {
display: flex; justify-content: space-between; align-items: center;
font-family: var(--f-pixel); font-size: 13px; letter-spacing: 2px;
padding: 6px 0 18px; border-bottom: 1px dashed #ff3ea533;
margin-bottom: 24px;
flex-wrap: wrap; gap: 10px;
}
.topnav-brand { color: var(--pink); }
.topnav-links { display: flex; gap: 18px; }
.topnav-links a { color: var(--cream); }
.topnav-links a:hover { color: var(--lemon); }
.hero {
position: relative;
padding: 30px 32px 36px;
background:
radial-gradient(ellipse at 30% 0%, #ff3ea522 0%, transparent 55%),
radial-gradient(ellipse at 80% 100%, #5ef7ff1a 0%, transparent 55%),
var(--plum);
border: 2px solid var(--ink);
margin-bottom: 36px;
overflow: hidden;
}
.hero-header {
display: grid;
grid-template-columns: auto 1fr auto;
align-items: end;
gap: 24px; margin-bottom: 22px;
}
.hero-wordmark .eyebrow {
font-family: var(--f-pixel); font-size: 16px; letter-spacing: 4px; color: var(--pink);
}
.hero-wordmark .big {
font-family: var(--f-pixel);
font-size: clamp(48px, 7vw, 88px);
line-height: 0.9; letter-spacing: 0; margin-top: 6px;
display: inline-block;
padding-right: 0.12em;
color: transparent;
background: linear-gradient(180deg, var(--cream) 0%, var(--cream) 50%, var(--pink) 50%, var(--pink) 100%);
-webkit-background-clip: text; background-clip: text;
filter: drop-shadow(0 0 8px #ff3ea580);
}
.hero-tagline {
font-family: var(--f-hand); font-size: 26px;
color: var(--cream); transform: rotate(-2deg); line-height: 1.05;
text-align: center;
}
.hero-status {
font-family: var(--f-mono); font-size: 13px;
color: var(--cyan); text-align: right; line-height: 1.55;
}
.hero-status .live { color: var(--pink); }
.hero-status .blink { animation: blink 1s steps(2) infinite; }
@keyframes blink { 50% { opacity: 0.15; } }
.hero-body {
display: grid;
grid-template-columns: 260px 1fr;
gap: 24px;
position: relative;
}
.hero-stations { display: flex; flex-direction: column; gap: 10px; }
.hero-stations-label {
font-family: var(--f-pixel); color: var(--lemon);
font-size: 14px; letter-spacing: 2px;
padding: 5px 10px;
background: var(--ink); border: 2px solid var(--lemon);
align-self: flex-start; transform: rotate(-2deg);
margin-bottom: 4px;
}
.hero-deck-wrap { display: flex; flex-direction: column; gap: 18px; min-width: 0; }
.hero-footer { display: flex; gap: 12px; align-items: stretch; }
.share-btn {
font-family: var(--f-mono); font-size: 13px;
background: var(--ink); color: var(--cyan);
padding: 10px 14px; border: 1.5px dashed #5ef7ff66;
cursor: pointer; white-space: nowrap;
}
.share-btn:hover { border-style: solid; border-color: var(--cyan); }
.ticker {
flex: 1; overflow: hidden; white-space: nowrap;
background: var(--ink); padding: 10px 0;
border-top: 1px dashed #ff3ea566; border-bottom: 1px dashed #ff3ea566;
display: flex; align-items: center;
}
.ticker-inner {
display: inline-block; padding-left: 100%;
animation: marq 30s linear infinite;
color: var(--pink); font-family: var(--f-mono); font-size: 13px; letter-spacing: 1px;
}
@keyframes marq { to { transform: translateX(-100%); } }
@media (max-width: 1000px) {
.hero-body { grid-template-columns: 1fr; }
.hero-stations { display: grid; grid-template-columns: repeat(2, 1fr); }
}
@media (max-width: 640px) {
.page { padding: 14px; }
.hero { padding: 20px 16px 24px; }
.hero-header { grid-template-columns: 1fr; gap: 14px; }
.hero-tagline { text-align: left; }
.hero-status { text-align: left; }
.hero-stations { grid-template-columns: 1fr; }
}

View file

@ -0,0 +1,28 @@
.full-secthead { margin-bottom: 14px; }
.full-secthead-jp { font-family: var(--f-pixel); font-size: 18px; color: var(--lemon); letter-spacing: 4px; }
.full-secthead-en { font-family: var(--f-pixel); font-size: 22px; color: var(--cream); letter-spacing: 1px; margin-top: 2px; }
.card {
background: var(--plum);
border: 2px solid var(--ink);
padding: 22px 24px;
position: relative;
}
.card::before {
content: ""; position: absolute; inset: 4px; border: 1px dashed #ff3ea522; pointer-events: none;
}
.history-list { display: flex; flex-direction: column; }
.history-row {
display: grid; grid-template-columns: 50px 1fr auto;
gap: 12px; padding: 8px 4px;
border-bottom: 1px dashed #ff3ea522;
font-size: 13px;
}
.history-row:last-child { border-bottom: none; }
.history-row .t { font-family: var(--f-mono); color: var(--cyan); }
.history-row .title { font-family: var(--f-pixel); font-size: 14px; color: var(--cream); }
.history-row .artist { font-family: var(--f-mono); color: #fff4e8aa; font-size: 12px; }
.history-empty {
font-family: var(--f-mono); font-size: 13px; color: #fff4e8aa; padding: 8px 4px;
}

View file

@ -0,0 +1,20 @@
.listen-ways-list { display: flex; flex-direction: column; gap: 14px; }
.listen-way {
border: 1.5px dashed #5ef7ff44;
padding: 12px;
background: #0a04108c;
}
.lw-label { font-family: var(--f-pixel); font-size: 12px; color: var(--lemon); letter-spacing: 2px; margin-bottom: 4px; }
.lw-url {
display: block; font-family: var(--f-mono); font-size: 13px;
color: var(--cyan); background: var(--ink);
padding: 6px 8px; word-break: break-all;
}
.lw-hint { font-family: var(--f-mono); font-size: 11px; color: #fff4e866; margin-top: 6px; }
.row-grid { display: grid; gap: 24px; margin-bottom: 36px; }
.row-grid.two { grid-template-columns: 1.2fr 1fr; }
@media (max-width: 1000px) {
.row-grid.two { grid-template-columns: 1fr; }
}

View file

@ -0,0 +1,10 @@
.deck-spectrum {
display: flex; align-items: flex-end; gap: 2px;
height: 80px; margin-top: 18px;
padding: 10px; background: var(--ink); border: 2px solid var(--ink);
}
.spec-bar {
flex: 1; min-height: 3px;
background: linear-gradient(180deg, var(--pink) 0%, var(--lemon) 50%, var(--green) 100%);
transition: height 80ms linear;
}

View file

@ -0,0 +1,23 @@
.tape {
font-family: var(--f-mono);
background: var(--cream); color: var(--ink);
padding: 10px 12px 12px;
border: 2px solid var(--ink);
box-shadow: 3px 3px 0 var(--ink);
cursor: pointer; text-align: left;
transition: transform 120ms ease-out, box-shadow 120ms ease-out;
width: 100%;
}
.tape:hover { transform: translate(-2px,-2px); box-shadow: 5px 5px 0 var(--ink); }
.tape.active {
background: var(--pink); color: var(--cream);
transform: translate(-3px,-3px) rotate(-1deg);
box-shadow: 6px 6px 0 var(--lemon);
}
.tape-num { font-family: var(--f-mono); font-size: 10px; opacity: 0.65; letter-spacing: 1.5px; }
.tape-name { font-family: var(--f-pixel); font-size: 16px; line-height: 1.1; margin-top: 2px; }
.tape-jp { font-family: var(--f-pixel); font-size: 11px; opacity: 0.85; letter-spacing: 1.5px; margin-top: 2px; }
.tape-meta { font-family: var(--f-mono); font-size: 10px; margin-top: 6px; display: flex; justify-content: space-between; opacity: 0.75; }
.tape-holes { display: flex; gap: 4px; margin-top: 6px; }
.tape-holes span { width: 14px; height: 14px; border-radius: 50%; background: var(--ink); box-shadow: inset 0 0 0 3px var(--cream); }
.tape.active .tape-holes span { box-shadow: inset 0 0 0 3px var(--pink); }

View file

@ -0,0 +1,50 @@
@font-face {
font-family: "DotGothic16";
src: url("/fonts/DotGothic16.woff2") format("woff2");
font-weight: 400;
font-style: normal;
font-display: swap;
}
@font-face {
font-family: "VT323";
src: url("/fonts/VT323.woff2") format("woff2");
font-weight: 400;
font-style: normal;
font-display: swap;
}
@font-face {
font-family: "ReenieBeanie";
src: url("/fonts/ReenieBeanie.woff2") format("woff2");
font-weight: 400;
font-style: normal;
font-display: swap;
}
* { box-sizing: border-box; }
html, body { margin: 0; padding: 0; }
body {
background:
radial-gradient(ellipse at 20% 0%, #ff3ea522 0%, transparent 45%),
radial-gradient(ellipse at 80% 30%, #5ef7ff14 0%, transparent 45%),
radial-gradient(ellipse at 50% 100%, #a78bff22 0%, transparent 50%),
var(--plum);
color: var(--cream);
font-family: var(--f-mono);
min-height: 100vh;
font-variant-emoji: text;
}
body::before {
content: ""; position: fixed; inset: 0; pointer-events: none; z-index: 100;
background-image: url("data:image/svg+xml,%3Csvg viewBox='0 0 200 200' xmlns='http://www.w3.org/2000/svg'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.85' numOctaves='2'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23n)' opacity='0.6'/%3E%3C/svg%3E");
opacity: 0.07; mix-blend-mode: overlay;
}
body::after {
content: ""; position: fixed; inset: 0; pointer-events: none; z-index: 99;
background: repeating-linear-gradient(0deg, rgba(0,0,0,0.18) 0 1px, transparent 1px 3px);
opacity: 0.5;
}
a { color: var(--cyan); text-decoration: none; }
a:hover { color: var(--cream); text-decoration: underline; }
::selection { background: var(--pink); color: var(--ink); }
.page { max-width: 1200px; margin: 0 auto; padding: 24px 28px 0; position: relative; z-index: 1; }

View file

@ -0,0 +1,14 @@
:root {
--f-pixel: "DotGothic16", "VT323", monospace;
--f-mono: "VT323", ui-monospace, monospace;
--f-hand: "ReenieBeanie", cursive;
--pink: #ff3ea5;
--cyan: #5ef7ff;
--lemon: #ffe24a;
--lav: #a78bff;
--green: #5eff9b;
--cream: #fff4e8;
--ink: #0a0410;
--plum: #1a0820;
--plum-2: #2a1030;
}

View file

@ -0,0 +1,4 @@
name: Alpha
description: alpha station
color: '#ff0000'
tags: [test, alpha]

View file

@ -0,0 +1,2 @@
name: "unterminated
description: never closes

View file

@ -0,0 +1,4 @@
name: Beta
description: beta station
color: '#00ff00'
tags: [test, beta]

View file

@ -0,0 +1 @@
fake cover

View file

View file

@ -0,0 +1,61 @@
import { describe, expect, it } from 'vitest';
import { fmtMs, fmtRelative, fmtJpDate, fmtJpTime } from '@lib/format';
describe('fmtMs', () => {
it('formats zero as 0:00', () => {
expect(fmtMs(0)).toBe('0:00');
});
it('pads seconds to two digits', () => {
expect(fmtMs(7)).toBe('0:07');
});
it('formats 215 as 3:35', () => {
expect(fmtMs(215)).toBe('3:35');
});
it('handles minutes >= 10 correctly', () => {
expect(fmtMs(605)).toBe('10:05');
});
it('clamps negative input to 0:00', () => {
expect(fmtMs(-3)).toBe('0:00');
});
it('floors fractional seconds', () => {
expect(fmtMs(7.9)).toBe('0:07');
});
});
describe('fmtRelative', () => {
const now = new Date('2026-04-30T12:00:00Z');
it('formats <60s as Xs', () => {
expect(fmtRelative(new Date('2026-04-30T11:59:30Z'), now)).toBe('30s');
});
it('formats <1h as Xm', () => {
expect(fmtRelative(new Date('2026-04-30T11:55:00Z'), now)).toBe('5m');
});
it('formats <24h as Xh', () => {
expect(fmtRelative(new Date('2026-04-30T09:00:00Z'), now)).toBe('3h');
});
it('formats >=24h as Xd', () => {
expect(fmtRelative(new Date('2026-04-28T12:00:00Z'), now)).toBe('2d');
});
it('returns "now" for the current moment', () => {
expect(fmtRelative(new Date('2026-04-30T12:00:00Z'), now)).toBe('now');
});
it('returns "now" for a future timestamp (clock skew)', () => {
expect(fmtRelative(new Date('2026-04-30T12:00:05Z'), now)).toBe('now');
});
});
describe('fmtJpDate', () => {
it('formats with full kanji', () => {
// build the date in local time so the test isn't tz-sensitive
const d = new Date(2026, 3, 30); // month is 0-indexed → April
expect(fmtJpDate(d)).toBe('2026年4月30日');
});
});
describe('fmtJpTime', () => {
it('formats HH:MM:SS', () => {
const d = new Date();
d.setHours(7); d.setMinutes(3); d.setSeconds(9);
expect(fmtJpTime(d)).toBe('07:03:09');
});
});

View file

@ -0,0 +1,56 @@
import { describe, expect, it } from 'vitest';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { listStations, readMeta } from '@lib/stations';
const HERE = path.dirname(fileURLToPath(import.meta.url));
const FIXTURES = path.resolve(HERE, 'fixtures/library');
describe('readMeta', () => {
it('returns null for missing _meta.yml', async () => {
expect(await readMeta(FIXTURES, 'empty')).toBeNull();
});
it('returns null for malformed yaml (logged, not thrown)', async () => {
expect(await readMeta(FIXTURES, 'bad-yaml')).toBeNull();
});
it('returns null when the id has uppercase letters (regex rejection, no fs read)', async () => {
expect(await readMeta(FIXTURES, 'NotLower')).toBeNull();
});
it('parses a valid station', async () => {
const s = await readMeta(FIXTURES, 'alpha');
expect(s).toEqual({
id: 'alpha',
name: 'Alpha',
description: 'alpha station',
color: '#ff0000',
tags: ['test', 'alpha'],
mounts: { mp3: '/alpha.mp3', opus: '/alpha.opus' },
cover: null,
});
});
it('sets cover when a cover file exists', async () => {
const s = await readMeta(FIXTURES, 'beta');
expect(s?.cover).toBe('/api/stations/beta/cover');
});
});
describe('listStations', () => {
it('returns only valid stations, sorted by id', async () => {
const ss = await listStations(FIXTURES);
expect(ss.map((s) => s.id)).toEqual(['alpha', 'beta']);
});
it('skips empty and bad-yaml folders', async () => {
const ss = await listStations(FIXTURES);
expect(ss.find((s) => s.id === 'empty')).toBeUndefined();
expect(ss.find((s) => s.id === 'bad-yaml')).toBeUndefined();
});
it('handles a non-existent root gracefully', async () => {
expect(await listStations('/no/such/dir')).toEqual([]);
});
});

15
frontend/tsconfig.json Normal file
View file

@ -0,0 +1,15 @@
{
"extends": "astro/tsconfigs/strict",
"include": ["src/**/*", "astro.config.mjs"],
"exclude": ["dist", "node_modules"],
"compilerOptions": {
"jsx": "react-jsx",
"jsxImportSource": "react",
"baseUrl": ".",
"paths": {
"@lib/*": ["src/lib/*"],
"@components/*": ["src/components/*"],
"@styles/*": ["src/styles/*"]
}
}
}

12
frontend/vite.config.ts Normal file
View file

@ -0,0 +1,12 @@
import { defineConfig } from 'vite';
import { resolve } from 'node:path';
export default defineConfig({
resolve: {
alias: {
'@lib': resolve(__dirname, 'src/lib'),
'@components': resolve(__dirname, 'src/components'),
'@styles': resolve(__dirname, 'src/styles'),
},
},
});

17
frontend/vitest.config.ts Normal file
View file

@ -0,0 +1,17 @@
import { defineConfig } from 'vitest/config';
import { resolve } from 'node:path';
export default defineConfig({
test: {
environment: 'happy-dom',
include: ['src/tests/**/*.test.ts', 'src/tests/**/*.test.tsx'],
globals: false,
},
resolve: {
alias: {
'@lib': resolve(__dirname, 'src/lib'),
'@components': resolve(__dirname, 'src/components'),
'@styles': resolve(__dirname, 'src/styles'),
},
},
});

45
scripts/restore-album-covers.sh Executable file
View file

@ -0,0 +1,45 @@
#!/usr/bin/env bash
# one-time: extract cover.jpg from each album archive in
# ~/Downloads/Telegram\ Desktop and upload to summer storage box.
# safe to re-run; skips albums whose cover already exists on summer.
set -euo pipefail
SRC="${SRC:-/c/Users/user/Downloads/Telegram Desktop}"
SSH_KEY="${SSH_KEY:-$HOME/.ssh/keys/devilreef}"
SSH_OPTS=(-o IdentityAgent=none -o IdentitiesOnly=yes -i "$SSH_KEY")
REMOTE_LIB="/mnt/trashbox/denpa-radio/library/minecraft/tracks"
declare -A MAP=(
["C418 - Minecraft - Volume Alpha.tar.gz"]="Volume Alpha"
["C418 - Minecraft - Volume Beta.tar.gz"]="Volume Beta"
["Lena_Raine,_Minecraft_Minecraft_Caves_Cliffs_Original_Game.gz"]="Caves & Cliffs"
["Minecraft - Minecraft_ Pixel Drift.tar.gz"]="Pixel Drift"
["Peter_Hont,_Minecraft_Minecraft_Dungeons_Original_Game_Soundt.gz"]="Minecraft Dungeons"
["Peter_Hont,_Minecraft_Minecraft_Dungeons_Creeping_Winter_Ori.gz"]="Minecraft Dungeons - Creeping Winter"
["Peter_Hont,_Minecraft_Minecraft_Dungeons_Echoing_Void_Origin.gz"]="Minecraft Dungeons - Echoing Void"
["Peter_Hont,_Minecraft_Minecraft_Dungeons_Howling_Peaks_Origi.gz"]="Minecraft Dungeons - Howling Peaks"
["Peter_Hont,_Minecraft_Minecraft_Dungeons_Jungle_Awakens_Orig.gz"]="Minecraft Dungeons - Jungle Awakens"
["Peter_Hont,_Samuel_berg,_Minecraft_Minecraft_Dungeons_Ultima.gz"]="Minecraft Dungeons - Ultimate Additions"
)
tmp=$(mktemp -d)
trap 'rm -rf "$tmp"' EXIT
for archive in "${!MAP[@]}"; do
album="${MAP[$archive]}"
remote_path="$REMOTE_LIB/$album/cover.jpg"
if ssh "${SSH_OPTS[@]}" summer "test -f \"$remote_path\""; then
echo "skip: $album (cover exists)"
continue
fi
echo "extract: $archive$album"
tar -xzf "$SRC/$archive" -C "$tmp" cover.jpg
echo "upload: $album"
ssh "${SSH_OPTS[@]}" summer "mkdir -p \"$REMOTE_LIB/$album\" && cat > \"$remote_path\"" < "$tmp/cover.jpg"
rm "$tmp/cover.jpg"
done
echo "done"
ssh "${SSH_OPTS[@]}" summer "ls \"$REMOTE_LIB\"/*/cover.jpg"

5
streamer/.dockerignore Normal file
View file

@ -0,0 +1,5 @@
node_modules
dist
tests
*.test.ts
.vitest

29
streamer/Dockerfile Normal file
View file

@ -0,0 +1,29 @@
FROM node:22-bookworm-slim AS build
WORKDIR /app
COPY package.json ./
RUN npm install --no-audit --no-fund
COPY tsconfig.json ./
COPY src ./src
RUN npm run build
FROM node:22-bookworm-slim
ENV NODE_ENV=production \
DEBIAN_FRONTEND=noninteractive \
PUPPETEER_EXECUTABLE_PATH=/usr/bin/chromium
RUN apt-get update \
&& apt-get install -y --no-install-recommends \
chromium \
ffmpeg \
fonts-noto-cjk \
ca-certificates \
tini \
&& rm -rf /var/lib/apt/lists/*
WORKDIR /app
COPY package.json ./
RUN npm install --omit=dev --no-audit --no-fund
COPY --from=build /app/dist ./dist
ENTRYPOINT ["/usr/bin/tini", "--"]
CMD ["node", "dist/index.js"]

4344
streamer/package-lock.json generated Normal file

File diff suppressed because it is too large Load diff

30
streamer/package.json Normal file
View file

@ -0,0 +1,30 @@
{
"name": "denpa-streamer",
"version": "0.1.0",
"private": true,
"type": "module",
"scripts": {
"build:views": "node --experimental-strip-types src/views/build.ts",
"build:server": "tsc -p tsconfig.json",
"build": "npm run build:views && npm run build:server",
"start": "node dist/index.js",
"test": "vitest run",
"test:watch": "vitest",
"lint": "eslint src tests"
},
"dependencies": {
"puppeteer-core": "^23.0.0",
"react": "^18.3.1",
"react-dom": "^18.3.1"
},
"devDependencies": {
"@types/node": "^25.6.0",
"@types/react": "^18.3.0",
"@types/react-dom": "^18.3.0",
"esbuild": "^0.24.0",
"eslint": "^9.0.0",
"typescript": "^5.9.3",
"typescript-eslint": "^8.0.0",
"vitest": "^2.0.0"
}
}

60
streamer/src/chrome.ts Normal file
View file

@ -0,0 +1,60 @@
import { EventEmitter } from "node:events";
import puppeteer, { Browser, CDPSession, Page } from "puppeteer-core";
export interface ChromeOpts {
pageUrl: string;
width: number;
height: number;
framerate: number;
jpegQuality?: number; // 0..100, default 85
executablePath?: string; // default /usr/bin/chromium
}
export class ChromeRenderer extends EventEmitter {
private browser: Browser | null = null;
private page: Page | null = null;
private cdp: CDPSession | null = null;
constructor(private opts: ChromeOpts) { super(); }
async start(): Promise<void> {
this.browser = await puppeteer.launch({
executablePath: this.opts.executablePath ?? "/usr/bin/chromium",
headless: true,
args: [
"--no-sandbox",
"--disable-dev-shm-usage",
"--disable-gpu",
"--hide-scrollbars",
`--window-size=${this.opts.width},${this.opts.height}`,
"--autoplay-policy=no-user-gesture-required",
],
defaultViewport: { width: this.opts.width, height: this.opts.height },
});
this.page = await this.browser.newPage();
await this.page.goto(this.opts.pageUrl, { waitUntil: "networkidle2" });
this.cdp = await this.page.target().createCDPSession();
await this.cdp.send("Page.startScreencast", {
format: "jpeg",
quality: this.opts.jpegQuality ?? 85,
everyNthFrame: 1,
});
this.cdp.on("Page.screencastFrame", async (frame) => {
const buf = Buffer.from(frame.data, "base64");
this.emit("frame", buf);
await this.cdp!.send("Page.screencastFrameAck", { sessionId: frame.sessionId });
});
this.browser.on("disconnected", () => this.emit("disconnected"));
}
async stop(): Promise<void> {
try { await this.cdp?.send("Page.stopScreencast"); } catch { /* ignore */ }
await this.browser?.close().catch(() => {});
this.browser = null;
this.page = null;
this.cdp = null;
}
}

86
streamer/src/config.ts Normal file
View file

@ -0,0 +1,86 @@
export class ConfigError extends Error {
constructor(message: string) {
super(message);
this.name = "ConfigError";
}
}
export interface Config {
station: string;
rtmpUrl: string;
pcmHost: string;
pcmPort: number;
nowPlayingDir: string;
libraryDir: string;
style: "denpa" | "modern";
tz: string;
videoBitrate: string;
audioBitrate: string;
framerate: number;
resolution: { width: number; height: number };
stationDisplayName?: string;
stationTagline?: string;
stationTuneInUrl: string;
branding: boolean;
healthPort: number;
logLevel: "debug" | "info" | "warn" | "error";
icecastStatusUrl?: string;
}
function require_(env: Record<string, string | undefined>, key: string): string {
const v = env[key];
if (!v) throw new ConfigError(`missing required env ${key}`);
return v;
}
function parsePcm(url: string): { host: string; port: number } {
const m = url.match(/^tcp:\/\/([^:/]+):(\d+)$/);
if (!m) throw new ConfigError(`invalid LIQUIDSOAP_PCM (expected tcp://host:port): ${url}`);
return { host: m[1]!, port: Number(m[2]) };
}
function parseRes(s: string): { width: number; height: number } {
const m = s.match(/^(\d+)x(\d+)$/);
if (!m) throw new ConfigError(`invalid RESOLUTION: ${s}`);
return { width: Number(m[1]), height: Number(m[2]) };
}
export function loadConfig(env: Record<string, string | undefined> = process.env): Config {
const style = require_(env, "STYLE");
if (style !== "denpa" && style !== "modern") {
throw new ConfigError(`STYLE must be 'denpa' or 'modern', got ${style}`);
}
const rtmpUrl = require_(env, "RTMP_URL");
if (!/^rtmps?:\/\//.test(rtmpUrl)) {
throw new ConfigError(`RTMP_URL must start with rtmp:// or rtmps://`);
}
const { host: pcmHost, port: pcmPort } = parsePcm(require_(env, "LIQUIDSOAP_PCM"));
const logLevel = (env.LOG_LEVEL ?? "info") as Config["logLevel"];
if (!["debug", "info", "warn", "error"].includes(logLevel)) {
throw new ConfigError(`invalid LOG_LEVEL: ${logLevel}`);
}
return {
station: require_(env, "STATION"),
rtmpUrl,
pcmHost,
pcmPort,
nowPlayingDir: env.NOW_PLAYING_DIR ?? "/now-playing",
libraryDir: env.LIBRARY_DIR ?? "/library",
style,
tz: env.TZ ?? "UTC",
videoBitrate: env.VIDEO_BITRATE ?? "4500k",
audioBitrate: env.AUDIO_BITRATE ?? "160k",
framerate: Number(env.FRAMERATE ?? 30),
resolution: parseRes(env.RESOLUTION ?? "1920x1080"),
stationDisplayName: env.STATION_DISPLAY_NAME || undefined,
stationTagline: env.STATION_TAGLINE || undefined,
stationTuneInUrl: env.STATION_TUNE_IN_URL ?? "denpa.femboy.page",
branding: (env.BRANDING ?? "true").toLowerCase() !== "false",
healthPort: Number(env.HEALTH_PORT ?? 12010),
logLevel,
icecastStatusUrl: env.ICECAST_STATUS_URL || undefined,
};
}

69
streamer/src/ffmpeg.ts Normal file
View file

@ -0,0 +1,69 @@
import { ChildProcessWithoutNullStreams, spawn } from "node:child_process";
import { EventEmitter } from "node:events";
import { Writable } from "node:stream";
export interface FfmpegOpts {
rtmpUrl: string;
width: number;
height: number;
framerate: number;
videoBitrate: string;
audioBitrate: string;
}
export class Ffmpeg extends EventEmitter {
private proc: ChildProcessWithoutNullStreams | null = null;
constructor(private opts: FfmpegOpts) { super(); }
start(): { videoIn: Writable; audioIn: Writable } {
const args = [
"-loglevel", "warning",
// video in (mjpeg pipe on fd 3) — declare full-range to silence swscaler
"-color_range", "jpeg",
"-f", "image2pipe", "-c:v", "mjpeg",
"-thread_queue_size", "1024",
"-r", String(this.opts.framerate), "-i", "pipe:3",
// audio in (s16le pcm pipe on fd 4)
"-f", "s16le", "-ar", "48000", "-ac", "2",
"-thread_queue_size", "1024",
"-i", "pipe:4",
// video encode (explicit colorspace conversion silences swscaler nags)
"-vf", "scale=in_range=full:out_range=tv,format=yuv420p",
"-c:v", "libx264", "-preset", "veryfast",
"-color_range", "tv",
"-b:v", this.opts.videoBitrate,
"-maxrate", this.opts.videoBitrate, "-bufsize", "9000k",
"-g", String(this.opts.framerate * 2), "-keyint_min", String(this.opts.framerate * 2),
"-r", String(this.opts.framerate),
// audio encode
"-c:a", "aac", "-b:a", this.opts.audioBitrate, "-ar", "48000",
// output
"-f", "flv", this.opts.rtmpUrl,
];
this.proc = spawn("ffmpeg", args, {
stdio: ["ignore", "pipe", "pipe", "pipe", "pipe"],
}) as unknown as ChildProcessWithoutNullStreams;
const stdio = (this.proc as unknown as { stdio: Writable[] }).stdio;
const videoIn = stdio[3]!;
const audioIn = stdio[4]!;
this.proc.stderr.on("data", (d) => this.emit("log", d.toString()));
this.proc.on("exit", (code) => this.emit("exit", code));
// ffmpeg exiting (e.g. RTMP drop) closes its stdins — subsequent writes
// emit EPIPE on these sockets, which would crash the supervisor as
// unhandled. Catch them; the proc.on("exit") handler does the actual restart.
videoIn.on("error", (err) => this.emit("log", `videoIn: ${err.message}`));
audioIn.on("error", (err) => this.emit("log", `audioIn: ${err.message}`));
return { videoIn, audioIn };
}
stop(): void {
this.proc?.kill("SIGTERM");
this.proc = null;
}
}

122
streamer/src/fft.ts Normal file
View file

@ -0,0 +1,122 @@
export interface SpectrumOpts {
bars: number;
sampleRate: number;
fftSize?: number;
smoothing?: number;
}
export class SpectrumAnalyzer {
private fftSize: number;
private window: Float32Array;
private buffer: Float32Array;
private bufFill = 0;
private smooth: Float32Array;
private bandStarts: number[];
private bandEnds: number[];
constructor(private opts: SpectrumOpts) {
this.fftSize = opts.fftSize ?? 1024;
this.window = new Float32Array(this.fftSize);
for (let i = 0; i < this.fftSize; i++) {
this.window[i] = 0.5 - 0.5 * Math.cos((2 * Math.PI * i) / (this.fftSize - 1));
}
this.buffer = new Float32Array(this.fftSize);
this.smooth = new Float32Array(opts.bars);
[this.bandStarts, this.bandEnds] = this.makeBands();
}
private makeBands(): [number[], number[]] {
const minHz = 40;
const maxHz = Math.min(16000, this.opts.sampleRate / 2);
const fftBin = (hz: number) => Math.round((hz / this.opts.sampleRate) * this.fftSize);
const starts: number[] = [];
const ends: number[] = [];
for (let i = 0; i < this.opts.bars; i++) {
const a = minHz * Math.pow(maxHz / minHz, i / this.opts.bars);
const b = minHz * Math.pow(maxHz / minHz, (i + 1) / this.opts.bars);
starts.push(Math.max(1, fftBin(a)));
ends.push(Math.max(starts[i]! + 1, fftBin(b)));
}
return [starts, ends];
}
feed(pcm: Buffer): void {
const samples = pcm.length / 4;
let bi = this.bufFill;
for (let i = 0; i < samples; i++) {
const l = pcm.readInt16LE(i * 4);
const r = pcm.readInt16LE(i * 4 + 2);
this.buffer[bi] = ((l + r) / 2) / 32768;
bi++;
if (bi >= this.fftSize) {
this.computeFrame();
bi = 0;
}
}
this.bufFill = bi;
}
bars(): number[] {
const out = new Array<number>(this.opts.bars);
for (let i = 0; i < this.opts.bars; i++) out[i] = this.smooth[i]!;
return out;
}
private computeFrame(): void {
const re = new Float32Array(this.fftSize);
const im = new Float32Array(this.fftSize);
for (let i = 0; i < this.fftSize; i++) re[i] = this.buffer[i]! * this.window[i]!;
fftInPlace(re, im);
const alpha = this.opts.smoothing ?? 0.6;
// db scaling — same idea as web audio's getByteFrequencyData.
// hann-windowed full-scale sine has peak mag ~ fftSize/2; reference to that.
const ref = this.fftSize / 2;
const dbMin = -80;
const dbMax = -20;
for (let i = 0; i < this.opts.bars; i++) {
let mag = 0;
const s = this.bandStarts[i]!;
const e = this.bandEnds[i]!;
for (let k = s; k < e; k++) mag += Math.sqrt(re[k]! * re[k]! + im[k]! * im[k]!);
mag /= e - s;
const db = 20 * Math.log10(Math.max(mag, 1e-9) / ref);
const norm = Math.max(0, Math.min(1, (db - dbMin) / (dbMax - dbMin)));
this.smooth[i] = alpha * this.smooth[i]! + (1 - alpha) * norm;
}
}
}
function fftInPlace(re: Float32Array, im: Float32Array): void {
const n = re.length;
for (let i = 1, j = 0; i < n; i++) {
let bit = n >> 1;
for (; j & bit; bit >>= 1) j ^= bit;
j ^= bit;
if (i < j) {
[re[i], re[j]] = [re[j]!, re[i]!];
[im[i], im[j]] = [im[j]!, im[i]!];
}
}
for (let len = 2; len <= n; len <<= 1) {
const halfLen = len >> 1;
const angle = (-2 * Math.PI) / len;
const wre = Math.cos(angle);
const wim = Math.sin(angle);
for (let i = 0; i < n; i += len) {
let cre = 1;
let cim = 0;
for (let k = 0; k < halfLen; k++) {
const tre = cre * re[i + k + halfLen]! - cim * im[i + k + halfLen]!;
const tim = cre * im[i + k + halfLen]! + cim * re[i + k + halfLen]!;
re[i + k + halfLen] = re[i + k]! - tre;
im[i + k + halfLen] = im[i + k]! - tim;
re[i + k] = re[i + k]! + tre;
im[i + k] = im[i + k]! + tim;
const ncre = cre * wre - cim * wim;
cim = cre * wim + cim * wre;
cre = ncre;
}
}
}
}

43
streamer/src/icecast.ts Normal file
View file

@ -0,0 +1,43 @@
import { EventEmitter } from "node:events";
export interface IcecastOpts {
statusUrl: string;
mountName: string;
intervalMs?: number;
}
export interface IcecastListeners { current: number; peak: number; }
export class IcecastPoller extends EventEmitter {
private timer: NodeJS.Timeout | null = null;
private peak = 0;
constructor(private opts: IcecastOpts) { super(); }
start(): void {
this.poll();
this.timer = setInterval(() => this.poll(), this.opts.intervalMs ?? 30000);
}
stop(): void {
if (this.timer) clearInterval(this.timer);
}
private async poll(): Promise<void> {
try {
const r = await fetch(this.opts.statusUrl, { signal: AbortSignal.timeout(5000) });
if (!r.ok) throw new Error(`icecast status ${r.status}`);
const j = await r.json() as {
icestats: { source?: { listenurl: string; listeners: number }[] | { listenurl: string; listeners: number } };
};
const sources = j.icestats.source;
const list = Array.isArray(sources) ? sources : sources ? [sources] : [];
const found = list.find((s) => s.listenurl.endsWith(this.opts.mountName));
const current = found?.listeners ?? 0;
this.peak = Math.max(this.peak, current);
this.emit("listeners", { current, peak: this.peak });
} catch (err) {
this.emit("warn", err);
}
}
}

133
streamer/src/index.ts Normal file
View file

@ -0,0 +1,133 @@
import { createServer } from "node:http";
import { join } from "node:path";
import { loadConfig } from "./config.js";
import { NowPlayingWatcher } from "./nowplaying.js";
import { PageServer } from "./page-server.js";
import { PcmTap } from "./pcm-tap.js";
import { SpectrumAnalyzer } from "./fft.js";
import { ChromeRenderer } from "./chrome.js";
import { Ffmpeg } from "./ffmpeg.js";
import { IcecastPoller } from "./icecast.js";
const cfg = loadConfig();
const log = (level: string, msg: string, extra?: unknown) =>
console.log(JSON.stringify({ ts: new Date().toISOString(), level, msg, ...(extra ? { extra } : {}) }));
const PAGE_PORT = 8080;
const STATIC_DIR = join(import.meta.dirname ?? __dirname, "views");
const BARS = cfg.style === "denpa" ? 48 : 72;
let lastFrameAt = 0;
async function main() {
const np = new NowPlayingWatcher({
station: cfg.station,
nowPlayingDir: cfg.nowPlayingDir,
libraryDir: cfg.libraryDir,
});
const page = new PageServer({
port: PAGE_PORT,
staticDir: STATIC_DIR,
libraryDir: cfg.libraryDir,
style: cfg.style,
station: cfg.station,
tz: cfg.tz,
tuneInUrl: cfg.stationTuneInUrl,
branding: cfg.branding,
});
const pcm = new PcmTap({ host: cfg.pcmHost, port: cfg.pcmPort });
const spec = new SpectrumAnalyzer({ bars: BARS, sampleRate: 48000 });
const ice = cfg.icecastStatusUrl
? new IcecastPoller({ statusUrl: cfg.icecastStatusUrl, mountName: `/${cfg.station}.mp3` })
: null;
const ffmpeg = new Ffmpeg({
rtmpUrl: cfg.rtmpUrl,
width: cfg.resolution.width,
height: cfg.resolution.height,
framerate: cfg.framerate,
videoBitrate: cfg.videoBitrate,
audioBitrate: cfg.audioBitrate,
});
const chrome = new ChromeRenderer({
pageUrl: `http://127.0.0.1:${PAGE_PORT}/?style=${cfg.style}`,
width: cfg.resolution.width,
height: cfg.resolution.height,
framerate: cfg.framerate,
});
await page.start();
await np.start();
np.on("change", (s) => page.pushNowPlaying(s));
if (np.current()) page.pushNowPlaying(np.current());
const pushTimer: NodeJS.Timeout = setInterval(() => page.pushSpectrum(spec.bars()), Math.round(1000 / 30));
ice?.on("listeners", (l) => page.pushListeners(l));
ice?.start();
const { videoIn, audioIn } = ffmpeg.start();
ffmpeg.on("log", (m: string) => {
const msg = m.trim();
// mjpeg decoder emits an unsuppressable swscaler "deprecated pixel format"
// nag before any filters run; cosmetic, drop it from logs.
if (msg.includes("deprecated pixel format used")) return;
if (/^\[swscaler @ 0x[0-9a-f]+\]\s*$/.test(msg)) return;
log("info", "ffmpeg", msg);
});
ffmpeg.on("exit", (code: number | null) => {
log("error", "ffmpeg exited", { code });
process.exit(1);
});
pcm.on("data", (chunk: Buffer) => {
spec.feed(chunk);
audioIn.write(chunk);
});
pcm.on("connecting", () => log("info", "pcm connecting"));
pcm.on("connected", () => log("info", "pcm connected"));
pcm.on("disconnected", () => log("warn", "pcm disconnected"));
pcm.start();
await chrome.start();
chrome.on("frame", (buf: Buffer) => {
lastFrameAt = Date.now();
videoIn.write(buf);
});
chrome.on("disconnected", () => {
log("error", "chrome disconnected");
process.exit(1);
});
// health endpoint
const health = createServer((req, res) => {
if (req.url !== "/health") { res.statusCode = 404; res.end(); return; }
const stale = Date.now() - lastFrameAt > 5000;
res.statusCode = stale ? 503 : 200;
res.setHeader("content-type", "application/json");
res.end(JSON.stringify({ ok: !stale, lastFrameAt }));
});
health.listen(cfg.healthPort, "0.0.0.0", () => log("info", `health :${cfg.healthPort}`));
const shutdown = async () => {
clearInterval(pushTimer);
pcm.stop();
await chrome.stop();
ffmpeg.stop();
np.stop();
ice?.stop();
await page.stop();
process.exit(0);
};
process.on("SIGTERM", shutdown);
process.on("SIGINT", shutdown);
}
main().catch((err) => {
log("error", "fatal", { err: err instanceof Error ? err.message : String(err) });
process.exit(1);
});

100
streamer/src/nowplaying.ts Normal file
View file

@ -0,0 +1,100 @@
import { EventEmitter } from "node:events";
import { existsSync, readFileSync, watch, FSWatcher } from "node:fs";
import { join } from "node:path";
export interface NowPlayingTrack {
title: string;
artist: string;
album: string;
duration: string;
cover_path: string;
}
export interface NowPlayingState {
station: string;
title: string;
artist: string;
album: string;
filename: string;
duration: string;
started_at: string;
cover_path: string;
cover_url: string;
up_next: (NowPlayingTrack & { cover_url: string })[];
}
export interface NowPlayingOpts {
station: string;
nowPlayingDir: string;
libraryDir: string;
}
export class NowPlayingWatcher extends EventEmitter {
private state: NowPlayingState | null = null;
private watcher: FSWatcher | null = null;
private debounce: NodeJS.Timeout | null = null;
private path: string;
constructor(private opts: NowPlayingOpts) {
super();
this.path = join(opts.nowPlayingDir, `${opts.station}.json`);
}
async start(): Promise<void> {
this.read();
this.watcher = watch(this.opts.nowPlayingDir, (_event, filename) => {
if (filename !== `${this.opts.station}.json`) return;
if (this.debounce) clearTimeout(this.debounce);
this.debounce = setTimeout(() => this.read(), 50);
});
}
stop(): void {
this.watcher?.close();
this.watcher = null;
if (this.debounce) clearTimeout(this.debounce);
}
current(): NowPlayingState | null {
return this.state;
}
private read(): void {
if (!existsSync(this.path)) return;
try {
const raw = readFileSync(this.path, "utf8");
const parsed = JSON.parse(raw);
const cover_path = this.resolveCover(parsed.cover_path, parsed.filename);
const up_next = (parsed.up_next ?? []).map((t: NowPlayingTrack) => ({
...t,
cover_url: this.coverUrl(this.resolveCover(t.cover_path, "")),
}));
this.state = {
...parsed,
cover_path,
cover_url: this.coverUrl(cover_path),
up_next,
};
this.emit("change", this.state);
} catch (err) {
this.emit("warn", err);
}
}
private resolveCover(suggested: string, _filename: string): string {
if (suggested && existsSync(this.toAbsolute(suggested))) return suggested;
const stationCover = `/library/${this.opts.station}/cover.jpg`;
if (existsSync(this.toAbsolute(stationCover))) return stationCover;
return "";
}
private toAbsolute(libraryPath: string): string {
if (!libraryPath.startsWith("/library/")) return libraryPath;
return join(this.opts.libraryDir, libraryPath.slice("/library/".length));
}
private coverUrl(libraryPath: string): string {
if (!libraryPath) return "";
return `/cover?path=${encodeURIComponent(libraryPath)}`;
}
}

129
streamer/src/page-server.ts Normal file
View file

@ -0,0 +1,129 @@
import { createServer, IncomingMessage, ServerResponse } from "node:http";
import { createReadStream, existsSync } from "node:fs";
import { extname, join } from "node:path";
import { EventEmitter } from "node:events";
export interface PageServerOpts {
port: number;
staticDir: string;
libraryDir: string;
style: "denpa" | "modern";
station: string;
tz: string;
tuneInUrl: string;
branding: boolean;
}
export class PageServer extends EventEmitter {
private nowClients = new Set<ServerResponse>();
private specClients = new Set<ServerResponse>();
private latestNowJson: string | null = null;
private latestListeners: { current: number; peak: number } = { current: 0, peak: 0 };
private server = createServer((req, res) => this.handle(req, res));
constructor(private opts: PageServerOpts) { super(); }
start(): Promise<void> {
return new Promise((resolve) => this.server.listen(this.opts.port, "127.0.0.1", () => resolve()));
}
stop(): Promise<void> {
for (const c of this.nowClients) c.end();
for (const c of this.specClients) c.end();
return new Promise((resolve) => this.server.close(() => resolve()));
}
pushNowPlaying(json: unknown): void {
this.latestNowJson = JSON.stringify(json);
const payload = `data: ${this.latestNowJson}\n\n`;
for (const c of this.nowClients) c.write(payload);
}
pushSpectrum(bars: number[]): void {
const payload = `data: ${JSON.stringify(bars)}\n\n`;
for (const c of this.specClients) c.write(payload);
}
pushListeners(l: { current: number; peak: number }): void {
this.latestListeners = l;
}
private handle(req: IncomingMessage, res: ServerResponse): void {
const url = new URL(req.url ?? "/", "http://localhost");
if (url.pathname === "/") return this.serveIndex(res);
if (url.pathname.startsWith("/assets/")) return this.serveStatic(url.pathname, res);
if (url.pathname === "/now-playing") return this.serveSse(res, this.nowClients, this.latestNowJson);
if (url.pathname === "/spectrum") return this.serveSse(res, this.specClients, null);
if (url.pathname === "/listeners") {
res.setHeader("content-type", "application/json");
res.end(JSON.stringify(this.latestListeners));
return;
}
if (url.pathname === "/cover") return this.serveCover(url, res);
res.statusCode = 404;
res.end("not found");
}
private serveIndex(res: ServerResponse): void {
const indexPath = join(this.opts.staticDir, "index.html");
if (!existsSync(indexPath)) {
res.statusCode = 500;
res.end("index not built");
return;
}
const inject = `<script>window.__STREAMER_CONFIG__=${JSON.stringify({
style: this.opts.style,
station: this.opts.station,
tz: this.opts.tz,
tuneInUrl: this.opts.tuneInUrl,
branding: this.opts.branding,
})}</script>`;
let html = "";
createReadStream(indexPath, "utf8")
.on("data", (chunk) => (html += chunk))
.on("end", () => {
res.setHeader("content-type", "text/html; charset=utf-8");
res.end(html.replace("</head>", `${inject}</head>`));
});
}
private serveStatic(pathname: string, res: ServerResponse): void {
const file = join(this.opts.staticDir, pathname.replace(/^\/assets\//, ""));
if (!file.startsWith(this.opts.staticDir) || !existsSync(file)) {
res.statusCode = 404; res.end("not found"); return;
}
const ct = ({
".js": "application/javascript",
".css": "text/css",
".woff2": "font/woff2",
} as const)[extname(file) as ".js" | ".css" | ".woff2"] ?? "application/octet-stream";
res.setHeader("content-type", ct);
res.setHeader("cache-control", "max-age=86400");
createReadStream(file).pipe(res);
}
private serveSse(res: ServerResponse, set: Set<ServerResponse>, initial: string | null): void {
res.setHeader("content-type", "text/event-stream");
res.setHeader("cache-control", "no-cache");
res.setHeader("connection", "keep-alive");
res.flushHeaders();
set.add(res);
if (initial) res.write(`data: ${initial}\n\n`);
res.on("close", () => set.delete(res));
}
private serveCover(url: URL, res: ServerResponse): void {
const requested = url.searchParams.get("path") ?? "";
if (!requested.startsWith("/library/")) { res.statusCode = 400; res.end(); return; }
const file = join(this.opts.libraryDir, requested.slice("/library/".length));
if (!file.startsWith(this.opts.libraryDir) || !existsSync(file)) {
res.statusCode = 404; res.end(); return;
}
const ext = extname(file).toLowerCase();
res.setHeader("content-type", ext === ".png" ? "image/png" : ext === ".webp" ? "image/webp" : "image/jpeg");
res.setHeader("cache-control", "no-store");
createReadStream(file).pipe(res);
}
}

51
streamer/src/pcm-tap.ts Normal file
View file

@ -0,0 +1,51 @@
import { EventEmitter } from "node:events";
import { Socket, connect } from "node:net";
export interface PcmTapOpts {
host: string;
port: number;
reconnectInitialMs?: number;
reconnectMaxMs?: number;
}
export class PcmTap extends EventEmitter {
private socket: Socket | null = null;
private stopped = false;
private backoff: number;
constructor(private opts: PcmTapOpts) {
super();
this.backoff = opts.reconnectInitialMs ?? 1000;
}
start(): void {
if (this.stopped) return;
this.connect();
}
stop(): void {
this.stopped = true;
this.socket?.destroy();
}
private connect(): void {
this.emit("connecting");
const sock = connect({ host: this.opts.host, port: this.opts.port });
this.socket = sock;
sock.on("connect", () => {
this.emit("connected");
this.backoff = this.opts.reconnectInitialMs ?? 1000;
});
sock.on("data", (chunk) => this.emit("data", chunk));
sock.on("error", (err) => this.emit("warn", err));
sock.on("close", () => {
if (this.stopped) return;
this.emit("disconnected");
const max = this.opts.reconnectMaxMs ?? 30000;
const wait = Math.min(this.backoff, max);
this.backoff = Math.min(this.backoff * 2, max);
setTimeout(() => this.connect(), wait);
});
}
}

View file

@ -0,0 +1,41 @@
import { StrictMode, useEffect, useState } from "react";
import { createRoot } from "react-dom/client";
import { StreamDenpa } from "./denpa.tsx";
import { StreamModern } from "./modern.tsx";
declare global {
interface Window {
__STREAMER_CONFIG__: { style: "denpa" | "modern"; station: string; tz: string; tuneInUrl: string; branding: boolean };
}
}
function useListeners(): { current: number; peak: number } {
const [v, setV] = useState({ current: 0, peak: 0 });
useEffect(() => {
let peak = 0;
const tick = async () => {
try {
const r = await fetch(`/listeners`);
if (r.ok) {
const j = await r.json();
peak = Math.max(peak, j.current ?? 0);
setV({ current: j.current ?? 0, peak });
}
} catch { /* ignore */ }
};
tick();
const t = setInterval(tick, 30000);
return () => clearInterval(t);
}, []);
return v;
}
function App() {
const cfg = window.__STREAMER_CONFIG__;
const listeners = useListeners();
return cfg.style === "denpa"
? <StreamDenpa cfg={cfg} listeners={listeners} />
: <StreamModern cfg={cfg} listeners={listeners} />;
}
createRoot(document.getElementById("root")!).render(<StrictMode><App /></StrictMode>);

View file

@ -0,0 +1,19 @@
import { build } from "esbuild";
import { copyFileSync, mkdirSync } from "node:fs";
const outDir = "dist/views";
mkdirSync(outDir, { recursive: true });
copyFileSync("src/views/index.html", `${outDir}/index.html`);
await build({
entryPoints: ["src/views/App.tsx"],
bundle: true,
format: "esm",
target: "es2022",
jsx: "automatic",
outfile: `${outDir}/main.js`,
loader: { ".tsx": "tsx", ".ts": "ts" },
define: { "process.env.NODE_ENV": '"production"' },
minify: true,
});
console.log("views bundled →", outDir);

View file

@ -0,0 +1,265 @@
import { useEffect, useState } from "react";
import { useNowPlaying } from "./shared/useNowPlaying.ts";
import { useSpectrum } from "./shared/useSpectrum.ts";
import { useElapsed } from "./shared/useElapsed.ts";
import { fmtMs, fmtClock, fmtClockSec, fmtJpDate } from "./shared/format.ts";
const KAOMOJI_STREAM = ["(´。• ᵕ •。`)", "٩(◕‿◕)۶", "(。◕‿◕。)", "(>ω<)", "(✿◠‿◠)"];
interface Cfg { station: string; tz: string; tuneInUrl: string; branding: boolean; }
interface Listeners { current: number; peak: number; }
interface Props { cfg: Cfg; listeners: Listeners; }
export function StreamDenpa({ cfg, listeners }: Props) {
const np = useNowPlaying();
const spectrum = useSpectrum();
const [now, setNow] = useState(() => new Date());
useEffect(() => {
const id = setInterval(() => setNow(new Date()), 1000);
return () => clearInterval(id);
}, []);
const startedAt = np ? Number(np.started_at) : 0;
const duration = np ? Number(np.duration) : 0;
const elapsed = useElapsed(startedAt, duration);
if (!np) return <div style={{ width: 1920, height: 1080, background: "#1a0820" }} />;
const pct = duration > 0 ? (elapsed / duration) * 100 : 0;
const upNext = np.up_next.slice(0, 4);
const nameJp = "ラジオ";
const channel = "CH.01";
const bitrate = 192;
const codec = "MP3/OPUS";
const genre = "auto";
const uptime = "—";
return (
<div className="sd-root">
<style>{`
.sd-root {
width: 1920px; height: 1080px;
box-sizing: border-box;
background:
radial-gradient(ellipse at 25% 0%, #5ef7ff22 0%, transparent 50%),
radial-gradient(ellipse at 80% 100%, #ff3ea522 0%, transparent 55%),
#1a0820;
color: #fff4e8;
font-family: var(--f-mono);
position: relative; overflow: hidden;
padding: 56px 64px 0;
font-variant-emoji: text;
}
.sd-root::before {
content: ""; position: absolute; inset: 0; pointer-events: none;
background-image: url("data:image/svg+xml,%3Csvg viewBox='0 0 200 200' xmlns='http://www.w3.org/2000/svg'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.85' numOctaves='2'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23n)' opacity='0.5'/%3E%3C/svg%3E");
opacity: 0.07; mix-blend-mode: overlay;
}
.sd-root::after {
content: ""; position: absolute; inset: 0; pointer-events: none;
background: repeating-linear-gradient(0deg, rgba(0,0,0,0.18) 0 1px, transparent 1px 3px);
opacity: 0.4;
}
.sd-header { display: grid; grid-template-columns: auto 1fr auto; align-items: end; gap: 36px; margin-bottom: 36px; position: relative; z-index: 2; }
.sd-wm .eyebrow { font-family: var(--f-pixel); font-size: 22px; letter-spacing: 6px; color: #ff3ea5; }
.sd-wm .big {
display: block; font-family: var(--f-pixel); font-size: 110px; line-height: 0.9;
margin-top: 10px; padding-right: 0.12em;
color: transparent;
background: linear-gradient(180deg, #fff4e8 0%, #fff4e8 50%, #ff3ea5 50%, #ff3ea5 100%);
-webkit-background-clip: text; background-clip: text;
filter: drop-shadow(0 0 14px #ff3ea580);
}
.sd-channel { font-family: var(--f-hand); font-size: 34px; transform: rotate(-2deg); line-height: 1.05; max-width: 280px; align-self: flex-end; padding-bottom: 14px; }
.sd-status { text-align: right; font-family: var(--f-mono); font-size: 18px; line-height: 1.5; color: #5ef7ff; padding-bottom: 12px; }
.sd-status .live { color: #ff3ea5; font-family: var(--f-pixel); font-size: 22px; letter-spacing: 3px; }
.sd-status .blink { animation: sd-blink 1s steps(2) infinite; }
@keyframes sd-blink { 50% { opacity: 0.15; } }
.sd-body { display: grid; grid-template-columns: 1.3fr 1fr; gap: 48px; position: relative; z-index: 2; }
.sd-deck {
background: linear-gradient(180deg, #fff4e8 0%, #f5e0c4 100%);
color: #0a0410; padding: 32px;
border: 4px solid #0a0410;
box-shadow: 12px 12px 0 #ff3ea5, 24px 24px 0 #5ef7ff;
position: relative;
}
.sd-deck::before { content: ""; position: absolute; inset: 6px; border: 1.5px dashed #0a041044; pointer-events: none; }
.sd-screen { background: #0a0410; color: #5eff9b; font-family: var(--f-mono); font-size: 18px; padding: 22px 26px; border: 3px inset #0a0410; position: relative; overflow: hidden; }
.sd-screen::after { content: ""; position: absolute; inset: 0; pointer-events: none; background: repeating-linear-gradient(0deg, rgba(94,255,155,0.08) 0 1px, transparent 1px 3px); }
.sd-screen-row { display: flex; justify-content: space-between; opacity: 0.78; }
.sd-screen .now { font-family: var(--f-pixel); font-size: 56px; line-height: 1.05; color: #fff4e8; text-shadow: 0 0 12px #5eff9b88; margin: 10px 0 6px; }
.sd-screen .artist { font-size: 24px; color: #5ef7ff; margin-bottom: 14px; }
.sd-progress-row { display: flex; align-items: center; gap: 14px; margin-top: 16px; }
.sd-progress { flex: 1; height: 6px; background: #5eff9b22; border: 1px solid #5eff9b66; }
.sd-progress-fill { height: 100%; background: #5eff9b; transition: width 200ms linear; }
.sd-time { font-size: 18px; color: #5ef7ff; min-width: 56px; }
.sd-reels { display: flex; align-items: center; gap: 22px; padding: 22px; margin-top: 26px; background: #0a0410; border: 3px solid #0a0410; }
.sd-reel {
width: 150px; height: 150px; border-radius: 50%;
background:
radial-gradient(circle, #fff4e8 0 22px, transparent 22px),
conic-gradient(from 0deg, #2a1030 0 25%, #1a0820 25% 50%, #2a1030 50% 75%, #1a0820 75%);
border: 3px solid #fff4e8;
animation: sd-spin 1.6s linear infinite;
position: relative;
}
.sd-reel::before { content: ""; position: absolute; inset: 10px; border-radius: 50%; border: 1px dashed #fff4e833; }
@keyframes sd-spin { to { transform: rotate(360deg); } }
.sd-strip { flex: 1; height: 10px; background: linear-gradient(90deg, #fff4e8 0%, #d4a578 50%, #fff4e8 100%); border-top: 2px solid #0a0410; border-bottom: 2px solid #0a0410; }
.sd-spectrum { display: flex; align-items: flex-end; gap: 3px; height: 110px; margin-top: 22px; padding: 12px; background: #0a0410; border: 3px solid #0a0410; }
.sd-spec-bar { flex: 1; min-height: 4px; background: linear-gradient(180deg, #ff3ea5 0%, #ffe24a 50%, #5eff9b 100%); transition: height 80ms linear; }
.sd-right { display: flex; flex-direction: column; gap: 28px; min-width: 0; }
.sd-card { background: #1a0820; border: 2px solid #0a0410; padding: 24px 28px; position: relative; }
.sd-card::before { content: ""; position: absolute; inset: 5px; border: 1px dashed #ff3ea533; pointer-events: none; }
.sd-h { font-family: var(--f-pixel); font-size: 18px; letter-spacing: 4px; color: #ffe24a; margin-bottom: 4px; }
.sd-h-en { font-family: var(--f-pixel); font-size: 24px; color: #fff4e8; letter-spacing: 1px; }
.sd-h-sub { font-family: var(--f-mono); font-size: 14px; color: #fff4e8aa; margin-top: 6px; }
.sd-queue { margin-top: 14px; display: flex; flex-direction: column; gap: 8px; }
.sd-queue-row { display: grid; grid-template-columns: 32px 1fr auto; gap: 12px; padding: 8px 4px; border-bottom: 1px dashed #ff3ea522; align-items: baseline; }
.sd-queue-row:last-child { border-bottom: none; }
.sd-queue-row .n { font-family: var(--f-mono); font-size: 14px; color: #5ef7ff; }
.sd-queue-row .t { font-family: var(--f-pixel); font-size: 22px; color: #fff4e8; }
.sd-queue-row .a { font-family: var(--f-mono); font-size: 14px; color: #fff4e8aa; }
.sd-queue-row .d { font-family: var(--f-mono); font-size: 14px; color: #ff3ea5; align-self: center; }
.sd-stats { display: grid; grid-template-columns: repeat(2, 1fr); gap: 16px; margin-top: 14px; }
.sd-stat { padding: 14px 16px; background: #0a0410; border: 1.5px dashed #5ef7ff44; }
.sd-stat .lbl { font-family: var(--f-pixel); font-size: 12px; letter-spacing: 2px; color: #ffe24a; }
.sd-stat .val { font-family: var(--f-pixel); font-size: 32px; color: #fff4e8; margin-top: 4px; line-height: 1; }
.sd-stat .sub { font-family: var(--f-mono); font-size: 12px; color: #5ef7ff; margin-top: 4px; }
.sd-bomb { position: absolute; pointer-events: none; z-index: 5; }
.sd-bomb.b1 { top: 36px; right: 340px; }
.sd-bomb.b2 { top: 250px; right: 36px; }
.sd-bottom {
position: absolute; left: 0; right: 0; bottom: 0;
height: 64px; background: #0a0410;
border-top: 2px dashed #ff3ea566;
display: grid; grid-template-columns: auto 1fr auto;
align-items: center; gap: 24px; padding: 0 36px;
z-index: 6;
}
.sd-listen { font-family: var(--f-pixel); font-size: 22px; letter-spacing: 2px; color: #5ef7ff; }
.sd-listen b { color: #ff3ea5; margin-right: 10px; }
.sd-ticker { overflow: hidden; white-space: nowrap; }
.sd-ticker-inner { display: inline-block; padding-left: 100%; animation: sd-marq 36s linear infinite; color: #ffe24a; font-family: var(--f-mono); font-size: 16px; letter-spacing: 1px; }
@keyframes sd-marq { to { transform: translateX(-100%); } }
.sd-clock { font-family: var(--f-pixel); font-size: 26px; color: #fff4e8; letter-spacing: 2px; padding: 6px 14px; background: #1a0820; border: 1.5px dashed #5ef7ff66; }
.sd-sticker { display: inline-block; padding: 10px 18px; font-family: var(--f-pixel); font-size: 20px; letter-spacing: 1px; border: 2px solid #0a0410; box-shadow: 4px 4px 0 #0a0410; }
`}</style>
<div className="sd-header">
{cfg.branding && (
<>
<div className="sd-wm">
<div className="eyebrow"> TRANSMISSION</div>
<span className="big">denpa.fm</span>
</div>
<div className="sd-channel">
{channel} <br/>
{nameJp}<br/>
{"<3"} block radio
</div>
</>
)}
<div className="sd-status">
<div className="live">[<span className="blink">REC</span>] ON AIR</div>
<div>{fmtJpDate(now)}</div>
<div>{fmtClockSec(now)} JST</div>
<div>{listeners.current} listening · peak {listeners.peak}</div>
</div>
</div>
<div className="sd-body">
<div className="sd-deck">
<div className="sd-screen">
<div className="sd-screen-row">
<span>&gt;&gt; NOW PLAYING</span>
<span>{bitrate}kbps · {codec}</span>
</div>
<div className="now">{np.title}</div>
<div className="artist">{np.artist} {np.album}</div>
<div className="sd-progress-row">
<span className="sd-time">{fmtMs(elapsed)}</span>
<div className="sd-progress"><div className="sd-progress-fill" style={{ width: pct + "%" }}></div></div>
<span className="sd-time">{fmtMs(duration)}</span>
</div>
</div>
<div className="sd-reels">
<div className="sd-reel"></div>
<div className="sd-strip"></div>
<div className="sd-reel"></div>
</div>
<div className="sd-spectrum">
{spectrum.map((v, i) => (
<div key={i} className="sd-spec-bar" style={{ height: `${Math.max(4, v * 100)}%` }}></div>
))}
</div>
</div>
<div className="sd-right">
<div className="sd-card">
<div className="sd-h"></div>
<div className="sd-h-en">// up next</div>
<div className="sd-h-sub">auto-DJ · no ads · no skips · forever</div>
<div className="sd-queue">
{upNext.map((q, i) => (
<div key={i} className="sd-queue-row">
<span className="n">0{i + 1}</span>
<span>
<span className="t">{q.title}</span><br/>
<span className="a">{q.artist}</span>
</span>
<span className="d">{fmtMs(Number(q.duration))}</span>
</div>
))}
</div>
</div>
<div className="sd-card">
<div className="sd-h"></div>
<div className="sd-h-en">// station info</div>
<div className="sd-stats">
<div className="sd-stat">
<div className="lbl">CHANNEL</div>
<div className="val">{channel}</div>
<div className="sub">{genre}</div>
</div>
<div className="sd-stat">
<div className="lbl">UPTIME</div>
<div className="val">{uptime}</div>
<div className="sub">since 2024</div>
</div>
<div className="sd-stat">
<div className="lbl">LISTENING</div>
<div className="val">{listeners.current}</div>
<div className="sub">peak {listeners.peak} today</div>
</div>
<div className="sd-stat">
<div className="lbl">BITRATE</div>
<div className="val">{bitrate}k</div>
<div className="sub">{codec}</div>
</div>
</div>
</div>
</div>
</div>
{/* Sticker bombs */}
<div className="sd-bomb b1"><span className="sd-sticker" style={{ background: "#5ef7ff", color: "#0a0410", transform: "rotate(-6deg)" }}>// 24/7 ON AIR //</span></div>
<div className="sd-bomb b2"><span className="sd-sticker" style={{ background: "#ffe24a", color: "#0a0410", transform: "rotate(5deg)" }}>SIDE A · CH.01</span></div>
{/* Bottom strip */}
{cfg.branding && (
<div className="sd-bottom">
<div className="sd-listen"><b>&gt;&gt;</b> tune in @ {cfg.tuneInUrl}</div>
<div className="sd-ticker">
<div className="sd-ticker-inner">
now broadcasting from a small room in tokyo 24/7 always-on {cfg.station} station stream URL: {cfg.tuneInUrl} requests via @denpa_bot no ads · no algorithms · just blocks {KAOMOJI_STREAM.join(" ※ ")}
</div>
</div>
<div className="sd-clock">{fmtClock(now)}</div>
</div>
)}
</div>
);
}

View file

@ -0,0 +1,23 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<title>denpa streamer</title>
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=DotGothic16&family=VT323&family=Reenie+Beanie&family=Inter:wght@400;500;600;700;800&display=swap" />
<style>
:root {
--f-pixel: "DotGothic16", "VT323", monospace;
--f-mono: "VT323", ui-monospace, monospace;
--f-hand: "Reenie Beanie", cursive;
}
html, body { margin: 0; padding: 0; background: #0a0410; }
body { font-family: "Inter", system-ui, sans-serif; }
</style>
</head>
<body>
<div id="root"></div>
<script type="module" src="/assets/main.js"></script>
</body>
</html>

View file

@ -0,0 +1,268 @@
import { useEffect, useState } from "react";
import { useNowPlaying } from "./shared/useNowPlaying.ts";
import { useSpectrum } from "./shared/useSpectrum.ts";
import { useElapsed } from "./shared/useElapsed.ts";
import { fmtMs, fmtClock } from "./shared/format.ts";
interface Cfg { station: string; tz: string; tuneInUrl: string; branding: boolean; }
interface Listeners { current: number; peak: number; }
interface Props { cfg: Cfg; listeners: Listeners; }
export function StreamModern({ cfg, listeners }: Props) {
const np = useNowPlaying();
const spectrum = useSpectrum();
const [now, setNow] = useState(() => new Date());
useEffect(() => {
const id = setInterval(() => setNow(new Date()), 1000);
return () => clearInterval(id);
}, []);
const startedAt = np ? Number(np.started_at) : 0;
const duration = np ? Number(np.duration) : 0;
const elapsed = useElapsed(startedAt, duration);
if (!np) return <div style={{ width: 1920, height: 1080, background: "#0c0d10" }} />;
const pct = duration > 0 ? (elapsed / duration) * 100 : 0;
const upNext = np.up_next.slice(0, 3);
const bitrate = 192;
return (
<div className="sm-root">
<style>{`
.sm-root {
width: 1920px; height: 1080px;
box-sizing: border-box;
background: #0c0d10;
color: #f5f5f0;
font-family: "Inter", system-ui, sans-serif;
position: relative; overflow: hidden;
padding: 80px;
display: grid;
grid-template-columns: 720px 1fr;
gap: 80px;
align-items: center;
}
/* Ambient color wash from artwork */
.sm-bg {
position: absolute; inset: -20%;
background:
radial-gradient(ellipse at 25% 40%, #4a6b7a 0%, transparent 50%),
radial-gradient(ellipse at 75% 60%, #6a8a5a 0%, transparent 55%);
filter: blur(80px); opacity: 0.5; z-index: 0;
}
.sm-grain {
position: absolute; inset: 0; pointer-events: none;
background-image: url("data:image/svg+xml,%3Csvg viewBox='0 0 200 200' xmlns='http://www.w3.org/2000/svg'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.85' numOctaves='2'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23n)' opacity='0.5'/%3E%3C/svg%3E");
opacity: 0.04; mix-blend-mode: overlay; z-index: 1;
}
/* Brand mark — top-left */
.sm-brand {
position: absolute; top: 48px; left: 80px;
display: flex; align-items: center; gap: 14px;
font-family: "Inter", sans-serif; font-size: 14px; letter-spacing: 4px;
color: #f5f5f0aa; z-index: 3;
text-transform: uppercase;
}
.sm-brand-dot { width: 10px; height: 10px; border-radius: 50%; background: #e8546b; box-shadow: 0 0 12px #e8546b; }
/* Live + clock — top-right */
.sm-meta-top {
position: absolute; top: 48px; right: 80px;
display: flex; align-items: center; gap: 28px;
font-family: "Inter", sans-serif; font-size: 14px; letter-spacing: 3px;
color: #f5f5f0aa; z-index: 3;
text-transform: uppercase;
}
.sm-live { display: flex; align-items: center; gap: 10px; color: #e8546b; }
.sm-live-dot { width: 8px; height: 8px; border-radius: 50%; background: #e8546b; animation: sm-pulse 1.6s ease-in-out infinite; }
@keyframes sm-pulse { 50% { opacity: 0.2; transform: scale(0.8); } }
.sm-clock { font-variant-numeric: tabular-nums; color: #f5f5f0; font-weight: 500; letter-spacing: 2px; }
/* Artwork */
.sm-art-wrap { position: relative; z-index: 2; aspect-ratio: 1 / 1; }
.sm-art {
width: 100%; aspect-ratio: 1 / 1;
border-radius: 12px;
background:
linear-gradient(135deg, #4a6b7a 0%, #2a3a48 100%);
box-shadow:
0 60px 120px rgba(0,0,0,0.6),
0 0 0 1px rgba(255,255,255,0.04);
position: relative;
overflow: hidden;
object-fit: cover;
display: block;
}
/* Stylized cover for "Sweden" — soft hills + sun */
.sm-art svg { width: 100%; height: 100%; display: block; }
/* Right column */
.sm-right { position: relative; z-index: 2; max-width: 880px; }
.sm-eyebrow {
font-family: "Inter", sans-serif; font-size: 13px; letter-spacing: 4px;
text-transform: uppercase; color: #f5f5f0aa; margin-bottom: 20px;
display: flex; gap: 18px; align-items: center;
}
.sm-eyebrow .pill {
padding: 4px 10px; border: 1px solid #f5f5f033; border-radius: 999px;
font-size: 11px; letter-spacing: 2px;
}
.sm-title {
font-family: "Inter", "Helvetica Neue", sans-serif;
font-weight: 800;
font-size: 124px; line-height: 0.95;
letter-spacing: -0.04em;
color: #f5f5f0;
margin: 0 0 24px;
}
.sm-artist {
font-family: "Inter", sans-serif; font-weight: 500;
font-size: 36px; color: #d4d0c4;
margin-bottom: 6px;
}
.sm-album {
font-family: "Inter", sans-serif; font-weight: 400;
font-size: 22px; color: #d4d0c499;
margin-bottom: 56px;
}
/* Progress */
.sm-progress {
height: 4px; background: #f5f5f01a;
border-radius: 2px; overflow: hidden;
margin-bottom: 12px;
}
.sm-progress-fill {
height: 100%; background: #f5f5f0;
width: 0%;
transition: width 200ms linear;
border-radius: 2px;
}
.sm-times {
display: flex; justify-content: space-between;
font-family: "Inter", sans-serif; font-size: 16px;
color: #f5f5f0aa; font-variant-numeric: tabular-nums;
letter-spacing: 1px;
margin-bottom: 56px;
}
/* Spectrum (subtle, bottom of right column) */
.sm-spectrum {
display: flex; align-items: flex-end; gap: 3px;
height: 64px;
margin-bottom: 32px;
}
.sm-spec-bar {
flex: 1; min-height: 2px;
background: #f5f5f088;
border-radius: 1px;
transition: height 80ms linear;
}
/* Up next — minimal */
.sm-upnext {
border-top: 1px solid #f5f5f01a;
padding-top: 24px;
}
.sm-upnext-h {
font-family: "Inter", sans-serif; font-size: 12px;
letter-spacing: 4px; text-transform: uppercase;
color: #f5f5f088;
margin-bottom: 16px;
}
.sm-upnext-list { display: flex; flex-direction: column; gap: 10px; }
.sm-upnext-row {
display: grid; grid-template-columns: 1fr auto;
gap: 24px; align-items: baseline;
padding-bottom: 10px;
border-bottom: 1px solid #f5f5f00f;
}
.sm-upnext-row:last-child { border-bottom: none; }
.sm-upnext-row .t {
font-family: "Inter", sans-serif; font-size: 22px; font-weight: 500;
color: #f5f5f0;
}
.sm-upnext-row .a { font-size: 16px; color: #d4d0c499; margin-left: 14px; font-weight: 400; }
.sm-upnext-row .d { font-size: 16px; color: #f5f5f088; font-variant-numeric: tabular-nums; }
/* Bottom strip */
.sm-bottom {
position: absolute; left: 80px; right: 80px; bottom: 56px;
display: flex; justify-content: space-between; align-items: center;
font-family: "Inter", sans-serif; font-size: 13px;
letter-spacing: 3px; text-transform: uppercase;
color: #f5f5f088;
z-index: 3;
}
.sm-bottom .listeners { color: #f5f5f0; }
`}</style>
<div className="sm-bg"></div>
<div className="sm-grain"></div>
{cfg.branding && (
<div className="sm-brand">
<span className="sm-brand-dot"></span>
denpa.fm {cfg.station}
</div>
)}
<div className="sm-meta-top">
<div className="sm-live"><span className="sm-live-dot"></span>Live</div>
<div className="sm-clock">{fmtClock(now)} JST</div>
</div>
{/* Artwork (left) */}
<div className="sm-art-wrap">
<img className="sm-art" src={np.cover_url} alt="" />
</div>
{/* Right column */}
<div className="sm-right">
<div className="sm-eyebrow">
<span>Now playing</span>
</div>
<h1 className="sm-title">{np.title}</h1>
<div className="sm-artist">{np.artist}</div>
<div className="sm-album">{np.album} · {bitrate}kbps</div>
<div className="sm-progress"><div className="sm-progress-fill" style={{ width: pct + "%" }}></div></div>
<div className="sm-times">
<span>{fmtMs(elapsed)}</span>
<span>{fmtMs(duration)}</span>
</div>
<div className="sm-spectrum">
{spectrum.map((v, i) => (
<div key={i} className="sm-spec-bar" style={{ height: `${Math.max(3, v * 100)}%`, opacity: 0.3 + v * 0.6 }}></div>
))}
</div>
<div className="sm-upnext">
<div className="sm-upnext-h">Up next</div>
<div className="sm-upnext-list">
{upNext.map((q, i) => (
<div key={i} className="sm-upnext-row">
<div>
<span className="t">{q.title}</span>
<span className="a">{q.artist}</span>
</div>
<div className="d">{fmtMs(Number(q.duration))}</div>
</div>
))}
</div>
</div>
</div>
{cfg.branding && (
<div className="sm-bottom">
<span>Tune in at <span className="listeners">{cfg.tuneInUrl}</span></span>
<span><span className="listeners">{listeners.current}</span> listeners · 24/7 · auto-DJ</span>
<span>{cfg.tuneInUrl}</span>
</div>
)}
</div>
);
}

View file

@ -0,0 +1,14 @@
export const fmtMs = (sec: number): string => {
const m = Math.floor(sec / 60);
const s = Math.floor(sec % 60);
return `${m}:${s.toString().padStart(2, "0")}`;
};
export const fmtClock = (d: Date): string =>
`${d.getHours().toString().padStart(2, "0")}:${d.getMinutes().toString().padStart(2, "0")}`;
export const fmtClockSec = (d: Date): string =>
`${fmtClock(d)}:${d.getSeconds().toString().padStart(2, "0")}`;
export const fmtJpDate = (d: Date): string =>
`${d.getFullYear()}${d.getMonth() + 1}${d.getDate()}`;

View file

@ -0,0 +1,10 @@
import { useEffect, useState } from "react";
export function useElapsed(startedAt: number, duration: number): number {
const [now, setNow] = useState(() => Date.now() / 1000);
useEffect(() => {
const t = setInterval(() => setNow(Date.now() / 1000), 200);
return () => clearInterval(t);
}, []);
return Math.min(duration, Math.max(0, now - startedAt));
}

View file

@ -0,0 +1,30 @@
import { useEffect, useState } from "react";
export interface NowPlayingTrack {
title: string;
artist: string;
album: string;
duration: string;
cover_url: string;
}
export interface NowPlayingState {
station: string;
title: string;
artist: string;
album: string;
duration: string;
started_at: string;
cover_url: string;
up_next: NowPlayingTrack[];
}
export function useNowPlaying(): NowPlayingState | null {
const [state, setState] = useState<NowPlayingState | null>(null);
useEffect(() => {
const es = new EventSource("/now-playing");
es.onmessage = (e) => setState(JSON.parse(e.data));
return () => es.close();
}, []);
return state;
}

View file

@ -0,0 +1,11 @@
import { useEffect, useState } from "react";
export function useSpectrum(): number[] {
const [bars, setBars] = useState<number[]>([]);
useEffect(() => {
const es = new EventSource("/spectrum");
es.onmessage = (e) => setBars(JSON.parse(e.data));
return () => es.close();
}, []);
return bars;
}

View file

@ -0,0 +1,48 @@
import { describe, expect, it } from "vitest";
import { loadConfig, ConfigError } from "../src/config.ts";
const baseEnv = {
STATION: "minecraft",
RTMP_URL: "rtmp://example/live/key",
LIQUIDSOAP_PCM: "tcp://liquidsoap:9100",
NOW_PLAYING_DIR: "/now-playing",
LIBRARY_DIR: "/library",
STYLE: "denpa",
TZ: "Asia/Tokyo",
VIDEO_BITRATE: "4500k",
AUDIO_BITRATE: "160k",
FRAMERATE: "30",
RESOLUTION: "1920x1080",
STATION_TUNE_IN_URL: "denpa.femboy.page",
HEALTH_PORT: "12010",
LOG_LEVEL: "info",
};
describe("loadConfig", () => {
it("parses required fields", () => {
const cfg = loadConfig(baseEnv);
expect(cfg.station).toBe("minecraft");
expect(cfg.rtmpUrl).toBe("rtmp://example/live/key");
expect(cfg.style).toBe("denpa");
expect(cfg.framerate).toBe(30);
});
it("rejects invalid style", () => {
expect(() => loadConfig({ ...baseEnv, STYLE: "weird" })).toThrow(ConfigError);
});
it("rejects malformed rtmp url", () => {
expect(() => loadConfig({ ...baseEnv, RTMP_URL: "not-a-url" })).toThrow(ConfigError);
});
it("rejects missing required field", () => {
const { STATION: _omit, ...rest } = baseEnv;
expect(() => loadConfig(rest)).toThrow(ConfigError);
});
it("parses pcm tcp url", () => {
const cfg = loadConfig(baseEnv);
expect(cfg.pcmHost).toBe("liquidsoap");
expect(cfg.pcmPort).toBe(9100);
});
});

View file

@ -0,0 +1,42 @@
import { describe, expect, it } from "vitest";
import { SpectrumAnalyzer } from "../src/fft.ts";
function sineFrame(freqHz: number, sampleRate = 48000, samples = 1024): Buffer {
const buf = Buffer.alloc(samples * 2 * 2); // s16le stereo
for (let i = 0; i < samples; i++) {
const v = Math.round(Math.sin((2 * Math.PI * freqHz * i) / sampleRate) * 16000);
buf.writeInt16LE(v, i * 4);
buf.writeInt16LE(v, i * 4 + 2);
}
return buf;
}
describe("SpectrumAnalyzer", () => {
it("produces N bars of normalized [0..1] floats", () => {
const a = new SpectrumAnalyzer({ bars: 48, sampleRate: 48000 });
a.feed(sineFrame(1000));
const bars = a.bars();
expect(bars).toHaveLength(48);
bars.forEach((v) => {
expect(v).toBeGreaterThanOrEqual(0);
expect(v).toBeLessThanOrEqual(1);
});
});
it("a 200hz sine puts most energy in low bars (logarithmic binning)", () => {
const a = new SpectrumAnalyzer({ bars: 48, sampleRate: 48000 });
for (let n = 0; n < 4; n++) a.feed(sineFrame(200));
const bars = a.bars();
const lowSum = bars.slice(0, 16).reduce((s, v) => s + v, 0);
const highSum = bars.slice(32).reduce((s, v) => s + v, 0);
expect(lowSum).toBeGreaterThan(0.1);
expect(lowSum).toBeGreaterThan(highSum + 0.1);
});
it("silence produces all-near-zero bars", () => {
const a = new SpectrumAnalyzer({ bars: 72, sampleRate: 48000 });
a.feed(Buffer.alloc(1024 * 4));
const bars = a.bars();
bars.forEach((v) => expect(v).toBeLessThan(0.01));
});
});

View file

@ -0,0 +1,3 @@
name: Minecraft
description: blocks
color: "#5ef7ff"

Binary file not shown.

After

Width:  |  Height:  |  Size: 630 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 630 B

View file

@ -0,0 +1,14 @@
{
"station": "minecraft",
"title": "Sweden",
"artist": "C418",
"album": "Volume Alpha",
"filename": "/library/minecraft/tracks/Volume Alpha/18. Sweden.flac",
"duration": "215",
"started_at": "1730358000",
"cover_path": "/library/minecraft/tracks/Volume Alpha/cover.jpg",
"up_next": [
{ "title": "Wet Hands", "artist": "C418", "album": "Volume Alpha", "duration": "90",
"cover_path": "/library/minecraft/tracks/Volume Alpha/cover.jpg" }
]
}

View file

@ -0,0 +1,103 @@
import { describe, expect, it, beforeEach, afterEach } from "vitest";
import { mkdtempSync, writeFileSync, rmSync, cpSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { NowPlayingWatcher } from "../src/nowplaying.ts";
let tmpRoot: string;
beforeEach(() => {
tmpRoot = mkdtempSync(join(tmpdir(), "denpa-np-"));
cpSync("tests/fixtures/now-playing", join(tmpRoot, "now-playing"), { recursive: true });
cpSync("tests/fixtures/library", join(tmpRoot, "library"), { recursive: true });
});
afterEach(() => {
rmSync(tmpRoot, { recursive: true, force: true });
});
describe("NowPlayingWatcher", () => {
it("reads initial state from disk", async () => {
const watcher = new NowPlayingWatcher({
station: "minecraft",
nowPlayingDir: join(tmpRoot, "now-playing"),
libraryDir: join(tmpRoot, "library"),
});
await watcher.start();
const state = watcher.current();
expect(state).not.toBeNull();
expect(state!.title).toBe("Sweden");
expect(state!.cover_url).toMatch(/^\/cover\?path=/);
expect(state!.up_next).toHaveLength(1);
watcher.stop();
});
it("rewrites cover_path to /cover?path=… url", async () => {
const watcher = new NowPlayingWatcher({
station: "minecraft",
nowPlayingDir: join(tmpRoot, "now-playing"),
libraryDir: join(tmpRoot, "library"),
});
await watcher.start();
expect(watcher.current()!.cover_url).toContain(
encodeURIComponent("/library/minecraft/tracks/Volume Alpha/cover.jpg"),
);
watcher.stop();
});
it("emits change events on file rewrite", async () => {
const watcher = new NowPlayingWatcher({
station: "minecraft",
nowPlayingDir: join(tmpRoot, "now-playing"),
libraryDir: join(tmpRoot, "library"),
});
await watcher.start();
let changes = 0;
watcher.on("change", () => changes++);
const newPayload = JSON.stringify({
station: "minecraft",
title: "Pigstep",
artist: "Lena Raine",
album: "Nether Update",
filename: "/library/minecraft/tracks/Nether/01. Pigstep.flac",
duration: "148",
started_at: "1730358500",
cover_path: "",
up_next: [],
});
writeFileSync(join(tmpRoot, "now-playing", "minecraft.json"), newPayload);
await new Promise((r) => setTimeout(r, 200));
expect(changes).toBeGreaterThan(0);
expect(watcher.current()!.title).toBe("Pigstep");
watcher.stop();
});
it("falls back to station cover when track cover missing", async () => {
const payload = JSON.stringify({
station: "minecraft",
title: "Untagged Track",
artist: "?",
album: "?",
filename: "/library/minecraft/tracks/UnknownAlbum/track.flac",
duration: "120",
started_at: "1730358000",
cover_path: "",
up_next: [],
});
writeFileSync(join(tmpRoot, "now-playing", "minecraft.json"), payload);
const watcher = new NowPlayingWatcher({
station: "minecraft",
nowPlayingDir: join(tmpRoot, "now-playing"),
libraryDir: join(tmpRoot, "library"),
});
await watcher.start();
expect(watcher.current()!.cover_url).toContain(
encodeURIComponent("/library/minecraft/cover.jpg"),
);
watcher.stop();
});
});

19
streamer/tsconfig.json Normal file
View file

@ -0,0 +1,19 @@
{
"compilerOptions": {
"target": "ES2023",
"module": "ES2022",
"moduleResolution": "bundler",
"lib": ["ES2023", "DOM"],
"jsx": "react-jsx",
"strict": true,
"noImplicitAny": true,
"noUncheckedIndexedAccess": true,
"esModuleInterop": true,
"skipLibCheck": true,
"outDir": "dist",
"rootDir": "src",
"declaration": false
},
"include": ["src/**/*.ts", "src/**/*.tsx"],
"exclude": ["src/views/**/*", "node_modules", "dist"]
}

View file

@ -0,0 +1,8 @@
import { defineConfig } from "vitest/config";
export default defineConfig({
test: {
include: ["tests/**/*.test.ts"],
environment: "node",
},
});