No description
  • Python 49%
  • TypeScript 25.3%
  • Shell 22.7%
  • Dockerfile 2.9%
  • JavaScript 0.1%
Find a file
eman 8c5210adae
All checks were successful
build-player / build (push) Successful in 41s
release-plugin / release (push) Successful in 47s
ci: build on archive.debian.org, bullseye is past LTS
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
2026-10-09 17:44:26 -07:00
.forgejo/workflows ci: build on archive.debian.org, bullseye is past LTS 2026-10-09 17:44:26 -07:00
backend ci: build on archive.debian.org, bullseye is past LTS 2026-10-09 17:44:26 -07:00
scripts Scaffold Decky Sendspin plugin: sendspin-go player + Music Assistant control panel 2026-08-28 01:06:42 -07:00
src Scaffold Decky Sendspin plugin: sendspin-go player + Music Assistant control panel 2026-08-28 01:06:42 -07:00
.gitignore Scaffold Decky Sendspin plugin: sendspin-go player + Music Assistant control panel 2026-08-28 01:06:42 -07:00
ma.py Scaffold Decky Sendspin plugin: sendspin-go player + Music Assistant control panel 2026-08-28 01:06:42 -07:00
main.py Patch sendspin-go: never block the read loop on artwork 2026-10-09 17:34:30 -07:00
package.json Scaffold Decky Sendspin plugin: sendspin-go player + Music Assistant control panel 2026-08-28 01:06:42 -07:00
plugin.json Scaffold Decky Sendspin plugin: sendspin-go player + Music Assistant control panel 2026-08-28 01:06:42 -07:00
pnpm-lock.yaml Scaffold Decky Sendspin plugin: sendspin-go player + Music Assistant control panel 2026-08-28 01:06:42 -07:00
README.md Patch sendspin-go: never block the read loop on artwork 2026-10-09 17:34:30 -07:00
rollup.config.js Scaffold Decky Sendspin plugin: sendspin-go player + Music Assistant control panel 2026-08-28 01:06:42 -07:00
tsconfig.json Scaffold Decky Sendspin plugin: sendspin-go player + Music Assistant control panel 2026-08-28 01:06:42 -07:00

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.py reads 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 Python sendspin client 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 port 8927. 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-devices on the Deck shows the pulse/pipewire device as default, not a raw hw: card. If miniaudio picks ALSA-direct it would hog the device; the fix would be --audio-device pointed 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:

  1. Acquire — CI builds sendspin-player from a pinned sendspin-go tag in a bullseye container and gathers libopus.so.0 beside it as bin/.
  2. Run — Decky Python backend renders player.yaml and launches LD_LIBRARY_PATH=bin/lib bin/sendspin-player --config … --daemon as the deck user, tracks the PID, exposes start/stop/status, and logs the player's stdout to ~/homebrew/logs/Sendspin/sendspin-player.log.
  3. 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.py translates 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

  1. Client choice + build spike. Pick a shippable client, prove it connects to MA's Sendspin provider. (done 2026-08-28, from the dev box)
  2. Control channel. Find and validate an MA API the backend can drive with the stdlib; resolve our player deterministically. (done 2026-08-28)
  3. Plugin. Backend, MA client, QAM panel, packaging, CI — ported from decky-squeezelite. (done 2026-08-28, builds clean)
  4. Hardware validation. Bare player on the Deck via test-on-deck.sh, then the plugin via URL install. Audio-device behavior, root→deck drop, game-audio mixing, the panel in game mode.
  5. 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:

  1. player_id == client_id (a native Sendspin player, as older MA versions and the phone app create), else
  2. any player whose output_protocols contains our client id (the 2.10 universal wrapper), else
  3. 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-player v1.8.2 (prebuilt) → --server 192.168.42.7:8927: handshake, clock sync, group join. MA lists it with the right model/version.
  • ma.py against the live server: resolves sendspin-machina to upsendspinmachina via output_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.py compile.
  • Test players created during the spike were removed from MA afterwards (players/remove).

Still to do on the Deck

  1. test-on-deck.sh: glibc check, device list (expect a pulse/pipewire default), audible playback from the MA UI.
  2. 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.
  3. Audio-follows-device: BT connect mid-play, dock/HDMI, and game-audio mixing.
  4. 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-config for 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 a player.yaml key, 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).