add docs/architecture.md
This commit is contained in:
@@ -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/`):
|
||||||
|
- `<track-id>.mp3` — 128k 44.1kHz stereo, ID3v2.3+ID3v1, title=DisplayTitle
|
||||||
|
- `playlist.m3u8` — #EXTM3U, order = on-air order, relative filenames
|
||||||
|
- `titles.json` — `{"<track-id>": "<display title>"}`
|
||||||
|
|
||||||
|
Streamer → MQTT (QoS 0, topic `airstudio/nowplaying`):
|
||||||
|
```json
|
||||||
|
{"station":"AI Radio","title":"Neon Rain","artist":"AI Radio",
|
||||||
|
"track":"Neon Rain","file":"<id>.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.
|
||||||
Reference in New Issue
Block a user