Part 10 of Running It For Real. Part 9 ran a Broadcastify feed; the other three destinations share its Manager and its counters but each bends the operating story. RdioScanner and OpenMHz are more uploads — different credentials, different endpoints, the same retry loop. Icecast is a different animal: a persistent live stream, not a per-call upload, which means it drops instead of retrying, reconnects itself across network blips, and holds a source password that must never end up in a config-audit dump. On a laptop you’d never tell them apart. Running them for real, the differences are the whole job. The wire formats live in Recording, Composition & Streaming (Parts 13–14); here we operate them.
TL;DR: RdioScanner wants a base
url(the backend appends/api/call-upload), anapi_key, and a numericsystem_id; OpenMHz wants anapi_keyand ashort_namethat becomes the upload path segment. Both are single POSTs riding the exactManager.sendWithRetryloop from Part 9 — same backoff, samesent/failedcounters. Icecast breaks the mould: it holds a background source connection open,Sendjust appends to a bounded queue and returnsnileven when it drops (so no pointless retries), and the connection reconnects on a fixed 5-second backoff after any blip. Itspasswordis a live secret; keep it out ofconfig.yaml.
Key takeaways
- RdioScanner’s URL is a base, not the endpoint. You configure the server
root; the backend appends
/api/call-upload. Getting this wrong is the most common “why is nothing uploading” misconfiguration. - OpenMHz identifies the system by a path segment. The
short_nameisn’t metadata — it’s baked into the upload URL, so a typo is a 404, not a rejection. - Icecast drops; it never retries.
Sendreturnsnileven on a full queue, because retrying live audio makes no sense — so its health shows up as dropped calls and reconnect log lines, not afailedcounter. - The source password is a live credential. Unlike an upload key checked per request, it authenticates a long-lived socket; treat it like the Broadcastify key and keep it in an environment file.
Cheat sheet
| Backend | Required config | Endpoint shape | Failure mode |
|---|---|---|---|
| RdioScanner | url, api_key, system_id |
<url>/api/call-upload |
retry via Manager, then failed++ |
| OpenMHz | api_key, short_name |
api.openmhz.com/<short_name>/upload |
retry via Manager, then failed++ |
| Icecast | host, port, password (+ mount) |
SOURCE <mount> socket |
drop call / reconnect; never failed++ |
| Shared retry | — | Manager.sendWithRetry |
2s→4s→8s, 4 tries |
| Health | — | Manager.Stats() |
sent / failed / dropped |
In this post
- RdioScanner — the base-URL trap and the credentials it checks.
- OpenMHz — why the short name is load-bearing in the URL.
- Icecast — a live stream that drops instead of retrying.
- Reconnect — how the source socket survives a network blip unattended.
- Monitoring three shapes — the counters read the same; the meaning differs.
RdioScanner: the base-URL that isn’t the endpoint
RdioScanner is a single multipart/form-data POST — the field vocabulary
(key, system, dateTime, talkgroup, source, frequency, audio) we
covered in the streaming series. The operating gotcha is the URL. You configure
the server root, and the backend appends the API path itself:
// internal/broadcast/rdioscanner.go (shape)
func NewRdioScanner(cfg RdioScannerConfig, hc *http.Client) (Backend, error) {
if cfg.URL == "" {
return nil, errors.New("broadcast/rdioscanner: url is required")
}
if cfg.APIKey == "" {
return nil, errors.New("broadcast/rdioscanner: api_key is required")
}
if cfg.SystemID == 0 {
return nil, errors.New("broadcast/rdioscanner: system_id is required")
}
return &rdioScannerBackend{
endpoint: strings.TrimRight(cfg.URL, "/") + "/api/call-upload",
// …apiKey, systemID, loc
}, nil
}
So url: https://scanner.example is correct and
url: https://scanner.example/api/call-upload is wrong — you’d get
.../api/call-upload/api/call-upload, a 404, and a feed that “authenticates fine”
but never lands a call. Because a 404 comes back as a non-2xx, Send returns an
error, and — exactly like Part 9 — the Manager retries four times and then ticks
failed["rdioscanner"]. The dateTime field is stamped with the operator’s
configured display timezone (an offset-bearing RFC3339), so a call reads with one
consistent wall-clock across RdioScanner, the webhook, and the call log. Three
credentials, one URL that has to be a root: that’s the whole RdioScanner
operating surface.
OpenMHz: the short name is in the URL
OpenMHz needs only two secrets — an api_key and a short_name — but the
short_name is doing more work than it looks. It isn’t a metadata field; it’s a
path segment in the upload URL:
// internal/broadcast/openmhz.go (shape)
func NewOpenMHz(cfg OpenMHzConfig, hc *http.Client) (Backend, error) {
if cfg.APIKey == "" {
return nil, errors.New("broadcast/openmhz: api_key is required")
}
if cfg.ShortName == "" {
return nil, errors.New("broadcast/openmhz: short_name is required")
}
return &openMHzBackend{
endpoint: strings.TrimRight(base, "/") + "/" + cfg.ShortName + "/upload",
// …base defaults to https://api.openmhz.com
}, nil
}
The operational consequence: a wrong short_name doesn’t produce an auth
rejection — it produces a 404 or a wrong-system upload, because the request
went to a different URL entirely. So when an OpenMHz feed’s failed counter
climbs but the api_key is right, suspect the short name before the key. The
upload body carries source_list and patch_list JSON arrays (OpenMHz expects
them even for a single granting unit); those are a wire-format detail, covered in
the streaming series, that you never touch operationally. Same Manager, same retry
loop, same counters — the only new operating fact is that OpenMHz encodes system
identity in the path, so path mistakes masquerade as system problems.
Icecast: a live stream, not an upload
Here the operating model changes completely. RdioScanner and OpenMHz upload a
finished call; Icecast maintains a continuous live stream a listener tunes into
at any moment. That single difference drives every operational property. There is
no per-call request — a background goroutine holds the source connection open, and
Send merely appends the call’s MP3 onto a bounded queue:
// internal/broadcast/icecast.go (shape)
func (b *icecastBackend) Send(_ context.Context, c *Call) error {
audio, err := c.MP3()
if err != nil {
return fmt.Errorf("%s: encode mp3: %w", b.name, err)
}
b.mu.Lock()
defer b.mu.Unlock()
if len(b.queue)+len(audio) > b.maxQueue {
b.log.Warn("broadcast: icecast queue full, dropping call",
"system", c.System, "tg", c.Talkgroup)
return nil // best-effort: drop, DON'T fail — no pointless retry
}
b.queue = append(b.queue, audio...)
return nil
}
Read that return nil carefully — it’s the crux of operating Icecast. A dropped
call returns success, because returning an error would trigger the Manager’s
retry loop, and retrying a chunk of live audio you’re already three seconds past
is meaningless. So Icecast’s health never shows up in the failed counter. Its
failure signal is different: the broadcast: icecast queue full, dropping call
warning, which means the source connection has stalled and the queue (capped at
about two minutes of audio) has backed up. You monitor Icecast by watching for
those drop warnings and the reconnect lines below — not by watching failed,
which for Icecast stays zero even when the stream is down.
How that principle shaped the Go code
- Best-effort is encoded in the return type, not a comment.
Sendreturningnilon a drop is what tells the Manager “don’t retry” — the operating policy lives in the signature, so the shared retry loop needs no special case for Icecast. - The queue is bounded, so a stalled stream can’t OOM the daemon.
maxQueueisbytesPerSec * 120— about two minutes — and past it the oldest audio is simply not accepted. A wedged Icecast server degrades to dropped calls, never to unbounded memory growth. - Close is cooperative.
Manager.Closetype-asserts each backend for aClose() errorand calls it; Icecast’s cancels the source goroutine and waits (bounded) for it to unwind, so a config reload doesn’t leak a socket.
Reconnect: surviving a blip unattended
The reason Icecast can be a 24/7 destination and not a babysitting chore is that
its source connection heals itself. The run loop dials, handshakes, paces
audio, and on any failure waits a fixed backoff and starts over:
// internal/broadcast/icecast.go (shape)
func (b *icecastBackend) run(ctx context.Context) {
defer close(b.done)
for {
if ctx.Err() != nil {
return
}
if err := b.runStream(ctx); err != nil {
b.log.Warn("broadcast: icecast source disconnected",
"backend", b.name, "err", err)
}
select {
case <-ctx.Done():
return
case <-time.After(icecastReconnect): // fixed 5s
}
}
}
Note the backoff is fixed at 5 seconds, not exponential like the upload retry —
because a live stream wants to be back up fast, and a source socket that’s down for
a minute means a minute of dead air for every listener. The trade-off is
deliberate: uploads back off to avoid hammering a struggling API; the stream
reconnects promptly to minimise the listener’s silence. Between calls the pacer
keeps the socket alive by feeding pre-encoded silence (the streaming series covers
that mechanism), so a quiet system doesn’t get its source timed out. Operationally,
a healthy Icecast feed logs one icecast source connected line at startup and then
goes quiet; a run of source disconnected warnings every ~5 seconds is your signal
the server or network is down.
Monitoring three shapes with one set of counters
All four backends report through the same Manager.Stats() and the same
GET /api/v1/broadcast, but the numbers mean different things per backend, and
knowing that is the operating skill:
- RdioScanner / OpenMHz behave like Broadcastify:
sentclimbs,failedshould stay flat. A climbingfailedis a bad credential, a wrong base URL (RdioScanner), or a wrong short name (OpenMHz). - Icecast never touches
failed. Itssentincrements on every successful queue append (which is almost always), sosentclimbing tells you calls are arriving at the backend, not that they reached listeners. Its real health is in the log: drop warnings and reconnect lines. droppedis Manager-wide, not per backend — it counts calls shed because the Manager’s own job queue was full, i.e. some backend is chronically slow and the daemon is protecting the recorder. A risingdroppedwith Icecast configured usually means the Icecast server is the slow one.
The practical alerting rule: watch failed rate for the upload backends, watch for
Icecast drop/reconnect log patterns, and watch Manager dropped as the
whole-subsystem backpressure signal. Three shapes, one dashboard.
Where this goes next
The four audio destinations all deliver a finished call. Part 11 turns to a different kind of outbound feed: the grant webhook, which fires a JSON POST the instant a control-channel grant is decoded — before any audio exists — for home automation, dashboards, and alerting. Same retry discipline, same bounded-queue-drops-under-load posture you’ve now seen three times, but a control-channel event rather than a recording.
FAQ
My RdioScanner feed authenticates but never uploads — why?
Almost always the url. Configure the server root (e.g. https://scanner.example);
the backend appends /api/call-upload itself. If you included the path, you get a
doubled /api/call-upload/api/call-upload 404 that retries and then increments
failed. Strip the path from your config.
OpenMHz failed is climbing but my api_key is correct — what else?
Check the short_name. It’s a path segment in the upload URL, not a metadata field,
so a wrong short name sends the POST to the wrong (or nonexistent) URL — a 404 or a
wrong-system upload rather than an auth error. Fix the short name before touching the
key.
Why doesn’t Icecast show failures in the counters?
Because it’s a live stream, not an upload. Send returns nil even when it drops a
call, so the Manager never retries and never counts a failure — retrying live audio
you’re already past is meaningless. Icecast health lives in the log: queue full,
dropping call warnings and source disconnected/connected reconnect lines.
Where does the Icecast source password go?
Treat it like the Broadcastify key. It authenticates a long-lived source socket, so
it’s a live credential — keep it in an environment file the systemd unit references
rather than inline in config.yaml, and out of any config-audit surface.
How fast does an Icecast feed recover from a network blip?
The source goroutine reconnects on a fixed 5-second backoff (not exponential),
re-running the dial and handshake until the stream is back. A brief blip costs a few
seconds of dead air; a sustained outage logs a source disconnected warning roughly
every 5 seconds until the server returns.
Series navigation
Part 10 of 14 · ← Part 9: Broadcast Backends I — Broadcastify · Next → Part 11: Grant Webhooks & External Integrations