SiriusXM streaming proxy in Go — decrypts HLS AAC segments and serves a continuous stream for moOde Audio multi-room
  • Go 94.4%
  • Shell 3.2%
  • Python 2.4%
Find a file
Repository files (latest commit first)
Filename Latest commit message Latest commit date
Caleb Richelson 7a99a90721
Some checks failed
test / test (push) Has been cancelled
docs: the release hash is per-commit, and note it where it bites
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 cfbb0e0 and 34a8af6 hashed 60060c39 and fb2354f6. That is
useful for identifying a deployed binary and a trap for anyone expecting a
recorded hash to reproduce, so it is stated in the build docs rather than left
to be rediscovered, along with why a dirty tree poisons the hash the same way.

The README's hand-build line gains -trimpath so it agrees with release.sh.
2026-10-23 23:12:54 -04:00
.forgejo/workflows cleanup: hermetic tests, CI, handler tests, client split, CDN failover, health, probe URLs 2026-09-17 15:54:29 -04:00
cmd/sxm-stream audit: drop dead code, fix the ResetSession race, bound header reads 2026-10-23 23:09:26 -04:00
internal audit: drop dead code, fix the ResetSession race, bound header reads 2026-10-23 23:09:26 -04:00
scripts docs: the release hash is per-commit, and note it where it bites 2026-10-23 23:12:54 -04:00
.env.example docs: rewrite the README around what the project is and why 2026-10-22 23:30:20 -04:00
.gitignore audit: drop dead code, fix the ResetSession race, bound header reads 2026-10-23 23:09:26 -04:00
CONTEXTMAP.md release: publish the arm64 artefact to Forgejo, and curl it on the Pi 2026-10-23 23:12:02 -04:00
CURRENT.md docs: the release hash is per-commit, and note it where it bites 2026-10-23 23:12:54 -04:00
DECISIONS.md release: publish the arm64 artefact to Forgejo, and curl it on the Pi 2026-10-23 23:12:02 -04:00
go.mod Initial commit: sxm-stream-go — SiriusXM AAC proxy in Go (carried over from sxm-go) 2026-09-15 23:32:47 -04:00
README.md docs: the release hash is per-commit, and note it where it bites 2026-10-23 23:12:54 -04:00

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 --quality flag 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 .env has 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.
  • live means 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.
  • lastsong starts 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. lastsong labels 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 --probe one-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}.aac and /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 --delay is 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 short
  • DECISIONS.md — why it is built this way, with the measurement behind each call; read it before changing behaviour
  • CONTEXTMAP.md — where everything lives