A fork of mairlist/streammonitor (by Torben Weibert / mairlist) that monitors multiple Icecast/Shoutcast MP3 streams from a single container instead of one container per stream, so on-air status for several stations can be centralized on one host rather than deployed per client.
Licensed AGPL-3.0, same as upstream — see LICENSE.
For each configured stream, streammonitor spawns curl <url> | lame --decode, decodes the MP3 to raw PCM, and checks whether the samples stay
below SILENCE_THRESHOLD (i.e. dead air) or the process dies outright
(i.e. the stream is offline). It exposes the result of all streams as JSON
over HTTP so something else — a dashboard, monitor-ntfy/ in this repo, a
cron job — can poll it and act on the result.
It does not send any notifications itself. If you want alerts, run
monitor-ntfy/ alongside it (see Alerting below) — it can push
to ntfy, a generic JSON webhook, and email via either Resend or SMTP. Every
channel can be configured from the dashboard once it's running,
from .env, or a mix of both.
Requires Docker and Docker Compose.
-
Clone this repo and
cdinto it. -
Copy the example stream list and point it at your own streams:
cp config/streams.example.yml config/streams.yml
Edit
config/streams.yml, e.g.:streams: - name: station-a url: https://icecast.example.com/station-a - name: station-b url: https://icecast.example.com/station-b
-
Start it:
docker compose up -d --build
-
Open
http://localhost:8000/dashboardin a browser. You should see one card per stream. A freshly started stream shows red ("offline") for the first second or two untilcurl/lameconnect — that's normal. -
(Optional) Set up alerting, either way round:
- From the dashboard — in the Notifications panel, fill in an ntfy topic URL, a webhook URL, and/or the email fields, then click Save & send test and confirm you actually receive it (for ntfy, install the ntfy app and subscribe to your topic first).
- From
.env—cp .env.example .env, fill in what you need, anddocker compose up -d. Handy for keeping an SMTP password or Resend API key out of the UI, and for provisioning a host from config.
See Alerting for what each channel does and how the two sources combine.
If you skip step 5, monitor-ntfy still starts but sends nothing until a
channel is configured — streammonitor itself works fine either way,
alerting is just inert until then.
Everything lives in config/, which both containers share as a mounted
volume (streammonitor can write to it, monitor-ntfy only reads it):
config/streams.yml— the streams to monitor (see Quick start).config/notify.json— notification settings (ntfy, webhook, email), written by the dashboard's Notifications panel. Not required to exist; alerting is just inert until something's saved there or set in.env.
Notification credentials come from the environment, and docker compose
reads a .env file next to docker-compose.yml automatically — so
cp .env.example .env and edit that rather than putting secrets in the
compose file. See Alerting for the full list.
Plus these environment variables, settable in docker-compose.yml:
-
STREAMS_CONFIG(optional, default/app/config/streams.yml) — path inside the container to the YAML file listing the streams to monitor. You normally don't need to change this;docker-compose.ymlalready mountsconfig/to/app/config. The format is:streams: - name: station-a url: https://icecast.example.com/station-a - name: station-b url: https://icecast.example.com/station-b
nameis used to key the stream in the API (GET /:name) and in per-stream ntfy alert state — keep it stable once alerts are wired up, renaming it will look like the old stream went offline and a new one came online. -
SILENCE_THRESHOLD(optional, default-20dBFS) — audio level below which a stream counts as silent. Applies to all streams; a more negative number (e.g.-30) is a more lenient/quieter threshold. -
CHECK_INTERVAL(optional, default1000ms) — how often each stream's decoded audio is sampled for silence. -
HTTP_PORT(optional, default8000) — port the status API listens on inside the container. Change the left-hand side of theports:mapping indocker-compose.ymlto expose it on a different host port.
-
GET /— array of status objects, one per configured stream, each shaped like upstream's single-stream response plus anamefield:[ { "name": "station-a", "url": "https://icecast.example.com/station-a", "silenceThreshold": -20, "status": "OK", "now": "2026-08-24T12:53:22.091Z", "onlineSince": "2026-08-24T12:53:05.629Z", "onlineDuration": 16, "offlineSince": null, "offlineDuration": 0, "silenceSince": "2026-08-24T12:53:21.903Z", "silenceDuration": 0 } ]statusis"OK"once the stream has actually produced decoded audio and stays that way while it keeps flowing;"ERROR"before that first audio arrives or after the decoder process dies (stream unreachable, connection dropped, URL doesn't serve audio, etc.) — it does not by itself mean silence; checksilenceDurationfor that. -
GET /:name— single stream's status object, 404 ifnameisn't configured. -
GET /:name/value/:key— single field from one stream's status, as upstream's/value/:keydid. E.g.GET /station-a/value/silenceDuration.
GET /dashboard is a small self-contained web UI (no build step, no
external assets) that:
- Shows a live-refreshing (every 3s) card per stream, colored by the same
ok/silence/offline classification
monitor-ntfyuses for alerts. - Has a Notifications panel to set an ntfy topic URL, a webhook URL, and/or email (Resend or SMTP), with a Save & send test button that fires one real notification through every configured channel immediately so you can confirm it actually arrives, rather than waiting for a real stream failure.
Anything already set in the environment shows up in the panel as a
greyed-out placeholder and stays in force unless you type over it. Secrets
(RESEND_API_KEY, SMTP_PASS) are never sent to the browser — a saved one
shows as "saved - leave blank to keep it", so you can edit the port without
retyping the password.
Settings are exposed as a small JSON API too, if you want to script it:
GET /api/settings— current{ntfyUrl, webhookUrl, email, envDefaults}.emailis what's saved inconfig/notify.json, with secrets replaced by the booleanshasResendApiKey/hasSmtpPass;envDefaultsmirrors the same shape for what the environment supplies.POST /api/settings— body{ntfyUrl, webhookUrl, email}; omit or send an empty string for a field to leave it to the environment. URLs must behttp://orhttps://.emailtakes{provider, from, to, resendApiKey, smtpHost, smtpPort, smtpSecure, smtpUser, smtpPass}—provideris"resend","smtp", or"off", and a blank secret keeps whatever is already saved. Overwritesconfig/notify.jsonin full (not a merge), and responds with the same shape asGET. A config that can't work (no recipients, SMTP without a host, and so on) is rejected with 400 and an explanation rather than saved.POST /api/settings/test— sends one test notification through each currently-configured channel; responds with per-channel{ok, statusCode}or{ok: false, error}, whereerrorcarries the provider's own message when there is one.
This API has no authentication, same as the rest of streammonitor — see the security note under Deploying.
monitor-ntfy/ is a separate small container that polls GET / on the
main monitor and, per stream, when it transitions between OK and a bad
state (offline or silence >10s) — and again on recovery — pushes:
- an ntfy push notification, if an ntfy topic URL is configured,
- a generic JSON webhook POST, if a webhook URL is configured. The body is
{name, event, state, message, text, timestamp}—textduplicatesmessagesince that's the field Slack/Discord/Mattermost incoming webhooks look for by default, so simple integrations need no extra templating, and/or - a plain-text email, if an email provider is configured. Subject is
Streammonitor <stream>: ALERTorStreammonitor <stream>: recovered, body is the same one-line message as the other channels.
All configured channels fire for the same transition; they're not either/or.
State is tracked per stream under STATE_DIR so a restart doesn't re-fire
alerts for streams that were already broken.
Pick one of two providers with EMAIL_PROVIDER (or the dashboard's
Provider dropdown):
resend— POSTs to the Resend HTTP API withRESEND_API_KEY. Nothing to run yourself and no SMTP ports to get through, but the sending domain has to be verified in Resend first (useonboarding@resend.devas the sender while testing).smtp— talks to any SMTP server: your own relay, a corporate Exchange/Microsoft 365 host, Postmark, Mailgun, whatever.SMTP_PORTdefaults to587with opportunistic STARTTLS; port465(orSMTP_SECURE=true) means implicit TLS. LeaveSMTP_USER/SMTP_PASSunset for a relay that doesn't want authentication.
EMAIL_TO (and the dashboard's To field) takes several recipients,
separated by commas or semicolons — so an Outlook-style
a@example.com; b@example.com paste works, as does
Ops <a@example.com>; On Call <b@example.com>. A JSON array works too when
config/notify.json is written by hand.
Two sources, and they combine field by field:
- The environment —
docker-compose.yml, or (better, for secrets) a.envfile beside it, whichdocker composereads automatically. Start from.env.example. config/notify.json, written by the dashboard's Notifications panel.monitor-ntfypicks changes up on its next poll (up toPOLL_INTERVALlater).
config/notify.json wins wherever it sets a value, and the environment
fills in the rest — so you can keep SMTP_PASS in .env while still
changing recipients from the dashboard. Choosing Off in the dashboard's
Provider dropdown records an explicit "off" that beats an EMAIL_PROVIDER
default from the environment, and switching email off also drops any
credentials stored in notify.json.
To actually receive ntfy alerts, install the ntfy app (or use a browser) and subscribe to your topic. Anyone who knows an ntfy topic name can subscribe to it or publish to it, so treat the topic name like a shared secret — pick something unguessable, or self-host ntfy.
Environment variables for monitor-ntfy:
STATUS_URL(required) — URL of the main streammonitor'sGET /endpoint, e.g.http://streammonitor:8000/when both run under the same Compose project.NTFY_URL(optional) — full ntfy topic URL to POST alerts to.WEBHOOK_URL(optional) — full webhook URL to POST alerts to.EMAIL_PROVIDER(optional) —resend,smtp, or unset for no email.EMAIL_FROM(optional) — sender address.Name <addr@example.com>also works, and wins overEMAIL_FROM_NAME.EMAIL_FROM_NAME(optional) — display name shown in the recipient's inbox instead of a barenoreply@address, e.g.Streammonitor Alerts.EMAIL_TO(optional) — one or more recipients, separated by commas or semicolons.Name <addr@example.com>entries are kept intact.RESEND_API_KEY(optional) — required whenEMAIL_PROVIDER=resend.SMTP_HOST,SMTP_PORT,SMTP_SECURE,SMTP_USER,SMTP_PASS(optional) — used whenEMAIL_PROVIDER=smtp; onlySMTP_HOSTis required, see above for the defaults.STATE_DIR(optional, default/var/lib/streammonitor-monitor) — where per-stream last-known-state files are kept. Should be a persistent volume (already set up indocker-compose.yml) so restarts don't re-notify for streams that were already down.POLL_INTERVAL(optional, default60seconds) — how often to poll the main monitor.
The channel variables are also read by the main streammonitor container,
since the dashboard's Save & send test button sends through the same
channels — docker-compose.yml already passes them to both.
If no channel is configured anywhere, state changes are still logged but nothing is sent. An email channel that's switched on but unusable (no recipients, say) is logged as an error each poll rather than failing quietly.
docker-compose.yml here is a starting point covering both containers:
docker compose up -d --buildThings you'll likely want to change for a real deployment:
config/streams.yml— your actual stations (see Quick start).- Notification settings — set them from the dashboard or in
.env(cp .env.example .env), not indocker-compose.ymlitself, which is committed. ports— which host port the status API is exposed on, if 8000 conflicts with something else.- Put something in front of port 8000 (reverse proxy, firewall rule) if
the host is reachable from the internet — the status API, dashboard, and
/api/settingsall have no authentication. Anyone who can reach port 8000 can read your stream URLs, repoint your notification webhook or SMTP server (which could be used to spam an arbitrary internal or external host, SSRF-adjacent), and send mail through whatever email credentials are configured — don't expose this port publicly without a proxy that adds auth./api/settingsnever hands back a stored API key or SMTP password, but it will happily use them.
AGPL-3.0, same as upstream — see LICENSE. Forked from mairlist/streammonitor by Torben Weibert / mairlist.