feat: play your network music library on the speaker, plus four carried-over fixes - #587
Conversation
The two config writes ran in the wrong order. envswitch commits the runtime layer as it stands when it runs, so the sys configuration write that followed it was discarded at the next boot. The fallback path we believed existed for chassis we cannot measure was never actually there, and bmxRegistryUrl and statsServerUrl, which have no envswitch form at all, could not be set by any route. Measured on a SoundTouch 20 (spotty, FW 27.0.6) with the box poisoned first so a pass could not be inherited from existing config: gesellix/Bose-SoundTouch#471. Confirmed live on a stock ST10 with no stick and SSH closed, which is the only state that runs this path: SSH opened 72 s after the reboot. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01W4ZJXsnpmJuazCKF6ijdcZ
…the panel The call that fills in the stereo balance had been placed inside the markup string instead of after it, so the Multi-Room page printed it verbatim and the balance never loaded. It now runs once the markup exists, and only when a pair is actually present. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01W4ZJXsnpmJuazCKF6ijdcZ
…speaker has A diagnostic report described everything about a speaker except what it can play from. A CineMate 130 owner reported that the new "offer every input" feature still showed him Bluetooth alone (discussion #577), and his report could not say why, because nothing in it listed his inputs. The speaker's own source list is now part of the report: every input and service slot with its name, its status and whether it is a socket. Linked streaming accounts are replaced by a hash like the other personal data, while socket names such as AUX, TV or CBL-Sat stay readable, since those are the part that has to be readable for the report to be worth anything. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01W4ZJXsnpmJuazCKF6ijdcZ
…just one Undo was only ever sent to the speaker recorded as the pair master, on the understanding that the master is the one that knows about the pair. That holds while a pair is healthy and stops holding the moment one half lets go. Measured on two SoundTouch 10s: the master reported no pair at all while the other speaker still held the entire pair, naming the master as the left channel. Every undo went to the master, was told there was nothing to undo, and the panel then said "current stereo pair" and "there is no stereo pair to undo" at the same time, with no way to clear it from the app. Sending it to the other speaker cleared both at once. Both speakers now get it, the master first. A speaker that is not in a pair answers that there is nothing to undo and is left alone, so the only case this changes is the one that was stuck. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01W4ZJXsnpmJuazCKF6ijdcZ
Speakers can see the media servers on your network, a FRITZ!Box, a NAS, a Plex box, but they will not play from one until that server is added as a music source. STR can now add it, in the app and in the phone remote. Once added, the speaker browses and plays the library on its own, and the library also appears in the original Bose app. The speaker forgets the setting on every restart, roughly a minute into the boot, so STR remembers which servers you enabled and puts them back. That restore reads before it writes: nothing is written when nothing is missing, so it cannot keep a speaker out of deep standby. Adding is not instant. The speaker accepts it at once and then confirms the account before the library can be used, so both interfaces say it is on its way instead of reporting a source that is not there yet. Verified end to end on a Portable against a FRITZ!Box 6690: added, browsed down to a track, played it natively with no help from STR, and confirmed it comes back by itself after a reboot. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01W4ZJXsnpmJuazCKF6ijdcZ
Added: network music library as a native speaker sourceA speaker can see the DLNA/UPnP media servers on the LAN by itself ( Once registered the speaker browses and plays the library natively: no stream proxy, no UPnP push from STR, and the library also appears in the original Bose app. The wire format, since none of it is in Bose'"'"'s public API docThe account is the server id from Browsing ( The part that needed STRThe registration does not survive a reboot: measured, the source is present for the first ~70 s of the boot and then disappears, because the box re-checks the account against marge and STR'"'"'s record of it lived only in memory. So the user'"'"'s choice is persisted ( Verified on hardware (Portable, FRITZ!Box 6690)
New UI strings are in all 12 locales in both interfaces. |
| } | ||
| } | ||
| if err := c.UnregisterMediaServer(ctx, boxapi.MediaServer{ID: id, FriendlyName: name}); err != nil { | ||
| s.logger.Warn("media server: the speaker refused the removal", "err", err, "id", id) |
| http.Error(w, err.Error(), http.StatusBadGateway) | ||
| return | ||
| } | ||
| s.logger.Info("media server: removed as a music source", "id", id) |
… of being told The library was kept alive by writing to the speaker after every restart. It is now simply part of the account the speaker reads at boot, the same way its radio stations arrive, so it is there again without anybody sending it anything. That is how the original setup worked, and it is better for the speaker: the old way meant a write on every start, and writes are what reset the countdown into deep standby. There is now no write at all on that path. The speaker is still told directly at the moment somebody adds a library, and only then, so it can be used straight away rather than after the next restart. Adding one it already has is now recognised and skipped, instead of being reported as a failure. Measured on a Portable: the library was removed from the speaker completely, the agent restarted, and the speaker fetched it back on its own account read with no registration call anywhere in the log. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01W4ZJXsnpmJuazCKF6ijdcZ
Correction: the registration is a PULL, not a pushJens pointed out that the original design registered a media server per Bose account and that the boxes polled for it, the way radio arrives, rather than the cloud pushing to them. That is right, and the marge request log proves it. At boot the box does: The box reads the account document and keeps whatever sources it advertises. So putting a media server in that document IS the persistence mechanism. Verified by removing the source from the speaker entirely ( This deletes the push-based The push to
The source shape is pinned by a test against One cosmetic difference: a pulled source shows in |
| // that works, just not until the speaker next reads its account. | ||
| if s.mediaServers != nil { | ||
| if err := s.mediaServers.Add(mediaservers.Server{ID: srv.ID, Name: srv.FriendlyName}); err != nil { | ||
| s.logger.Warn("media server: could not remember the server", "err", err, "id", srv.ID) |
| // perfectly healthy source as a failure. | ||
| pending := false | ||
| if have, herr := c.RegisteredMediaServerAccounts(ctx); herr == nil && have[srv.SourceAccount()] { | ||
| s.logger.Info("media server: already known to the speaker, nothing to push", "id", srv.ID) |
| // Not an error the user needs to see as failure: the setting is | ||
| // stored, so the library turns up after the speaker's next restart. | ||
| s.logger.Warn("media server: the speaker refused the immediate registration, it will appear after a restart", | ||
| "err", err, "id", srv.ID) |
| // measured. Never report this as ready. | ||
| pending = true | ||
| } | ||
| s.logger.Info("media server: enabled as a native music source", "id", srv.ID, "name", srv.FriendlyName) |
| // measured. Never report this as ready. | ||
| pending = true | ||
| } | ||
| s.logger.Info("media server: enabled as a native music source", "id", srv.ID, "name", srv.FriendlyName) |
| // stored intent that puts the source back at the next boot. | ||
| if s.mediaServers != nil { | ||
| if err := s.mediaServers.Remove(id); err != nil { | ||
| s.logger.Warn("media server: could not forget the server", "err", err, "id", id) |
…e media server discovery Comparing against JRpersonal/streborn#587 surfaced two gaps: no test pinned that a newly added source type renders the same element shape as a known-good default (the firmware rejects the whole account document if one source entry omits an expected element), and our DLNA discovery only swept SSDP from the service host, missing servers only visible from a paired speaker's own LAN segment. Adds TestSourceXMLShapeConsistencyAcrossTypes in pkg/service/marge, and has HandleDiscoverLibraryServers merge results from each paired speaker's own /listMediaServers alongside the existing SSDP sweep, deduped by UDN, with unreachable speakers skipped silently. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Four fixes, all verified against live hardware or by test.
1. SSH unlock on a stock speaker survives the reboot
buildBootstrapEnableSSHCommandsissuedenvswitchbeforesys configuration.envswitchcommits the runtime layer as it stands when it runs, so thesys configurationwrite that followed was discarded at the next boot.bmxRegistryUrlandstatsServerUrlhave noenvswitchform at all, sosys configurationis their only route and it never persisted.Measured by @bitranox on a SoundTouch 20 (spotty, FW 27.0.6) in gesellix/Bose-SoundTouch#471. The method is why it is trusted: an already-migrated box was poisoned first, all four URLs pointed at an unreachable address and confirmed to survive a reboot of their own, so a pass could not be inherited from existing config.
Confirmed live on a stock rhino ST10, no stick, SSH closed, which is the only state that runs this path: SSH opened 72 s after the reboot. The order is now pinned by the test.
2. Undoing a stereo pair reaches both speakers
Undo went only to the speaker recorded as pair master, on the understanding that the master is the half that knows about the pair. Measured 2026-08-10 on two SoundTouch 10s, that stops being true once one half lets go: the master answered
/getGroupwith<group />while the other speaker still held the whole document naming the master as LEFT,status GROUP_ERROR.So every undo was told "nothing to undo" while the panel simultaneously named the pair, and the leftover could not be cleared from the app at all. Sending
DELETE /api/box/zone?stereo=1to.49cleared both speakers in one call, so the agent-side teardown was already correct; only the addressing was wrong.Both halves now get the undo, master first. A speaker that is not in a pair answers "nothing to undo" and is left alone, so the only behaviour that changes is the stuck case. Target selection moved into
groups.jsasstereoUndoTargetsand is covered by tests.3. The diagnostic report records which inputs and services a speaker has
A CineMate 130 owner reported that the "offer every input" feature still showed him Bluetooth alone (discussion #577), and his report could not say why: nothing in a bundle described his inputs.
/sourcesis now captured per box. Linked streaming accounts are hashed like the other personal data (ACCT#...), while socket labels (AUX, TV, CBL-Sat) and the firmware's...UserNameplaceholder slots stay readable, since those are the part that has to be readable for the field to be worth anything.4. Stray code in the Multi-Room panel
The
fillPairBalancecall sat inside theinnerHTMLtemplate literal instead of after it, so the page printed the line verbatim and the balance never loaded.Verification
go build ./...clean,go vetclean,gofmtcleango test ./...indesktop-apppassesnpm testindesktop-app/frontend: 102 tests pass.48/.49, both now report{"members":[]}