- Python 49%
- TypeScript 25.3%
- Shell 22.7%
- Dockerfile 2.9%
- JavaScript 0.1%
deb.debian.org's debian-security pool now 404s for bullseye packages (git, curl, ca-certificates, dpkg-dev, ...); they live on archive.debian.org since LTS ended 2026-08. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01YXzef74qKcvYnhv21u8Edm |
||
|---|---|---|
| .forgejo/workflows | ||
| backend | ||
| scripts | ||
| src | ||
| .gitignore | ||
| ma.py | ||
| main.py | ||
| package.json | ||
| plugin.json | ||
| pnpm-lock.yaml | ||
| README.md | ||
| rollup.config.js | ||
| tsconfig.json | ||
decky-sendspin
A Decky Loader plugin that turns a Steam Deck into a Sendspin network audio player for Music Assistant, controllable directly from the Quick Access Menu (QAM) in game mode.
It is the successor to decky-squeezelite: same shape (a bundled player binary run as a backend subprocess, toggled from the QAM, with a now-playing panel driven by the server's API), re-targeted from Lyrion/LMS to Music Assistant after the 2026-08-25 cutover. This README doubles as the reference spec; where a decision was inherited unchanged it says so.
Status: built and validated against the live MA server from the dev box (2026-08-28): the bundled player connects and clock-syncs to MA's Sendspin provider, MA adopts it as a player, and
ma.pyreads now-playing state, cover art, and drives transport for a real queue. Not yet run on the Deck — the Deck was offline during the build. See "Validation" for the on-hardware checklist that remains.
Goal
- Install a self-contained Sendspin client on the Deck without fighting the immutable SteamOS filesystem.
- Configure the Sendspin server, player name, and MA API access from the plugin UI.
- Toggle the player on/off from game mode.
- Play through whatever audio device the Deck is currently using — built-in speaker, Bluetooth, wired headset, or HDMI when docked — with no manual device selection.
- Show what's playing, with play/pause/next/prev and volume, in the QAM.
Target environment
- Deploy target: Steam Deck running SteamOS (Arch-based, immutable root FS, audio via PipeWire), with Decky Loader installed.
- Dev machine: Manjaro (
machina). Already runs the Pythonsendspinclient as a systemd user service — useful as a reference, but not what we ship (see "Shipping the player"). - User's setup: Music Assistant 2.10 at
192.168.42.7:8095, Sendspin provider on port8927. This Deck becomes one more Sendspin player, which MA can group with the Sonos/HEOS/AirPlay set.
Key design decisions
| Decision | Choice | Rationale |
|---|---|---|
| Client implementation | sendspin-go (sendspin-player), not the Python sendspin CLI |
One Go binary + libopus.so.0. The Python client drags in numpy, PyAV (FFmpeg), sounddevice/PortAudio and needs a matching CPython ABI on the Deck. |
| Binary delivery | Ship a prebuilt bundle inside the plugin dir | SteamOS root FS is read-only and wiped on every OS update. ~/homebrew/plugins/ persists. (inherited) |
| Build location | CI, in a bullseye container (Go via cgo inherits the host glibc floor) | Keeps the glibc floor at 2.31 like the squeezelite bundle. The upstream prebuilt release wants 2.34 — fine on SteamOS 3.6+, but we control it anyway. |
| Service control | Backend-managed subprocess | Decky's Python backend runs as root and stays alive while loaded; drops to deck for the child. (inherited) |
| Audio output | miniaudio default device → pipewire-pulse default sink | Follows the Deck's current output automatically and mixes with game audio. |
| Player identity | Fixed Sendspin client id sendspin-steamdeck |
MA keys the player on it, so renaming the player doesn't create a new one and the plugin can find its own player without name matching. |
| Transport controls | MA's POST /api JSON-RPC with a user API token |
A Sendspin player has no control surface of its own; MA does. HTTP keeps ma.py stdlib-only (no websocket client in the stdlib). |
| Test target | Real Steam Deck with Decky | Game-mode UI and PipeWire behavior can't be fully validated on the dev box. (inherited) |
Shipping the player
Local patches
backend/build.sh applies backend/patches/*.patch to the pinned tag and
stamps the version as e.g. v1.8.2+p0001 (shown in MA's device info). The
upstream prebuilt from fetch-release.sh is unpatched: don't ship it.
| Patch | Why |
|---|---|
0001 never block the read loop on artwork |
v1.8.2 advertises artwork@v1 but nothing reads ArtworkChunks (cap 10). MA sends a cover per track, so on the 11th track change the websocket reader blocks forever: audio stops, MA logs Role queue full for player (4096/4096), client too slow - disconnecting, and the player never reconnects (the reader never sees the close). Silence after ~10 songs until the player is toggled. Diagnosed 2026-10-09 from a SIGQUIT goroutine dump (reader parked at client.go:461). Drop once upstream fixes it. |
A stuck player can be diagnosed the same way: kill -QUIT <pid> dumps every
goroutine into sendspin-player.log (the previous run's log is kept as .1).
Why sendspin-go
The sendspin PyPI client (what machina runs) is a Python package with
compiled dependencies: numpy, av (bundles FFmpeg), sounddevice (needs a
system PortAudio), pulsectl, zeroconf… Vendoring that into a plugin means a
matching CPython ABI for whatever Python SteamOS ships, plus ~100 MB of wheels.
sendspin-go is a complete protocol
implementation in Go. Its sendspin-player is one 8 MB binary that links only
libopus.so.0 beyond glibc/libm, decodes PCM/FLAC/Opus, and does mDNS
discovery, clock sync and a jitter buffer. Audio goes through miniaudio, which
dlopens the host's libpulse (then libasound as fallback) at runtime, so
the Deck's own sound-server client library and routing config are used — the
same "don't bundle the audio layer" rule as the squeezelite build.
Verified 2026-08-28 from the dev box against the live server: v1.8.2 connects
to 192.168.42.7:8927, completes the handshake and clock sync (rtt ~0.4 ms),
and MA reports it as model: Sendspin Go Player, software_version: v1.8.2.
The glibc floor
cgo binaries inherit the build host's glibc symbol versions. Upstream releases
are built on a current Ubuntu and require GLIBC_2.34. SteamOS 3.6+ ships
2.38, so that would run — but CI builds from source in a golang:1.24-bullseye
container (glibc 2.31) so the floor stays where the squeezelite bundle was and
an older SteamOS won't bite. bin/VERSION records built_glibc_max either way.
Server address form
sendspin-player --server takes a bare host:port and adds ws:// and
/sendspin itself. The Python client wants the full
ws://host:8927/sendspin — paste that into the Go player and it tries to dial
ws://ws:%2F%2F… and fails. ma.sendspin_server() normalizes whatever is
typed (scheme and path stripped, port 8927 defaulted) before it reaches
player.yaml. Blank means mDNS discovery.
player.yaml is generated, and must stay read-only for the player
The backend renders player.yaml (name, client_id, server, daemon: true)
into the plugin settings dir on every start and launches
sendspin-player --config <that> --daemon. sendspin-go writes back to its
config file only when it has to derive a client id (or when --client-id /
--static-delay-ms are passed as flags). We always supply client_id in the
file and pass no such flags, so the demoted player never needs write access to
a root-owned settings dir. Keep it that way: adding a --client-id flag to the
command line would reintroduce the write.
Audio: following the current output device
Unchanged from squeezelite in intent: the user does not pick a device;
playback follows the PipeWire default sink. With miniaudio the mechanism is
pipewire-pulse: no --audio-device is set, miniaudio's first-choice Linux
backend is PulseAudio, and the pulse default sink is PipeWire's default. The
child is launched with XDG_RUNTIME_DIR=/run/user/1000 so it finds the socket
from a root-spawned process.
Needs on-hardware validation (same list as last time, plus one):
--list-audio-deviceson the Deck shows the pulse/pipewire device as default, not a rawhw:card. If miniaudio picks ALSA-direct it would hog the device; the fix would be--audio-devicepointed at the pulse device.- Plug in BT mid-play → audio moves.
- Mixes with game audio rather than grabbing an exclusive device.
- Docked (HDMI sink becomes default).
Configuration surface
Exposed in the UI, persisted via Decky settings (config.json, mode 0600
because of the token), applied via player.yaml / used by ma.py:
| UI field | Where it goes | Notes |
|---|---|---|
| Player name | name: in player.yaml |
Defaults to Steam Deck. What MA shows. |
| Sendspin server | server: in player.yaml |
host[:port], normalized. Blank = mDNS. |
| Music Assistant URL | ma.py base URL |
host[:port]; bare host gets :8095; a pasted :8927 is corrected to 8095. |
| MA API token | Authorization: Bearer |
Write-only in the UI: the backend never echoes it back, and a blank field on Save keeps the stored one. Create it in MA under Settings → Users → API tokens. |
| (fixed) client id | client_id: in player.yaml |
sendspin-steamdeck. Not user-facing; change in config.json only if you want a second identity. |
Architecture
Three layers, as before:
- Acquire — CI builds
sendspin-playerfrom a pinned sendspin-go tag in a bullseye container and gatherslibopus.so.0beside it asbin/. - Run — Decky Python backend renders
player.yamland launchesLD_LIBRARY_PATH=bin/lib bin/sendspin-player --config … --daemonas thedeckuser, tracks the PID, exposes start/stop/status, and logs the player's stdout to~/homebrew/logs/Sendspin/sendspin-player.log. - Control — Decky frontend: a QAM panel with the on/off toggle, the
now-playing section, and config fields, calling backend methods over
@decky/api;ma.pytranslates those into MA API commands.
Repo layout
decky-sendspin/
├─ plugin.json # Decky manifest (name, author, flags)
├─ package.json # frontend deps: @decky/ui, @decky/api
├─ rollup.config.js # bundles frontend → dist/index.js
├─ src/
│ └─ index.tsx # QAM panel: toggle, now playing, config fields
├─ main.py # backend Plugin class: start/stop/status/config, ma_* bridge
├─ ma.py # Music Assistant client: player lookup, status, transport, art
├─ scripts/
│ ├─ stage-plugin.sh # payload file list, shared by installer + CI
│ ├─ install-on-deck.sh # rsync dev install over SSH
│ └─ test-on-deck.sh # run the bare player on the Deck, no plugin
├─ backend/
│ ├─ Dockerfile # golang:1.24-bullseye, source build
│ ├─ build.sh # clone sendspin-go, apply patches/, compile, gather libs
│ ├─ patches/ # local fixes on top of the pinned tag
│ └─ fetch-release.sh # dev shortcut: upstream prebuilt binary
├─ .forgejo/workflows/
│ ├─ build-player.yml # bundle artifact
│ └─ release-plugin.yml # plugin zip → moving `latest` release
├─ bin/ # build output, shipped with the plugin (gitignored)
│ ├─ sendspin-player
│ ├─ lib/libopus.so.0
│ └─ VERSION
└─ README.md
Milestones
- Client choice + build spike. Pick a shippable client, prove it connects to MA's Sendspin provider. (done 2026-08-28, from the dev box)
- Control channel. Find and validate an MA API the backend can drive with the stdlib; resolve our player deterministically. (done 2026-08-28)
- Plugin. Backend, MA client, QAM panel, packaging, CI — ported from decky-squeezelite. (done 2026-08-28, builds clean)
- Hardware validation. Bare player on the Deck via
test-on-deck.sh, then the plugin via URL install. Audio-device behavior, root→deckdrop, game-audio mixing, the panel in game mode. - Grouping. Confirm the Deck can join the kitchen-led group from the MA
UI / Node-RED (MA player
can_group_with), and decide whether the plugin should expose "join group" itself.
Music Assistant control (ma.py)
The key fact is the same as with squeezelite: the player has no control interface. Sendspin clients are driven by the server. So play/pause/next go to Music Assistant, addressed to the MA player that wraps this Deck.
Which API
MA's primary API is a websocket (/ws), which needs a client library the
stdlib doesn't have. But the webserver also registers POST /api, which
accepts the same command envelope and returns the bare result:
POST http://192.168.42.7:8095/api
Authorization: Bearer <token>
{"message_id": "decky", "command": "players/get", "args": {"player_id": "…"}}
Verified live: result JSON on 200, Authentication required 401 without or
with a bad token, null for an unknown player id, Invalid Command 400 for a
bad command name. (Note it's the MA envelope, not JSON-RPC 2.0 —
{"jsonrpc":"2.0","method":…} gets missing required field.)
Finding our player
MA 2.10 wraps a newly connected Sendspin client in a universal player whose
id is a fresh up… hash, unrelated to anything the client sent. What does
carry through is our client id, as output_protocols[].output_protocol_id.
So MAClient.player_id() scans players/all for:
player_id == client_id(a native Sendspin player, as older MA versions and the phone app create), else- any player whose
output_protocolscontains our client id (the 2.10 universal wrapper), else - a case-insensitive name match, as a last resort.
Observed on the live server: the first connection of a new client showed up
only via players/get_by_name as type: protocol; on reconnect MA had
wrapped it (up5155f49a, type: player, output_protocol_id: <client id>)
and it had a queue. If the panel says the player isn't known to MA right after
first start, toggle the player off and on, or enable it in the MA UI.
Commands and status
| Piece | Behavior |
|---|---|
| Transport | TRANSPORT maps UI action names to players/cmd/* (play_pause, next, previous, volume_up, …), so the frontend never speaks MA. |
| Volume | players/cmd/volume_set with volume_level 0–100. |
| Status | players/get + player_queues/get_active_queue, flattened to the same dict shape lms.py produced: mode, title/artist/album, art URL, time, duration, volume, muted. |
| Elapsed time | MA reports elapsed_time as of elapsed_time_last_updated; the client extrapolates while playing so the readout doesn't jump, and zeroes it when the queue has no current item (MA keeps the last track's value around). |
| Radio | streamdetails.stream_title becomes the title and the station name the subtitle. |
Cover art has to go through the backend
Steam's CEF blocks plain-http images from the LAN (confirmed on hardware last
time), so art is fetched by the backend and handed to the UI as a data: URL,
cached one deep, refetched only when the track changes.
MA makes this simpler than LMS did: every image carries a proxy_id, and
/imageproxy/<proxy_id>?size=256 serves it resized, unauthenticated,
whether the original is a local file or a Spotify CDN URL. Allowed sizes are
0, 80, 160, 256, 512, 1024 (anything else is a 400); 256 measured 16 KB
JPEG for a 46 KB original. Only URLs on the MA host are fetched, which is
also what keeps a root-run backend from fetching arbitrary hosts.
Panel behavior
Cover art, title, artist — album, a progress bar (hidden for streams with no
duration), prev/play-pause/next as gamepad-focusable buttons, and a volume
slider. The hardware-found details from decky-squeezelite are carried over
verbatim (D-pad needs <Focusable flow-children="horizontal">; poll skips a
tick while a request is in flight; polled volume is ignored for 1.5 s after a
drag and the set call is debounced 250 ms; the volume field uses an icon and no
separator so it reads as part of Now Playing). One change: MA's play_pause
is a no-op on an idle queue, so the button sends play when mode == "stop".
When the player is running but MA isn't configured, the panel says so instead of "Connecting…".
Building the player bundle
In CI (primary): .forgejo/workflows/build-player.yml runs
backend/build.sh inside a golang:1.24-bullseye job container and uploads
sendspin-player-bundle.tar.gz. release-plugin.yml does the same build and
ships it inside the plugin zip. The sendspin-go tag is pinned in both files
(SENDSPIN_GO_REF, currently v1.8.2); bump deliberately.
Locally, from source (needs Docker):
docker build -t sendspin-build backend/ --build-arg SENDSPIN_GO_REF=v1.8.2
docker create --name ssb sendspin-build
docker cp ssb:/out ./bin
docker rm ssb
Locally, prebuilt (no Docker; what the dev box uses):
backend/fetch-release.sh # upstream release binary + this machine's libopus.so.0
Same bin/ layout; bin/VERSION says which you have and its glibc floor.
Validate on the Deck (before involving the plugin):
DECK=deck@steamdeck-wired SERVER=192.168.42.7:8927 scripts/test-on-deck.sh
This rsyncs bin/ to the Deck, prints the Deck's glibc next to the bundle's
requirement, lists miniaudio's playback devices, and runs the player under a
test client id so you can play to it from the MA web UI.
Installing on the Deck (dev workflow)
Unchanged from decky-squeezelite: stage over SSH, then one sudo step.
DECK=deck@steamdeck-wired scripts/install-on-deck.sh
Builds the frontend, stages plugin.json + main.py + ma.py +
package.json + dist/ + bin/, rsyncs, sudo-installs to
~/homebrew/plugins/Sendspin, and restarts plugin_loader. Logs land in
~/homebrew/logs/Sendspin/ on the Deck — Decky's own log plus
sendspin-player.log, the player's stdout (server it connected to, device
miniaudio picked, sync quality).
Installing from a URL (no store needed)
.forgejo/workflows/release-plugin.yml publishes the zip to a moving
latest release:
https://git.theluthers.me/decky/decky-sendspin/releases/download/latest/Sendspin.zip
Paste into Decky (Settings → General → Install from URL). To update,
paste it again. The exec-bit trap from last time still applies and
main.py:_ensure_executable() still handles it — don't clean it up. The repo
must be publicly readable for the Deck to fetch the asset without credentials.
Validation
From the dev box (2026-08-28)
sendspin-playerv1.8.2 (prebuilt) →--server 192.168.42.7:8927: handshake, clock sync, group join. MA lists it with the right model/version.ma.pyagainst the live server: resolvessendspin-machinatoupsendspinmachinaviaoutput_protocols; reads the bedroom's playing queue (title/artist/album, elapsed, duration, volume) and fetches its art through the imageproxy; bad token →MAError("Music Assistant rejected the API token"); unknown client id → clear message.- Frontend builds (
pnpm run build),main.py/ma.pycompile. - Test players created during the spike were removed from MA afterwards
(
players/remove).
Still to do on the Deck
test-on-deck.sh: glibc check, device list (expect a pulse/pipewire default), audible playback from the MA UI.- Plugin via URL install: toggle starts the player as
deck, MA adopts it, the panel shows the queue, transport and volume work, D-pad moves across the buttons. - Audio-follows-device: BT connect mid-play, dock/HDMI, and game-audio mixing.
- Grouping with the kitchen leader from the MA UI.
Toolchain / prerequisites
- Node.js ≥ 16.14,
pnpm(v9 in CI; v11 locally works) - Go ≥ 1.24 +
libopus-dev+pkg-configfor a source build, or Docker - A Steam Deck with Decky Loader for testing
Decided
- No auto-start. Toggle it on manually each time. (inherited)
- Mix with game audio. The player is an ordinary PipeWire stream. (inherited)
- Player name is settable in the plugin UI, identity is not. (new)
- MA token lives only on the Deck, in a 0600 settings file, and is never sent back to the frontend. (new)
Stretch goals
- Group join from the QAM. MA exposes
players/cmd/group/players/cmd/ungroup; a "Join kitchen" button would let the Deck pop into the house group the way machina does on unlock. - Static delay. sendspin-go has
--static-delay-ms(machina uses 105 ms with the Python client); expose it if the Deck is audibly early/late in a group. It's aplayer.yamlkey, so it's a config field away. - MPRIS bridge. Same reasoning as last time: a second face on the MA control channel, so Steam's media overlay works. sendspin-go has no MPRIS of its own (the Python client does).