Eliminate the Icecast server dependency by building a native source receiver into the control plane.
DJs using BUTT and Mixxx need the Icecast source protocol to stream live audio. Currently, Grimnir requires a standalone Icecast server as a middleman: DJs push audio to Icecast, then the media engine pulls from it via souphttpsrc. This adds deployment complexity, an extra container, and an unnecessary network hop.
Build a harbor — an HTTP server built into the control plane that directly accepts Icecast-compatible source connections, decodes the audio to raw PCM, and feeds it into the existing encoder pipeline. The Icecast source protocol is just HTTP PUT with Basic auth — no proprietary code or licensing concerns.
BUTT/Mixxx
│ PUT /live.mp3
│ Authorization: Basic base64(source:<token>)
│ Content-Type: audio/mpeg
│ [continuous audio stream]
▼
┌─────────────────────────────────────┐
│ Harbor HTTP Server (:8088) │ ← new, in control plane
│ 1. Parse Basic auth → extract token│
│ 2. Resolve mount path → station │
│ 3. live.Service.HandleConnect() │
│ 4. Start GStreamer decoder process │
│ 5. io.Copy(decoderStdin, req.Body) │
└──────────┬──────────────────────────┘
│ raw S16LE PCM (stdout)
▼
┌──────────────────────────────────────┐
│ Existing Encoder Pipeline (fdsrc) │ ← already exists
│ fdsrc fd=0 → tee → lamemp3enc → HQ │
│ → lamemp3enc → LQ │
│ → opusenc → WebRTC │
└──────────────────────────────────────┘
-
Harbor lives in the control plane — has direct DB access (token validation), event bus (DJ connect/disconnect events), and playout director (encoder pipeline stdin). No new gRPC plumbing needed.
-
Audio piped via stdin/stdout — follows the existing
pcmCrossfadeSessionpattern: a GStreamer decoder reads compressed audio from stdin, outputs raw S16LE PCM to stdout, which is written to the encoder pipeline's stdin. No CGo, no named pipes, no TCP port allocation. -
Token-based auth via HTTP Basic — BUTT/Mixxx send
Authorization: Basic base64(source:password). We usesourceas username (standard convention, ignored) and the generated token as password. Same token system the web UI already uses. -
Both PUT and SOURCE methods — modern clients use HTTP PUT, legacy clients (older BUTT versions) use the proprietary
SOURCEmethod. We support both. SOURCE requires HTTP connection hijacking.
- Add
GRIMNIR_HARBOR_ENABLED,GRIMNIR_HARBOR_BIND,GRIMNIR_HARBOR_PORT,GRIMNIR_HARBOR_MAX_SOURCEStointernal/config/config.go - Create
internal/harbor/package withServerstruct,ListenAndServe(),Shutdown(ctx) - Wire into
internal/server/server.gostartup/shutdown
Files: internal/config/config.go, internal/harbor/server.go, internal/server/server.go
- Implement
handleSource(w, r)HTTP handler - Accept PUT and SOURCE methods (405 for others)
- Parse
Authorization: Basicheader, extract token from password field - Resolve mount from URL path (e.g.,
/live.mp3→ DB lookupMountby name) - For SOURCE method: hijack TCP connection, send
HTTP/1.0 200 OK\r\n\r\n - For PUT method: send 200, flush headers, read from
r.Body - Parse Ice-* metadata headers (
Ice-Name,Ice-Description,Ice-Genre,Ice-Bitrate,Content-Type,User-Agent) - Enforce max concurrent sources limit
- Track active connections in
conns map[string]*SourceConnection
Files: internal/harbor/server.go, internal/harbor/metadata.go
- Build decoder pipeline:
fdsrc fd=0 ! decodebin ! audioconvert ! audioresample ! audio/x-raw,format=S16LE,rate=44100,channels=2 ! fdsink fd=1 - Decoder reads compressed audio (MP3, Ogg, AAC, Opus) from stdin
- Decoder writes raw S16LE PCM to stdout
- Uses
decodebinfor automatic format detection - Goroutine pipes decoder stdout → encoder stdin
- Clean shutdown on context cancellation
Files: internal/harbor/decoder.go
- Add
Director.InjectLiveSource(ctx, stationID, mountID) (encoderIn io.WriteCloser, release func(), err error)- Pauses the
pcmCrossfadeSessionfor the mount (stops current decoder, keeps encoder pipeline running) - Returns the encoder's stdin
io.WriteCloserfor the harbor decoder to write into - Returns a
releasecallback that resumes automation when the DJ disconnects
- Pauses the
- Add
pcmCrossfadeSession.Pause()— stops current decoder, returnsencoderIn - Add
pcmCrossfadeSession.Resume()— re-enables the session for automation
Files: internal/playout/director.go, internal/playout/crossfade.go
- Wire the full flow: auth → connect → decode → inject → stream → disconnect → release
handleSourcecallslive.Service.HandleConnect()after auth succeeds (triggers priority handover)io.Copy(decoderStdin, audioSource)blocks until DJ disconnects (TCP close / EOF)- On disconnect: close decoder, call
live.Service.HandleDisconnect(), callrelease()to resume automation - Publish
EventDJConnect/EventDJDisconnectevents (already handled by live service) - Handle edge cases: DJ reconnect, encoder pipeline not running, station not active
Files: internal/harbor/server.go
- When harbor is enabled, show harbor host:port in Connection Info card instead of Icecast
- Update BUTT guide: Address=harbor host, Port=harbor port, Mount=/live.mp3, User=source, Password=token
- Update Mixxx guide similarly
- Show both harbor and Icecast info if both are configured
- Pass
HarborEnabled,HarborHost,HarborPortto template data
Files: internal/web/pages_live.go, internal/web/templates/pages/dashboard/live/dashboard.html
- Add harbor env vars to
docker-compose.ymlgrimnir service - Expose port 8088 in docker-compose.override.yml
- Add nginx proxy config example for harbor (proxy_buffering off, client_max_body_size 0)
- Icecast container becomes optional (can be removed from compose if harbor is enabled)
Files: docker-compose.yml, docker-compose.override.yml
- Unit tests for Basic auth parsing (valid, invalid, missing, malformed base64)
- Unit tests for mount path resolution
- Unit tests for SOURCE vs PUT method handling
- Unit tests for Ice-* header parsing
- Unit tests for max sources enforcement
- Integration test for connection lifecycle (connect → stream → disconnect)
Files: internal/harbor/server_test.go, internal/harbor/decoder_test.go
| Software | Method | Content-Type | Status |
|---|---|---|---|
| BUTT | PUT | audio/mpeg, audio/ogg | Supported |
| Mixxx | SOURCE/PUT | audio/mpeg, audio/ogg | Supported |
| OBS | PUT | audio/mpeg | Supported |
| ffmpeg | PUT | any audio/* | Supported |
| Liquidsoap | PUT | any audio/* | Supported |
GRIMNIR_HARBOR_ENABLED=true # Enable built-in source receiver
GRIMNIR_HARBOR_BIND=0.0.0.0 # Bind address
GRIMNIR_HARBOR_PORT=8088 # Listen port (DJs connect here)
GRIMNIR_HARBOR_MAX_SOURCES=10 # Max concurrent source connections- Enable harbor alongside Icecast (both work simultaneously)
- Update DJ connection guides to point at harbor
- Once verified, remove Icecast container from docker-compose
- Reclaim the port and resources