- Go 94.4%
- Shell 3.2%
- Python 2.4%
| Filename | Latest commit message | Latest commit date |
|---|---|---|
|
Some checks failed
test / test (push) Has been cancelled
Go stamps vcs.revision into the binary, so a docs-only commit builds to a different file than the one before it. Measured while cutting the first release: identical code at |
||
| .forgejo/workflows | ||
| cmd/sxm-stream | ||
| internal | ||
| scripts | ||
| .env.example | ||
| .gitignore | ||
| CONTEXTMAP.md | ||
| CURRENT.md | ||
| DECISIONS.md | ||
| go.mod | ||
| README.md | ||
sxm-stream-go
A SiriusXM streaming proxy in Go. It authenticates against SiriusXM, fetches the
encrypted HLS playlist for a channel, decrypts the AES-128-CBC segments in memory, and
serves one continuous raw AAC stream with ICY metadata at /{channelId}.stream — the
single thing moOde Audio's player asks for.
It is one static binary with no cgo and no external Go dependencies, aimed at a Raspberry Pi running moOde. It authenticates and decrypts by itself, so every room keeps playing when the rest of the house is off.
Why this exists, and why Go
The Python project sxm-client came first, does the same job, and is still the reference
this port is measured against. Four things made it the wrong tool here:
- Multi-room is the case it cannot win. moOde's MPD/ALSA pipeline will not play SiriusXM's encrypted HLS when the stream is shared between rooms. Single-room worked; multi-room is the entire reason for the rewrite.
- Bitrate is not selectable. It takes the first variant from the master playlist,
which is always 256k, so a thin-link room cannot be served at 32k. Its
--qualityflag looks like it would, and does not — that sets the DVR window size. - Per-listener settings are impossible. Delay and window live on a shared client object, so two listeners on one process overwrite each other's settings.
- It needs Python. A venv with dependencies, and
.envhas to be sourced before starting. Fine on a laptop; a liability on a Pi in a closet.
Go buys the deployment, and that is the whole argument for it: CGO_ENABLED=0 statically
links everything, so the artefact is one file that runs on any Pi with nothing installed,
and per-connection state is natural, so one process serves every room from one session.
Parity with the Python implementation remains the standard wherever it is cheap to check — decryption, playlist resolution, ICY framing — and it is verified rather than asserted: identical ciphertext and plaintext hashes for the same segment, the same ADTS offset, and bit-identical PCM at a constant, non-drifting offset. Where this project diverges, the divergence and its measurement are recorded in DECISIONS.md.
What this adds over the Python proxy
?delay=and?bitrate=per connection, so one process serves every room at its own delay and bitrate. All four bitrates work: 256k (AAC-LC) and 96k/64k/32k (HE-AAC), which Python cannot select at all.livemeans 30 s behind the live edge, and that is a floor rather than a default. The CDN publishes a segment 11-20 s after it starts, so a smaller delay has nothing to deliver; anything shorter is raised to 30 s and logged.lastsongstarts the current track from the top, choosing its window from the track's age. Between songs it plays the track that just ended and says so instead of failing.- The DVR window follows from the delay, never set by hand: a band table
(
internal/server/bands.go) decides which playlist to read and when to refill. Python's separate window flag is what produced its worst bug — a five-hour window played from segment one. - Titles are looked up per segment, so the ICY title describes the audio being sent,
not the live edge.
lastsonglabels its first segment from the plan it chose. - CDN failover: a failed playlist fetch is retried once against the secondary host and sticks there, with segments following the same host.
- A passive health endpoint at
/channels/, and a--probeone-shot that resolves exactly what a listener on a given URL would get. - An offline test suite and CI:
go test ./...needs no network and no credentials.
Deliberately omitted
/{id}.m3u8,/{id}.aacand/key/1. The Python handler serves them for browser clients; moOde never asks for anything but the continuous stream, so they are not planned here.- Deep delays. Anything past a few minutes reads the 1845-segment, ~205 KB LARGE playlist every 8 s pass — about 205 kbps beside ~256 kbps of audio. The fix (a poll interval computed per band) costs cushion and buys nothing in daily use, so it is declined in DECISIONS.md, with what would reopen it.
- A fatter initial buffer. Handing over the published backlog at connect is simply a
larger delay, and
--delayis already that knob. - Album art. moOde never reads the ICY
StreamUrl, so nothing is put there.
Build
Go 1.22 or newer. Nothing else.
cp .env.example .env # then fill in SXM_USERNAME / SXM_PASSWORD
go build -o sxm-stream ./cmd/sxm-stream/
Where gofmt is not on PATH, use $(go env GOROOT)/bin/gofmt.
Run
.env is read from the working directory (then ../.env, ../../.env). There is no
interactive prompt: without credentials the process exits with
credentials: SXM_USERNAME and SXM_PASSWORD not found in .env, so start it from the
directory that holds .env.
./sxm-stream --host 127.0.0.1 --port 9999 # live, 30s behind the edge
./sxm-stream --host 0.0.0.0 --port 9999 # reachable by the rooms
./sxm-stream --host 0.0.0.0 --port 9999 --delay lastsong # start the track from the top
./sxm-stream --host 0.0.0.0 --port 9999 --delay 3m --bitrate 64k
--host 0.0.0.0 is what lets the rooms connect; the default 127.0.0.1 accepts only the
Pi's own connections.
Then, in another shell:
./sxm-stream --probe 9139 # auth -> channel -> playlist -> decrypt, then exit
curl -sI http://127.0.0.1:9999/9139.stream # expect audio/aac + icy-metaint: 65536
curl -s http://127.0.0.1:9999/channels/ # health, including the live listener count
ffmpeg -v warning -t 70 -i http://127.0.0.1:9999/9139.stream -f null - # silence = good
--probe is the cheap end-to-end check — it authenticates, resolves the playlist,
decrypts one real segment and prints what it found, with no server or player involved:
./sxm-stream --probe '9139?delay=2m&bitrate=32k'
./sxm-stream --probe 'http://127.0.0.1:9999/9139.stream?delay=lastsong'
How to read its output: %16 = 0 means the segment arrived whole; adts offset should be
a few dozen bytes; adts frames is ~42 per second of audio at 256k and ~21 per second at
the HE-AAC bitrates; age on the start line should match the delay you asked for. A
warning line with window=LARGE means you asked for more history than the CDN keeps
(~5 h).
Hand ffmpeg the URL, not a curl capture: the stream interleaves ICY blocks every
64 KB, and ffmpeg only strips those when it reads the stream itself — a raw capture looks
like corrupt audio. VLC and moOde both display the ICY title natively; treat either as a
listener's check rather than a correctness gate, since VLC will happily play a stream
ffmpeg flags. (Two VLC lines that are not problems: ICY metaint=65536, and faad's
one-off decoded zero sample at startup, which plain ffmpeg-produced .aac files get
too.)
Endpoints
| Path | Meaning |
|---|---|
/{id}.stream |
the continuous decrypted AAC stream with ICY metadata |
/{id}.stream?delay= |
per-connection delay: live (default), lastsong, 90s, 3m, 1h30m |
/{id}.stream?bitrate= |
per-connection bitrate: 256k (default), 96k, 64k, 32k |
/channels/ |
health: listener count, last good playlist fetch, most recent error |
A bad ?delay= or ?bitrate= is a 400 naming what is accepted — never a silent
fallback, because someone who asked for 32k must not receive 256k.
{id} is the channel's SXM id, and not always a number: 9139 is Phish Radio, 9506 is
numeric, and the Grateful Dead channel is gratefuldead. The API's channelId is a
string and the path accepts any \w+, so a slug works wherever a number does. A name
that is not a real channelId fails with a 503 and a stream: get channel … not found
line.
/channels/ is deliberately passive: it reports what the stream loop last saw and makes
no upstream call, so a broken network cannot make the health check itself fail.
degraded means listeners are attached with no successful playlist fetch in the last
minute.
Flags
| Flag | Meaning |
|---|---|
--host, --port |
bind address (default 127.0.0.1:9999) |
--delay |
live (default, 30 s behind the edge), lastsong, or a duration (90s, 3m, 1h30m). 30 s is the floor: anything shorter is raised to it and logged |
--bitrate |
256k (default), 96k, 64k, 32k; anything else errors |
--probe <target> |
one-shot end-to-end check, then exit: 9139, 9139?delay=2m&bitrate=32k, or a full URL |
Precedence, last one wins: ?delay=/?bitrate= on the URL, an explicit flag, then
SXM_DELAY/SXM_BITRATE in .env, then the built-in default. A malformed value stops
startup and names the value rather than falling back. There is no flag for the DVR window
or the refill policy — both follow from the delay.
Reading the log
One line needs interpretation. WARN buffer trough means the audio a listener held fell
below what that delay band's delivery can be held to — a fill that arrived late or not at
all. The boundary is per band, because a healthy trough sits at the band's own floor by
construction:
| delay band | healthy trough is around | WARN below |
|---|---|---|
| ≤ 1 m (fill at once) | delay − lag + one segment |
delay − lag |
| 1 m – 2 m | 30 s | 20 s |
| 2 m – 5 m and deeper | 1 m | 50 s |
lag is the connection's own worst publication lag — the age_at_first_sight value on
the new segment lines — so the first row calibrates itself instead of assuming the
CDN's 11-20 s. At live a trough of 20-28 s is normal and no WARN is possible; one
appearing there means a fill genuinely did not land.
Install on a Pi
Target linux/arm64 for a 64-bit Pi OS (uname -m → aarch64); for 32-bit use
GOARCH=arm GOARM=7 and a different output name. /sxm-stream-* is gitignored.
Fetch a published build
scripts/release.sh publishes the arm64 binary as a release asset on
forgejo.richelson.cloud/mercury/sxm-stream-go, so the Pi pulls it rather than being
handed it over scp. The repo is public, so the Pi needs no credential to download:
curl -fL https://forgejo.richelson.cloud/mercury/sxm-stream-go/releases/download/latest/sxm-stream-arm64 \
-o sxm-stream.new
/releases/download/latest/<asset> is the floating alias, and it is Forgejo's form, not
GitHub's: /releases/latest/download/<asset> is a 404 here. latest is a tag the server
reserves for the newest release and every other unknown tag is a 404, so curl -f fails
loudly instead of writing an HTML error page into the binary's place. Swap the tag for a
pinned one (.../download/v2026.10.23/sxm-stream-arm64) when the deploy wants to be
reproducible rather than current — the release body carries the sha256 for exactly that.
Verify the transfer, then swap, then restart. The hash is not decoration: it is the first
link in the && chain, so a truncated download or a captive portal's login page stops
before the mv:
echo "<sha256 from the release page> sxm-stream.new" | sha256sum -c
chmod +x sxm-stream.new && mv sxm-stream.new sxm-stream
sudo systemctl restart sxm-stream && systemctl is-active sxm-stream
.env is not in any release and never should be — it holds the SiriusXM credentials. It
is copied once, by hand, and scp does not preserve mode, so the chmod 600 after it is
not decoration; without it the credentials land mode 644 inside your home directory.
mv rather than cp — here and on every upgrade — because a rename never overwrites the
inode a running process is executing.
Build it yourself
GOOS=linux GOARCH=arm64 CGO_ENABLED=0 go build -trimpath -ldflags="-s -w" -o sxm-stream-arm64 ./cmd/sxm-stream/
file sxm-stream-arm64 # ELF 64-bit ARM aarch64, statically linked
readelf -d sxm-stream-arm64 # "There is no dynamic section in this file."
-trimpath is what release.sh uses, so a hand-build from the same clean commit hashes
the same. It will not match the release if your tree is dirty or you are on a different
commit: Go stamps vcs.revision into the binary, so the hash is stable per commit rather
than across them. That is the useful direction — a deployed binary names the commit it came
from — but it is why the hash in the release notes is not a constant.
Publishing a release
scripts/release.sh # tags vYYYY.MM.DD, or vYYYY.MM.DD.1 on a second run
scripts/release.sh v2026.11.01 # an explicit tag
It builds the arm64 binary, tags HEAD, creates the release, uploads the asset, and then
re-downloads it unauthenticated and compares the hash — so the check that the Pi will
pass is the check that ran, not a local curl that happened to have a token. It reads
FORGEJO_TOKEN from the environment or from the Hermes profile .env, and never prints
it. A dirty tree is a warning rather than a refusal, because a hotfix from a dirty tree is
a real thing to want — but the warning lists the files the tag will not reproduce.
The unit. WorkingDirectory is how .env is found, and --host 0.0.0.0 is what lets
the rooms connect:
[Unit]
Description=SiriusXM stream proxy
After=network-online.target
Requires=network-online.target
[Service]
Type=simple
WorkingDirectory=/home/pi/sxm-stream
ExecStart=/home/pi/sxm-stream/sxm-stream --host 0.0.0.0 --port 9999
Restart=always
RestartSec=10
StandardOutput=journal
StandardError=journal
[Install]
WantedBy=multi-user.target
sudo systemctl daemon-reload && sudo systemctl enable --now sxm-stream
journalctl -u sxm-stream -f
Three notes on that file. There is deliberately no --delay/--bitrate in ExecStart —
those belong in .env, so the unit stays generic. There is no User=, so the service
runs as root; add User=pi if you would rather it did not (the binary only reads
~/sxm-stream/.env). Restart=always with RestartSec=10 means a failed bind or a
lapsed session recovers on its own.
Then point the moOde players at it — each station URL is independent, so one process serves every room:
http://<pi>:9999/9139.stream # the .env defaults
http://<pi>:9999/9139.stream?bitrate=32k # a room on a thin link
http://<pi>:9999/9139.stream?delay=lastsong # this room restarts the track
Check the moOde version before expecting per-track titles (cat /var/local/www/VERSION):
the radio_track_covers preference and the iTunes lookup path only exist on the v10+
develop line; on v9.x the ICY StreamTitle is as far as it gets.
Upgrading is the same three steps against whichever artefact you have: curl -fL the
release (or build it), verify the hash, mv it over sxm-stream, then
sudo systemctl restart sxm-stream. systemd does not re-exec on binary replacement —
the running process keeps the old image until restarted. And do not
run --probe while the service is up: it is a second concurrent login and can invalidate
the service's own session (API code 204).
One listener, however many rooms
Measured: four rooms playing the Pi's proxy for 46 minutes held one connection
(peer=127.0.0.1), with nothing at all from the other rooms' Pis — the source Pi's moOde
pulls the stream once and re-shares it to the rooms over its own multi-room transport.
Count it rather than assume it:
journalctl -u sxm-stream --since today | grep -o 'peer=[0-9.]*' | sort | uniq -c
One host and one connect line is the fan-out case. Several connect lines from one host
means one pull per room instead, so N players on the same URL cost N copies of the same
segments — fine for a few rooms, but check the account's concurrent-stream limit before
scaling up. Variant URL resolution is shared either way, cached per
(channel, window, bitrate).
Tests
go test ./... # offline and deterministic; this is what CI runs
go test -tags live ./... # live diagnostics; needs SXM_USERNAME / SXM_PASSWORD in the environment
go vet -tags live ./... # keeps the live-tagged files compiling
CI (.forgejo/workflows/test.yml) runs gofmt, build, vet, the tests, and the arm64
cross-build, all offline.
scripts/stream_probe.py is the measurement instrument for the cushion: it connects
like a player and prints arrival-by-arrival cushion — the trough, not the peak — which
is how the numbers under "Verified on hardware" in CURRENT.md were taken.
Read these next
CURRENT.md— what is true now and what is left, deliberately shortDECISIONS.md— why it is built this way, with the measurement behind each call; read it before changing behaviourCONTEXTMAP.md— where everything lives