diff --git a/docs/architecture.md b/docs/architecture.md new file mode 100644 index 0000000..bc25209 --- /dev/null +++ b/docs/architecture.md @@ -0,0 +1,75 @@ +# Architecture + +## Design (DDD layering) + +``` +cmd/ thin entrypoints: env parsing, wiring, loops. No logic. +internal/app/ application services: LoadLibrary, Transcode, Ship. +internal/infra/ infrastructure adapters: m3u writer, icecast SOURCE client, + MQTT 3.1.1 client, ffmpeg/scp process runners. +internal/domain/ pure core: Track, UnixTime, FilterPlayable, BuildPlaylist. +``` + +Dependency rule: `cmd → app → infra → domain`. The domain imports nothing +project-internal and performs no I/O — playlist policy is 100% unit-testable. + +## Key decisions + +1. **Generator and stream are decoupled by files, not by a queue.** + The shipper drops `playlist.m3u8` + mp3s + `titles.json` into the VPS + web root; the streamer re-reads them each lap. Consequences: + - generator machine can sleep; the stream keeps playing shipped music + - no broker/queue dependency; scp is the sync primitive + - playlist is the single source of truth for what's on air + +2. **Continuous live stream, not playlist-file playback.** + Old stereos handle one URL (`/ai-radio.mp3`) far better than m3u + playlists (gaps, no re-fetch). The m3u8 still ships as a fallback + artifact and as the streamer's work list. + +3. **Titles reach the stereo two ways:** + - **ID3 tags** baked into each mp3 at transcode time (ID3v2.3 + ID3v1 + fallback) — what the stereo display actually reads from the stream + - **`titles.json` sidecar** → MQTT now-playing — for dashboards/logging, + because parsing ID3 from a live stream is painful + +4. **Zero third-party Go deps.** MQTT 3.1.1 and the icecast SOURCE protocol + are small enough to hand-roll (see `internal/infra/mqtt.go`, + `icecast.go`); both are wire-tested against `tests/fakes.py`. ffmpeg and + scp are the only external binaries. + +5. **Playlist policy lives in the domain.** + `BuildPlaylist(tracks, size, bias, seed)`: newest-biased weighted random + (weight = 1/(rank+bias)), disliked tracks filtered, deterministic under + seed. Changing musical behavior never touches I/O code. + +6. **Failure posture: the stream never dies.** + - MQTT publish failure → log and continue + - icecast drop → reconnect loop (10s backoff) + - bad file in playlist → skip + - empty library → wait, don't crash + - playlist titles are sanitized: newlines flattened and `#EXT` stripped + so a song title can't forge a playlist entry (tested) + +## Data flow contract + +Shipper → VPS web root (`web/ai-radio/`): +- `.mp3` — 128k 44.1kHz stereo, ID3v2.3+ID3v1, title=DisplayTitle +- `playlist.m3u8` — #EXTM3U, order = on-air order, relative filenames +- `titles.json` — `{"": ""}` + +Streamer → MQTT (QoS 0, topic `airstudio/nowplaying`): +```json +{"station":"AI Radio","title":"Neon Rain","artist":"AI Radio", + "track":"Neon Rain","file":".mp3", + "stream_url":"https://…/ai-radio.mp3","ts":1790798159} +``` + +## Known trade-offs + +- Streamer opens a fresh MQTT connection per track (fire-and-forget, simple). + Fine at one publish per ~2 min. +- scp re-uploads whole files; no delta sync. At 128k a 3-min song ≈ 2.8 MB — + negligible. +- The streamer assumes all mp3s share 44.1kHz stereo (enforced at transcode). + Mixing in foreign files can glitch the stream boundary.