Files
radio-go/docs/architecture.md
2026-09-30 21:59:34 +02:00

3.2 KiB

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):

{"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.