Sendspin Bluetooth Bridge — turn any BT speaker into an MA player and HA

Many thanks, this is coming just as I was about to try squeezelite-esp32! :tada:

I have had mixed success with the quality of the audio stream between my RPi4 running HAOS and the BT speakers in other rooms/opposite ends of my house. I have an abundance of ESPHome bluetooth proxies scattered around in much more ideal locations relative to the speakers, but they do not appear as potential adapters, only the default hci0 built in to the RPi4. No luck if I add the proxy’s mac address to the app’s yaml config and restart, it just continues to appear as a form entry and is not acknowledged as an adapter :frowning_face: :

Is this a skill issue on my part, or are BT proxies not supported as adapters (yet)? Much appreciated!

Hi! This is not a skill issue on your side — at the moment ESPHome Bluetooth proxies are not supported as audio adapters for the bridge.

The short version is: Home Assistant Bluetooth proxies are great for BLE traffic (sensors, presence, etc.), but they do not expose a real Linux/BlueZ audio controller to the add-on. The bridge
currently works only with adapters that are visible on the host itself via bluetoothctl list / hci* and can create a real bluez_sink.* audio sink in PulseAudio.

That is why:

  • only the Pi’s built-in hci0 appears in the adapter list
  • adding a proxy MAC manually is not enough
  • the extra MAC stays just a config entry and never becomes an active adapter

For Bluetooth audio playback, the adapter must be able to do the full classic Bluetooth audio path locally on the host:

  • pairing / trust / reconnect via BlueZ
  • A2DP audio transport
  • PulseAudio/PipeWire sink creation

ESPHome BT proxies do not provide that today.

So for now the practical options are:

  • use a local USB Bluetooth dongle on the HA host
  • place the host/bridge closer to the speakers
  • run a separate bridge instance on another nearby Linux box / Pi / LXC and let MA group them

If you want, I can also add a small UI improvement so manual non-detected adapter entries show a clearer “configured but not present on this host” message instead of looking like they should work
immediately.

1 Like

I have the same problem. The computer (Intel mini PC) that runs Home Assistant/MA is located in the basement.

Is there absolutely no way to use a hardware Bluetooth gateway that connects either via LAN or Wi-Fi?

If I were to use a Raspberry Pi for this, what would the configuration look like? Is it not possible with an ESP32?

1 Like

Just here to say THANK YOU!!!

Only just got MA beta working after “The Merge” so I finally got to try this out and had immediate success with a 2013 Jam Classic speaker. It’s a tiny cheap unit that happens to sound surprisingly good so I bought several at the time, now I can breath new life into them thanks to this App. Connecting was smooth, integration with MA was smooth, and syncing was smooth… smoooooooth.

I have only one issue with the front end of the app… dark mode please.

2 Likes

:pray: Thank you all for the interest in the project, the testing, the feedback, the bug reports, and the ideas. It really helps move the bridge forward much faster :slight_smile:

What’s New (v2.31.9 → v2.40.6)

:control_knobs: A much better Music Assistant playback UI — the dashboard got a lot more polished across both card and list views. Playback controls, queue previews, progress display, badges, warnings, and live
status all feel cleaner and more consistent now.

:framed_picture: Better now-playing and artwork handling — album art is more reliable again, playback progress is less jumpy, and the UI does a much better job showing what is actually playing right now.

:repeat: Music Assistant controls are much more reliable — a lot of work went into fixing next / previous / shuffle / repeat, especially for solo players and mixed MA versions. In practice, transport
controls now behave much better for both single speakers and MA sync groups.

:zap: Faster, calmer UI behavior — queue actions and MA state sync were improved so the interface reacts faster, shows more useful pending/confirmed state, and recovers more gracefully from short
disconnects instead of feeling “stuck” or confusing.

:stethoscope: Far better status, startup, and diagnostics — the bridge now exposes startup progress, richer health/status snapshots, better diagnostics, and clearer onboarding hints. That makes it much easier
to understand what the bridge is doing and why something is not ready yet.

:jigsaw: Home Assistant addon experience improved a lot — stable, RC, and beta tracks are now much clearer and safer to use. Parallel addon channels were improved, ingress behavior is cleaner, and the UI
now better reflects what is fixed by the installed HA addon track versus what is user-configurable.

:arrow_up: Updates are safer and clearer — update channels are now first-class, standalone/LXC updates are more predictable, and the release flow is cleaner: stable builds use GitHub Releases, while
prerelease tracks use tags.

:art: General UI polish everywhere — lots of smaller improvements landed across warnings, spacing, helper text, header actions, onboarding, and config screens.

:last_quarter_moon: Specially for @HappyCadaver — new theme switcher now has three modes: Light, Dark, and Auto :slightly_smiling_face:

:video_game: Want to see the UI almost live before updating? Try the demo stand here:
https://sendspin-demo.onrender.com

1 Like

Thanks again for all the work and improvement on this! That UI improvement would be great, some way to imperatively submit “please add this adapter” and get a response as to wether it works or not (and why) would be great. Currently, it’s ambiguous if filling out the fields is enough, if the adapter is only searched for on save + restart, etc :slight_smile:

I’ve ordered a dedicated bluetooth adapter with long range antenna to see if that’ll fix the range/connection quality issues using the embedded RaspberryPi 4 antenna; will report if that ends up being the solution to my woes!

If that doesn’t, would be keen to +1 @fmsmuc 's question re. non-BT proxy remote adapter setup suggestions :bowing_man:

If either solution doesn’t pan out, I wonder if there’s potential support from SendSpin to do something like squeezelite-esp32, where music is streamed to the wifi-connected ESP32s that then play music to a dedicated speaker that it’s paired with? :thought_balloon:

Since there are a ton of UI and theme improvements going on, here are a few places where overflow doesn’t seem to be working as intended, or at least in the context of the device fleet list and the discovery/pairing part of the UI (gathered from the new demo site):

As always, thanks for your time and effort! If there’s anything specific that needs contributions, I see that Copilot is heavily involved; I could see what happens if I let Claude Code take a crack at some problems when I have access again next week :slight_smile:

1 Like

Thanks a lot for the kind words, the testing, and the detailed screenshots — this kind of feedback is genuinely very helpful.

On the hardware side, the bridge is intentionally built around the most universal layers and abstractions rather than specific devices, so in general anything that works with BlueZ + PulseAudio
should be a good candidate. There will always be exceptions with very specific devices and vendor quirks of course — something like Ray-Ban Meta Glasses is a good example of where things may get
weird :slightly_smiling_face:

About ESP: I think the more correct direction is not “ESP pretending to be a Bluetooth proxy”, but a proper Sendspin/Resonate-style client on ESP. This thread is probably the most relevant
discussion right now:
Media player with esp32 s3 N16R8 and max98357 with sendspin synchronized audio

We’re also doing some early research for a new experimental v3.0 branch and looking at possible future directions like Pulse LocalSink, ALSA, USB auto-discover, SnapcastClient, VBAN, and LE Audio
tracker.

About the UI artifacts: I was able to reproduce the issue on a tablet in portrait orientation. It turned out to be a real responsive layout gap: at some intermediate tablet widths, parts of the
UI were still behaving too much like the desktop layout, which caused elements to overflow to the right. I’ve already adjusted the responsive layout for that case and verified the fix locally in
tablet portrait mode. The changes are now in main, on demo site and will be included in the next release.

And yes, Copilot is the main tool I use, but with different models behind it — roughly half Claude Opus 4.6 and half GPT-5.4. They’re honestly pretty close in practice, except GPT-5.4 tends to be a
bit more verbose :slightly_smiling_face: As the codebase grows, efficiency naturally drops a bit even with a 1M context window, so I’ll definitely keep trying to optimize the AI-assisted workflow. Very happy to hear any
tips, recommendations, or your own experience too.

1 Like

My eyes really appreciate that, thank you!

Next FR (ya I’m prone ot pushing boundries):
Home Assistant uses areas (aka rooms) for devices and I have my only BT adapter assigned to the room it’s in. It would be nice if the HA App (formerly known as Add-on) installation type could check if HA has the BT adapter assigned to an area and use the area name as the default bridge name for that adapter. Example: Jam Classic @ Livingroom

Regardless thanks a lot for all your efforts here.

2 Likes

What’s New (v2.40.6 → v2.42.1)

:compass: A much smarter operator guidance system — the bridge now has a real onboarding checklist, recovery assistant, and unified operator guidance model. Instead of just showing raw status, the UI now
does a much better job explaining what is wrong, what is ready, and what to do next.

:clipboard: Setup flow is much clearer now — onboarding is no longer just an empty-state hint. It became a structured checklist with progress, completion markers, and direct actions for Bluetooth setup,
device attachment, Music Assistant auth, and latency review. It also stays available from the header as a reference even after the first setup is done.

:ambulance: Recovery UX is now much stronger — the new Recovery Center and recovery assistant group active issues more clearly, show recovery traces, and suggest safer next actions like reconnect, re-pair,
release, or diagnostics. This makes troubleshooting much less guessy when a speaker drops out or gets stuck in a bad state.

:stethoscope: Diagnostics got a major overhaul — diagnostics is now split into a simpler Overview plus Advanced diagnostics, with much better scanability. There are also copy helpers for support
workflows, expandable raw details for advanced debugging, and direct jumps to the most relevant config sections.

:lock: Restart, startup, and update flows feel much more correct — a lot of work went into lockout behavior and startup state handling. The UI now stays aligned with the real backend
startup/restart/update progress much better, including the finalizing phase. LXC updates also behave more cleanly now, and after an update the UI does a cache-busting refresh so the browser actually
loads the new frontend immediately.

:no_entry_sign: Disabled devices now behave properly — disabling a device is now much more consistent across dashboard state, live refreshes, and Save and restart. Disabled devices keep their proper disabled
visuals, survive refreshes correctly, and no longer “bounce back” into misleading states.

:repeat: Bluetooth recovery and release are more responsive — releasing a device while reconnect is in progress is much less frustrating now. Internally, the reconnect flow can be cancelled more cleanly,
so release actions no longer feel blocked behind long retry loops.

:art: A lot of UI polish landed too — larger artwork in grid playback cards, clearer diagnostics hierarchy, better disabled-state visuals, improved onboarding/recovery copy, and better action placement
across the dashboard.

:building_construction: Under the hood this was also a big architecture release — a lot of runtime logic was moved out of the old monolithic state path into dedicated services for bridge runtime state, Music Assistant
state, device registry snapshots, async jobs, event hooks, operator guidance, and recovery analysis. So this release is not just more polished — it also gives the project a much stronger base for
the next steps.

1 Like

What’s New (v2.42.1 → v2.42.2)

:mag: Bluetooth discovery is much better now — the scan flow is more capable and easier to understand. The scan modal now supports adapter selection, an explicit Audio devices only filter, and a
dedicated rescan action, so multi-adapter setups are much easier to work with.

:art: The Bluetooth scan UI got a real polish pass — the discovery modal now follows the same compact design language as the rest of the app, with a cleaner header, clearer scan progress, better result
framing, and more consistent actions for Add to fleet and Add & pair.

:triangular_ruler: The compact UI system is more unified across the whole app — badges, chips, guidance surfaces, action menus, count badges, configuration headers, and notice blocks now share a more explicit
design-system layer instead of feeling like separate styling islands. The login screen also now fits much better with the main UI.

:white_check_mark: Operator guidance got smarter in edge cases — onboarding now recognizes when all configured speakers have been manually released and offers direct reclaim actions, so it is easier to get playback
back without digging through settings first.

:label: Status badges and guidance entry points are more consistent — interactive and passive badges now have better hover/cursor behavior and more consistent visual treatment, and guidance cards
configured to show by default now reliably open from the header entry point.

:gear: Scan results are more honest and less confusing — results now stay aligned with the selected discovery scope, and non-audio Bluetooth devices are surfaced more clearly when the audio-only filter
is turned off. The modal copy also explains the actual operator workflow better.

:lock: Home Assistant login to Music Assistant add-ons is fixed again — standalone bridge installs can now complete HA login after TOTP even when MA’s direct OAuth bootstrap is unavailable. The bridge
now falls back to the HA login flow, resolves MA ingress via Supervisor APIs, and completes token creation through an HA ingress session.

:information_source: HA login failures are also easier to understand now — when HA OAuth is not configured on the Music Assistant side, the bridge now reports the real MA-side reason and explicitly suggests switching
to direct Music Assistant authentication instead of leaving the user with a vague failure.

What’s New (v2.42.3 → v2.45.0)

:compass: Setup and recovery guidance is much clearer now — the bridge now follows a more realistic dependency order when guiding users through setup and troubleshooting: runtime access, Bluetooth adapter
health, audio backend, speaker availability, sink readiness, Music Assistant integration, and only then latency tuning. This makes the “next best action” much more obvious in real-world failure
states.

:art: Onboarding is calmer and less noisy on non-empty installs — the setup checklist no longer dominates the main notice stack once you already have devices configured. Instead, onboarding stays more
compact, recovery guidance gets the top-level attention, and the UI feels much more focused during normal day-to-day use.

:triangular_ruler: The bridge now has a more consistent internal state/capability model — device/runtime/configuration status, blocked reasons, transport health, and recovery hints are now derived through a shared
model instead of being pieced together in several different places. That consistency feeds the dashboard, onboarding, diagnostics, and operator guidance.

:hand: Blocked actions are now much more touch/mobile friendly — disabled controls no longer depend on hover-only tooltips to explain what is wrong. Cards and rows now show visible compact hints and
dependency explanations directly, so it is much easier to understand why an action is unavailable on phones and tablets.

:repeat: Music Assistant settings can now be reloaded without restarting the whole bridge — if you change the MA URL or token, the running MA integration can now reload in place, refresh credentials, and
rerun discovery/group sync without forcing a full bridge restart.

:house: Home Assistant Areas are now integrated into naming flows — the config UI can now pull the HA area registry so Bridge name can offer one-click area suggestions, and Bluetooth adapters can
optionally use matching HA area names as custom adapter names. This HA-zones-based naming work was added at the request of user @HappyCadaver. It is also configurable, and in HA add-on mode it is
enabled by default.

:mag: Music Assistant discovery is smarter in Home Assistant environments — MA discovery now better prioritizes HA add-on candidates, preserves more useful discovery context, and makes it easier to
retry discovery from onboarding/recovery instead of falling straight into manual configuration.

:stethoscope: Diagnostics are much more useful for real troubleshooting now — diagnostics downloads and bug reports now include a plain-text recovery timeline summary, recovery diagnostics gained
rerunnable safe checks, latency recommendations are more actionable, and the retained recovery timeline is deeper with advanced filtering by severity, source, scope, and visible window.

:label: Recovery guidance is more compact and actionable — repeated issue pills now collapse into a calmer +N more summary, grouped actions show affected-device previews before bulk operations, and
duplicate remediation text is reduced when the same root cause already has a top-level explanation.

:rocket: The Bluetooth scan and pairing flow is more robust — active scans remain resumable after the modal is closed, scan/pair polling is more hardened, keyboard/focus behavior is better, result rows
are more honest about what is clickable, and scan outcomes no longer fail in edge cases that previously caused confusing UI behavior.

:white_check_mark: A number of stubborn edge cases were fixed across RC/stable installs — this includes better standalone/LXC release-ref persistence, cleaner RC update completion behavior, clearer mixed onboarding
states, better prioritization of adapter-access failures, and several UI fixes around checklist rendering, step indicators, and diagnostics-driven recovery flows.

Really liking where this project is going! I too am having this issue on my RPi. When I run docker exec sendspin-client id I can see it’s running as root, but I am unsure how to resolve this.

Sure - that’s actually a very useful clue.

On Raspberry Pi / Docker installs that use the host’s user-scoped PipeWire/PulseAudio socket, seeing root inside the container can be part of the problem. The socket may be mounted correctly, but the audio server can still refuse the connection if the process inside the container is not running as the same user/session that owns the host audio socket.

Could you paste the output of these commands?

   docker exec sendspin-client ls -la /run/user/1000/pulse/
   docker exec sendspin-client env | grep -E 'PULSE|XDG'
   docker exec sendspin-client id
   docker inspect sendspin-client --format '{{json .Mounts}}'

And on the host as well:

   id
   pactl info
   ls -la /run/user/1000/pulse/

If you want, you can also try one quick diagnostic test in docker-compose.yml:

user: "${AUDIO_UID:-1000}:${AUDIO_UID:-1000}"

Then restart the container and see whether audio starts working. I would treat that as a test for now rather than the final fix, but it would tell us very quickly whether this is a UID/session
mismatch with the host audio socket.

If you post those outputs, I should be able to narrow it down pretty quickly.

Thank you! So I added the user to the docker-compose file, and it gets the pulse info! (Note there seems to be a startup race condition where running docker exec sendspin-client pactl info after reboot cannot connect until I restart the container. However, now I get internal server errors authenticating Music Assistant, and even reading the server logs, nor can I pair and connect the speakers.

For reference (and for anyone else who stumbles upon this). This was my original output of the commands before adding the user.

**docker exec sendspin-client ls -la /run/user/1000/pulse/**
total 4
drwxr-xr-x 2 root root 40 Mar 23 17:57 .
drwxr-xr-x 4 root root 4096 Mar 23 17:50 ..
**docker exec sendspin-client env | grep -E 'PULSE|XDG'**
PULSE_SERVER=unix:/run/user/1000/pulse/native
XDG_RUNTIME_DIR=/run/user/1000
**docker exec sendspin-client id**
uid=0(root) gid=0(root) groups=0(root)
**docker inspect sendspin-client --format '{{json .Mounts}}'**
[{"Type":"bind","Source":"/etc/docker/Sendspin","Destination":"/config","Mode":"rw","RW":true,"Propagation":"rprivate"},{"Type":"bind","Source":"/run/user/1000/pipewire-0","Destination":"/run/user/1000/pipewire-0","Mode":"rw","RW":true,"Propagation":"rprivate"},{"Type":"bind","Source":"/run/user/1000/pulse","Destination":"/run/user/1000/pulse","Mode":"rw","RW":true,"Propagation":"rprivate"},{"Type":"bind","Source":"/var/run/dbus","Destination":"/var/run/dbus","Mode":"rw","RW":true,"Propagation":"rprivate"}]
**id**
uid=1000(pi) gid=1000(pi) groups=1000(pi),4(adm),20(dialout),24(cdrom),27(sudo),29(audio),44(video),46(plugdev),60(games),100(users),102(input),105(render),110(netdev),992(docker),993(gpio),994(i2c),995(spi)
**pactl info**
Server String: /run/user/1000/pulse/native
Library Protocol Version: 35
Server Protocol Version: 35
Is Local: yes
Client Index: 69
Tile Size: 65496
User Name: pi
Host Name: rpi-outsidespeakers
Server Name: pulseaudio
Server Version: 16.1
Default Sample Specification: s16le 2ch 44100Hz
Default Channel Map: front-left,front-right
Default Sink: bluez_sink.F8_5C_7E_2A_0B_E0.a2dp_sink
Default Source: bluez_sink.F8_5C_7E_2A_0B_E0.a2dp_sink.monitor
Cookie: 4175:418b
**ls -la /run/user/1000/pulse/**
total 4
drwx------ 2 pi pi  80 Mar 23 17:58 .
drwx------ 7 pi pi 160 Mar 23 18:38 ..
srw-rw-rw- 1 pi pi   0 Mar 23 17:58 native
-rw------- 1 pi pi   5 Mar 23 17:58 pid

That’s very helpful — it looks like the user: override confirmed the audio-side issue, but it probably introduced two new ones.

First, the Music Assistant 500 is very likely a config write permission issue. After login, the bridge saves the MA token into /config/config.json, so if /etc/docker/Sendspin is still owned by root, running the container as UID 1000 can make that fail.

Please check these on the host:

sudo ls -ld /etc/docker/Sendspin /etc/docker/Sendspin/config.json
   docker exec sendspin-client sh -lc 'id && ls -ld /config /config/config.json && test -w /config && echo config-writable'

If needed, temporarily fix that with:
sudo chown -R 1000:1000 /etc/docker/Sendspin

Second, for logs, please use the host command instead of the in-app log viewer for now:
docker logs --tail 200 sendspin-client

And for Bluetooth, please run:

  docker exec sendspin-client bluetoothctl show
   docker exec sendspin-client bluetoothctl devices
   docker exec sendspin-client bluetoothctl info <YOUR_SPEAKER_MAC>

One more thing: the reboot-only pactl failure does sound like a user-session audio startup race. Since you’re using /run/user/1000/..., please also check on the host after reboot:

   loginctl show-user pi
   systemctl --user status pulseaudio.service pulseaudio.socket

If you want, send me the output of those commands and I’ll narrow it down from there.

I’ve just published 2.46.0-rc.3, which specifically tries to fix this Raspberry Pi / Docker audio case.

Could you please try the new RC with your Pi?

  1. Update the image to:
    ghcr.io/trudenboy/sendspin-bt-bridge:v2.46.0-rc.3
  2. Remove the manual user: line from docker-compose.yml
  3. Keep AUDIO_UID=1000
  4. Recreate the container:
    docker compose pull
    docker compose up -d --force-recreate
    
    

If it still fails, please send:

   docker logs --tail 120 sendspin-client
   docker exec sendspin-client ps -o user:20,pid,command -C python3

OK, probably being thick, but I cannot pull Package sendspin-bt-bridge · GitHub
Image Package sendspin-bt-bridge · GitHub Error manifest unknown

Thanks - I checked on my side, and 2.46.0-rc.3 has already been published, so this looks like a temporary GHCR propagation delay.

Could you please try again now with:

docker pull ghcr.io/trudenboy/sendspin-bt-bridge:2.46.0-rc.3

If it still says manifest unknown, wait a few minutes and retry once more — GHCR sometimes lags a bit right after a new tag is pushed.

I probably should have said I am doing this on an old Pi 2B, so ARM 7 (I am now getting no matching manifest for linux/arm/v7 in the manifest list entries). I am using this as a replacement to an old setup I had for 2 outdoor speakers I had running using two instances of Snapcast.

Changing the permissions, I can now connect to MA, and can connect to the speakers, but I get a JBL Flip 6 @ rpi-outsidespeakers lost bridge transport error, but I do get an audio sink.

Logs:

2026-03-23 20:13:09,729 - __main__ - WARNING - [JBL Flip 6 @ rpi-outsidespeakers] daemon stderr:   File "/app/services/daemon_process.py", line 576, in <module>
2026-03-23 20:13:09,731 - __main__ - WARNING - [JBL Flip 6 @ rpi-outsidespeakers] daemon stderr:     main()
2026-03-23 20:13:09,733 - __main__ - WARNING - [JBL Flip 6 @ rpi-outsidespeakers] daemon stderr:   File "/app/services/daemon_process.py", line 572, in main
2026-03-23 20:13:09,733 - __main__ - WARNING - [JBL Flip 6 @ rpi-outsidespeakers] daemon stderr:     asyncio.run(_run(params))
2026-03-23 20:13:09,736 - __main__ - WARNING - [JBL Flip 6 @ rpi-outsidespeakers] daemon stderr:   File "/usr/local/lib/python3.12/asyncio/runners.py", line 195, in run
2026-03-23 20:13:09,737 - __main__ - WARNING - [JBL Flip 6 @ rpi-outsidespeakers] daemon stderr:     return runner.run(main)
2026-03-23 20:13:09,739 - __main__ - WARNING - [JBL Flip 6 @ rpi-outsidespeakers] daemon stderr:            ^^^^^^^^^^^^^^^^
2026-03-23 20:13:09,740 - __main__ - WARNING - [JBL Flip 6 @ rpi-outsidespeakers] daemon stderr:   File "/usr/local/lib/python3.12/asyncio/runners.py", line 118, in run
2026-03-23 20:13:09,742 - __main__ - WARNING - [JBL Flip 6 @ rpi-outsidespeakers] daemon stderr:     return self._loop.run_until_complete(task)
2026-03-23 20:13:09,742 - __main__ - WARNING - [JBL Flip 6 @ rpi-outsidespeakers] daemon stderr:            ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
2026-03-23 20:13:09,744 - __main__ - WARNING - [JBL Flip 6 @ rpi-outsidespeakers] daemon stderr:   File "/usr/local/lib/python3.12/asyncio/base_events.py", line 691, in run_until_complete
2026-03-23 20:13:09,746 - __main__ - WARNING - [JBL Flip 6 @ rpi-outsidespeakers] daemon stderr:     return future.result()
2026-03-23 20:13:09,748 - __main__ - WARNING - [JBL Flip 6 @ rpi-outsidespeakers] daemon stderr:            ^^^^^^^^^^^^^^^
2026-03-23 20:13:09,749 - __main__ - WARNING - [JBL Flip 6 @ rpi-outsidespeakers] daemon stderr:   File "/app/services/daemon_process.py", line 374, in _run
2026-03-23 20:13:09,751 - __main__ - WARNING - [JBL Flip 6 @ rpi-outsidespeakers] daemon stderr:     from sendspin.daemon.daemon import DaemonArgs
2026-03-23 20:13:09,753 - __main__ - WARNING - [JBL Flip 6 @ rpi-outsidespeakers] daemon stderr:   File "/usr/local/lib/python3.12/site-packages/sendspin/daemon/daemon.py", line 28, in <module>
2026-03-23 20:13:09,755 - __main__ - WARNING - [JBL Flip 6 @ rpi-outsidespeakers] daemon stderr:     from sendspin.audio_connector import AudioStreamHandler
2026-03-23 20:13:09,756 - __main__ - WARNING - [JBL Flip 6 @ rpi-outsidespeakers] daemon stderr:   File "/usr/local/lib/python3.12/site-packages/sendspin/audio_connector.py", line 18, in <module>
2026-03-23 20:13:09,758 - __main__ - WARNING - [JBL Flip 6 @ rpi-outsidespeakers] daemon stderr:     from sendspin.decoder import FlacDecoder
2026-03-23 20:13:09,759 - __main__ - WARNING - [JBL Flip 6 @ rpi-outsidespeakers] daemon stderr:   File "/usr/local/lib/python3.12/site-packages/sendspin/decoder.py", line 9, in <module>
2026-03-23 20:13:09,760 - __main__ - WARNING - [JBL Flip 6 @ rpi-outsidespeakers] daemon stderr:     import av
2026-03-23 20:13:09,762 - __main__ - WARNING - [JBL Flip 6 @ rpi-outsidespeakers] daemon stderr:   File "/usr/local/lib/python3.12/site-packages/av/__init__.py", line 3, in <module>
2026-03-23 20:13:09,765 - __main__ - WARNING - [JBL Flip 6 @ rpi-outsidespeakers] daemon stderr:     from av._core import time_base, library_versions, ffmpeg_version_info
2026-03-23 20:13:09,768 - __main__ - ERROR - [JBL Flip 6 @ rpi-outsidespeakers] daemon stderr: ImportError: libavformat.so.61: cannot open shared object file: No such file or directory
2026-03-23 20:13:10,013 - config - INFO - Loaded config from /config/config.json
2026-03-23 20:13:10,032 - config - INFO - Loaded config from /config/config.json
Failed to load cookie file from cookie: No such file or directory
Failed to load cookie file from cookie: No such file or directory
2026-03-23 20:13:11,081 - __main__ - WARNING - Daemon subprocess died unexpectedly, restarting in 1s...
2026-03-23 20:13:12,013 - config - INFO - Loaded config from /config/config.json
2026-03-23 20:13:12,038 - config - INFO - Loaded config from /config/config.json
Failed to load cookie file from cookie: No such file or directory
Failed to load cookie file from cookie: No such file or directory
2026-03-23 20:13:12,088 - __main__ - INFO - Starting Sendspin player 'JBL Flip 6 @ rpi-outsidespeakers' with auto-discovery (port 8928)
2026-03-23 20:13:12,090 - __main__ - INFO - [JBL Flip 6 @ rpi-outsidespeakers] Subprocess PULSE_SINK=bluez_sink.F8_5C_7E_2A_0B_E0.a2dp_sink
2026-03-23 20:13:12,101 - __main__ - INFO - Sendspin daemon subprocess started (PID 844) for 'JBL Flip 6 @ rpi-outsidespeakers'
2026-03-23 20:13:14,012 - config - INFO - Loaded config from /config/config.json
2026-03-23 20:13:14,028 - config - INFO - Loaded config from /config/config.json
Failed to load cookie file from cookie: No such file or directory
Failed to load cookie file from cookie: No such file or directory
2026-03-23 20:13:16,008 - config - INFO - Loaded config from /config/config.json
2026-03-23 20:13:16,027 - config - INFO - Loaded config from /config/config.json
Failed to load cookie file from cookie: No such file or directory
Failed to load cookie file from cookie: No such file or directory
2026-03-23 20:13:18,009 - config - INFO - Loaded config from /config/config.json
2026-03-23 20:13:18,026 - config - INFO - Loaded config from /config/config.json
Failed to load cookie file from cookie: No such file or directory
Failed to load cookie file from cookie: No such file or directory
2026-03-23 20:13:20,018 - config - INFO - Loaded config from /config/config.json
2026-03-23 20:13:20,035 - config - INFO - Loaded config from /config/config.json
Failed to load cookie file from cookie: No such file or directory
Failed to load cookie file from cookie: No such file or directory
2026-03-23 20:13:22,016 - config - INFO - Loaded config from /config/config.json
2026-03-23 20:13:22,033 - config - INFO - Loaded config from /config/config.json
Failed to load cookie file from cookie: No such file or directory
Failed to load cookie file from cookie: No such file or directory
2026-03-23 20:13:24,017 - config - INFO - Loaded config from /config/config.json
2026-03-23 20:13:24,036 - config - INFO - Loaded config from /config/config.json
Failed to load cookie file from cookie: No such file or directory
Failed to load cookie file from cookie: No such file or directory
2026-03-23 20:13:26,017 - config - INFO - Loaded config from /config/config.json
2026-03-23 20:13:26,036 - config - INFO - Loaded config from /config/config.json
Failed to load cookie file from cookie: No such file or directory
Failed to load cookie file from cookie: No such file or directory
2026-03-23 20:13:28,011 - config - INFO - Loaded config from /config/config.json
2026-03-23 20:13:28,027 - config - INFO - Loaded config from /config/config.json
Failed to load cookie file from cookie: No such file or directory
Failed to load cookie file from cookie: No such file or directory
2026-03-23 20:13:28,167 - services.ma_runtime_state - INFO - MA syncgroup cache updated: 0 mapped, 1 total group(s)
2026-03-23 20:13:30,008 - config - INFO - Loaded config from /config/config.json
2026-03-23 20:13:30,025 - config - INFO - Loaded config from /config/config.json
Failed to load cookie file from cookie: No such file or directory
Failed to load cookie file from cookie: No such file or directory
2026-03-23 20:13:30,084 - __main__ - ERROR - [JBL Flip 6 @ rpi-outsidespeakers] daemon stderr: Traceback (most recent call last):
2026-03-23 20:13:30,085 - __main__ - WARNING - [JBL Flip 6 @ rpi-outsidespeakers] daemon stderr:   File "<frozen runpy>", line 198, in _run_module_as_main
2026-03-23 20:13:30,085 - __main__ - WARNING - [JBL Flip 6 @ rpi-outsidespeakers] daemon stderr:   File "<frozen runpy>", line 88, in _run_code
2026-03-23 20:13:30,086 - __main__ - WARNING - [JBL Flip 6 @ rpi-outsidespeakers] daemon stderr:   File "/app/services/daemon_process.py", line 576, in <module>
2026-03-23 20:13:30,087 - __main__ - WARNING - [JBL Flip 6 @ rpi-outsidespeakers] daemon stderr:     main()
2026-03-23 20:13:30,088 - __main__ - WARNING - [JBL Flip 6 @ rpi-outsidespeakers] daemon stderr:   File "/app/services/daemon_process.py", line 572, in main
2026-03-23 20:13:30,089 - __main__ - WARNING - [JBL Flip 6 @ rpi-outsidespeakers] daemon stderr:     asyncio.run(_run(params))
2026-03-23 20:13:30,090 - __main__ - WARNING - [JBL Flip 6 @ rpi-outsidespeakers] daemon stderr:   File "/usr/local/lib/python3.12/asyncio/runners.py", line 195, in run
2026-03-23 20:13:30,092 - __main__ - WARNING - [JBL Flip 6 @ rpi-outsidespeakers] daemon stderr:     return runner.run(main)
2026-03-23 20:13:30,093 - __main__ - WARNING - [JBL Flip 6 @ rpi-outsidespeakers] daemon stderr:            ^^^^^^^^^^^^^^^^
2026-03-23 20:13:30,094 - __main__ - WARNING - [JBL Flip 6 @ rpi-outsidespeakers] daemon stderr:   File "/usr/local/lib/python3.12/asyncio/runners.py", line 118, in run
2026-03-23 20:13:30,095 - __main__ - WARNING - [JBL Flip 6 @ rpi-outsidespeakers] daemon stderr:     return self._loop.run_until_complete(task)
2026-03-23 20:13:30,096 - __main__ - WARNING - [JBL Flip 6 @ rpi-outsidespeakers] daemon stderr:            ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
2026-03-23 20:13:30,097 - __main__ - WARNING - [JBL Flip 6 @ rpi-outsidespeakers] daemon stderr:   File "/usr/local/lib/python3.12/asyncio/base_events.py", line 691, in run_until_complete
2026-03-23 20:13:30,098 - __main__ - WARNING - [JBL Flip 6 @ rpi-outsidespeakers] daemon stderr:     return future.result()
2026-03-23 20:13:30,100 - __main__ - WARNING - [JBL Flip 6 @ rpi-outsidespeakers] daemon stderr:            ^^^^^^^^^^^^^^^
2026-03-23 20:13:30,100 - __main__ - WARNING - [JBL Flip 6 @ rpi-outsidespeakers] daemon stderr:   File "/app/services/daemon_process.py", line 374, in _run
2026-03-23 20:13:30,103 - __main__ - WARNING - [JBL Flip 6 @ rpi-outsidespeakers] daemon stderr:     from sendspin.daemon.daemon import DaemonArgs
2026-03-23 20:13:30,104 - __main__ - WARNING - [JBL Flip 6 @ rpi-outsidespeakers] daemon stderr:   File "/usr/local/lib/python3.12/site-packages/sendspin/daemon/daemon.py", line 28, in <module>
2026-03-23 20:13:30,106 - __main__ - WARNING - [JBL Flip 6 @ rpi-outsidespeakers] daemon stderr:     from sendspin.audio_connector import AudioStreamHandler
2026-03-23 20:13:30,107 - __main__ - WARNING - [JBL Flip 6 @ rpi-outsidespeakers] daemon stderr:   File "/usr/local/lib/python3.12/site-packages/sendspin/audio_connector.py", line 18, in <module>
2026-03-23 20:13:30,109 - __main__ - WARNING - [JBL Flip 6 @ rpi-outsidespeakers] daemon stderr:     from sendspin.decoder import FlacDecoder
2026-03-23 20:13:30,110 - __main__ - WARNING - [JBL Flip 6 @ rpi-outsidespeakers] daemon stderr:   File "/usr/local/lib/python3.12/site-packages/sendspin/decoder.py", line 9, in <module>
2026-03-23 20:13:30,112 - __main__ - WARNING - [JBL Flip 6 @ rpi-outsidespeakers] daemon stderr:     import av
2026-03-23 20:13:30,113 - __main__ - WARNING - [JBL Flip 6 @ rpi-outsidespeakers] daemon stderr:   File "/usr/local/lib/python3.12/site-packages/av/__init__.py", line 3, in <module>
2026-03-23 20:13:30,114 - __main__ - WARNING - [JBL Flip 6 @ rpi-outsidespeakers] daemon stderr:     from av._core import time_base, library_versions, ffmpeg_version_info
2026-03-23 20:13:30,117 - __main__ - ERROR - [JBL Flip 6 @ rpi-outsidespeakers] daemon stderr: ImportError: libavformat.so.61: cannot open shared object file: No such file or directory
2026-03-23 20:13:32,013 - config - INFO - Loaded config from /config/config.json
2026-03-23 20:13:32,034 - config - INFO - Loaded config from /config/config.json
Failed to load cookie file from cookie: No such file or directory
Failed to load cookie file from cookie: No such file or directory
2026-03-23 20:13:32,114 - __main__ - WARNING - Daemon subprocess died unexpectedly, restarting in 1s...
2026-03-23 20:13:33,119 - __main__ - INFO - Starting Sendspin player 'JBL Flip 6 @ rpi-outsidespeakers' with auto-discovery (port 8928)
2026-03-23 20:13:33,121 - __main__ - INFO - [JBL Flip 6 @ rpi-outsidespeakers] Subprocess PULSE_SINK=bluez_sink.F8_5C_7E_2A_0B_E0.a2dp_sink
2026-03-23 20:13:33,131 - __main__ - INFO - Sendspin daemon subprocess started (PID 883) for 'JBL Flip 6 @ rpi-outsidespeakers'
2026-03-23 20:13:34,010 - config - INFO - Loaded config from /config/config.json
2026-03-23 20:13:34,026 - config - INFO - Loaded config from /config/config.json
Failed to load cookie file from cookie: No such file or directory
Failed to load cookie file from cookie: No such file or directory
2026-03-23 20:13:36,018 - config - INFO - Loaded config from /config/config.json
2026-03-23 20:13:36,034 - config - INFO - Loaded config from /config/config.json
Failed to load cookie file from cookie: No such file or directory
Failed to load cookie file from cookie: No such file or directory
2026-03-23 20:13:38,010 - config - INFO - Loaded config from /config/config.json
2026-03-23 20:13:38,028 - config - INFO - Loaded config from /config/config.json
Failed to load cookie file from cookie: No such file or directory
Failed to load cookie file from cookie: No such file or directory

EDIT: Just realised the version of ffmpeg I installed seems to be older than the version required, so I have an older library file. Looking at options…

Thanks, that makes sense — no point making you wait ~1.5 hours for a local build on an old Pi 2B.

The libavformat.so.61 error is enough for me to see this is an arm/v7 image packaging issue on my side, not just a config problem on your setup.

So please don’t build locally for now. I’ll fix the armv7 image path and publish a new tag, then I’ll ping you here to try it again.