Skip to content

Architecture

DatanoiseTV edited this page Jun 18, 2026 · 1 revision

Architecture

How TinyIce is put together internally. This is the orientation for anyone reading the source or planning a change; pair it with Developing.

Shape

main.go            Entry point: flags, config, signals, subcommands.
config/            tinyice.json schema, defaults, atomic save.
relay/             Audio/video core (the engine).
server/            HTTP/TCP layer, auth, handlers, sockets.
server/frontend/   Preact + Vite admin SPA and player (embedded into the binary).
monitoring/        Grafana dashboard + Prometheus scrape config.
packaging/         nFPM spec + systemd unit for .deb/.rpm.

The released binary is fully self-contained: pure-Go decoders/encoders, a CGO-free SQLite driver (github.com/glebarez/sqlite), and the entire frontend embedded via go:embed. It cross-compiles to Linux, macOS, Windows, and FreeBSD with no C toolchain.

The relay and circular buffers

The heart of TinyIce is the relay (relay/), a pub/sub engine. Every mount is a Stream backed by a single fixed-size circular buffer, not per-listener queues.

  • A source writes chunks into the buffer once.
  • Each listener holds an offset into that shared buffer and is woken by a signal channel when new data arrives; it pumps bytes straight from shared memory to its socket.
  • New listeners subscribe at an offset rewound by the mount's burst size, so they get an "instant start" burst from buffer history.

This is why per-listener overhead is small and the real ceiling is network bandwidth, not RAM (see Deployment). Ingestion is O(1); signalling N listeners is O(N) and cheap in Go.

Concurrency and lock discipline

Streaming servers live or die on their locking. TinyIce's rules, learned partly the hard way (the changelog documents the incidents):

  • Two-tier locks: a relay-wide RWMutex guards the map of streams; each Stream has its own RWMutex for its metadata and listener set. Bandwidth counters are sync/atomic.
  • Never hold a lock across blocking I/O. SetCurrentSong captures the diff under the lock then runs the SQLite history write outside it; Broadcast releases the stream write lock before fanning out listener signal channels (the former full-lock fan-out was the dominant contention vector).
  • No recursive RLock. Go's writer-preferring RWMutex deadlocks if a writer queues between a re-entrant pair — fixed in SavePlaylist/GetSongTitle.
  • Don't close a channel a sender might still use. Unsubscribe no longer closes the signal channel; a recover() in the fan-out is the backstop.
  • Bounded, always-advancing parsers. The Ogg page-boundary scanner advances ≥1 byte per iteration (a i += n - 3 that could stall held a read lock and hung the whole relay); the TS demuxer resyncs byte-by-byte.
  • Deadlines on every hijacked read so a silent peer can't pin a goroutine + FD + mount forever.

Subsystems

Package / file Responsibility
relay/relay.go, stream.go, buffer.go Stream lifecycle, circular buffer, broadcast/subscribe.
relay/streamer.go, transcode.go AutoDJ playback loop and native MP3/Opus encoding.
relay/webrtc.go pion/webrtc source ingest and WHEP egress, Ogg muxing.
relay/client.go Pulling upstream Icecast relays.
relay/mpd.go Minimal MPD control protocol per AutoDJ.
relay/history.go GORM models on pure-Go SQLite (history.db).
server/server.go Routing, startup, hot-swap.
server/auth.go Sessions, roles, IP security, lockouts.
server/listener.go Listener lifecycle + the internal metrics/pprof server.
server/handlers_*.go Admin, API, player, HLS handlers.
server/socket_*.go OS-specific SO_REUSEPORT syscalls.

Key data flows

Listener (Icecast): handleListener accepts the request → Stream.Subscribe returns a buffer offset rewound by burst_size → the loop reads from the circular buffer and waits on a signal channel for new data.

AutoDJ: the streamer selects a file → decode to PCM → transcode to MP3/Opus chunks → write to the stream buffer → Broadcast wakes listeners. Playback is paced to real time.

WebRTC source (Go Live): browser getUserMedia → Opus over a WebRTC track → muxed to Ogg pages → written into the stream buffer like any other source.

Video (RTMP/SRT → HLS): H.264 + AAC/MP3 is demuxed, the HLS muxer emits one PES per frame with real 90 kHz PTS+DTS, ADTS audio, PCR per PES, and keyframe- aligned segments at /<mount>/playlist.m3u8.

Pipeline abstraction

Sources, transcoders, and relays share a common pipeline/stream abstraction so stats (BytesIn/BytesOut via atomics), health, and lifecycle behave uniformly regardless of where the bytes came from. Transcoded outputs are tied to a configured encoder that re-attaches on source resume, which is why they're exempt from silent-mount reaping (Transcoding).

What's intentionally not here

  • No in-process self-updater. Removed deliberately; distributions own the binary (Installation).
  • No shared-state clustering. Relay chaining works (node pulls from node), but multiple nodes don't share listener/stream state. Scale a single node vertically and to its bandwidth ceiling, or chain edges.
  • Multi-tenant is schema-level groundwork, not a finished feature.

Next: Developing · Deployment · Command Line and Signals

Clone this wiki locally