Compare commits

..
Author SHA1 Message Date
Tobias GesellchenandClaude Opus 5 62da5f23e0 TEMPORARY: trace speaker HTTP and player WS/HTTP traffic
Not for merge. Measures the source-selection readback cost in PR #670
review finding 2.

Server side: an http.RoundTripper wrapper on the speaker client logs every
outgoing request with a sequence number, path, status and duration. One
hook catches get/post/postWithResponse/avtransport alike. Enabled by
AFTERTOUCH_TRACE_SPEAKER=1.

Browser side: wraps fetch and WebSocket to log the player's HTTP calls and
every speaker event frame, with __trace.mark()/__trace.report() to bracket
and summarise one interaction. Enabled by localStorage.aftertouchTrace='1'.

Both are off by default, so a stray build stays silent. Delete
pkg/client/trace_temp.go, static/js/trace_temp.js, and their two call sites
when done.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-05 20:39:45 +02:00
Tobias GesellchenandClaude Opus 5 fee9fa76ad docs(client): correct which sources a bare select actually works for
SOURCE-SELECTION.md listed TUNEIN, RADIO_BROWSER and STORED_MUSIC among the
sources selectable with a source and account alone, and showed SelectTuneIn
as an example. That is the trap: a speaker answers 200 for such a select,
parks on a stub now-playing, and carries on playing whatever it was playing,
so callers checking /now_playing see the source they asked for while the
audio is something else.

Splits the list into sources a bare select works for and sources that need a
ContentItem with a Location, replaces the SelectTuneIn example with
SelectContentItem, and carries the same warning into the doc comments on
SelectSource and SelectTuneIn, where a developer reaching for them will
actually see it. Also points the player's README at the new reference.

No behaviour change; comments and documentation only.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-05 20:36:58 +02:00
Tobias GesellchenandClaude Opus 5 cf2edd526d docs(player): document source selection behaviour and state ordering
The player had no reference covering how a source button behaves or how a
selection is confirmed, and both have non-obvious answers learned from real
hardware.

Records what the speaker actually does: that a READY source is not
necessarily selectable, that a bare select against a provider parks it on a
stub now-playing while the previous audio carries on, and the four-part
signature of that stub. Then the resulting click behaviour per source, the
confirmation model (event stream first, bounded readbacks as fallback,
definitive versus ambiguous write failures), the revision/epoch ordering the
browser relies on, and why source staleness needs two consecutive failures.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-05 20:36:58 +02:00
Tobias GesellchenandClaude Opus 5 c7319b383c fix(player): open the Library for STORED_MUSIC instead of selecting it
A STORED_MUSIC entry names a media server, not something to play: its
sourceAccount is a server UDN, and there is one entry per server. Selecting
it identifies no track or container, so browsing is the only meaningful
action.

Clicking one now opens the Library. It never resumes from Recents either:
even a resumable album there is a worse guess than showing the user what is
on the server. The clicked server is not carried over, matching how the other
browser pages let you pick a device rather than inheriting one.

Servers that are offline never reach this path anyway: the speaker reports
them status="UNAVAILABLE" and the source list only renders READY entries.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-05 20:36:58 +02:00
Tobias GesellchenandClaude Opus 5 2bf171e8e8 fix(player): never resume LOCAL_INTERNET_RADIO from recents
Clicking it played the AfterTouch notification ding. That was the newest
Recents entry for the source, and resuming the newest entry is what the
provider path does.

The source is not like the other two. AfterTouch plays its own one-shot audio
through it: TTS and the ding both go out over /custom/v1/playback/. So its
Recents mix notifications with stations, and the newest entry is as likely to
be a ding as anything worth replaying. On the test speaker the only
LOCAL_INTERNET_RADIO recent WAS the ding.

The announcement proxy path is distinguishable from Play URL's
BuildOrionLocation, so filtering was possible, but the same path also carries
URLs played through the CLI, and any future feature that injects audio would
have to remember to stay out of it. Opening Play URL unconditionally, which
is the page that emits content for this source, does not depend on being able
to classify what happens to be in Recents.

RADIO_BROWSER and TUNEIN keep resuming: nothing writes one-shot audio to
them, and on the test speaker their recents held real stations.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-05 20:36:58 +02:00
Tobias GesellchenandClaude Opus 5 2c1ac79ddd fix(player): never confirm a source the speaker reports as not playing
PROVIDER_SOURCES covers the sources known to produce the stub now-playing.
It cannot cover the ones we have not tested, ALEXA among them, and a source
list is whatever the speaker chooses to advertise.

So the readback now refuses to confirm the stub itself, wherever it comes
from: a now-playing that names the source we asked for but reports no
location, no play status, and an item name echoing the source is reported as
a failure rather than a success. That shape is what a speaker returns for a
select it accepted but cannot act on.

All three conditions are required together. A physical input reports no
location and no item name of its own yet is genuinely playing, so any one
condition alone would reject real selections; a test covers exactly that
case, confirming with PlayStatus set and no location.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-05 20:36:58 +02:00
Tobias GesellchenandClaude Opus 5 1e7dae35aa fix(player): treat LOCAL_INTERNET_RADIO as a provider source too
Confirmed on real hardware: selecting it bare produces the byte-identical
stub RADIO_BROWSER produced, with the source name echoed as the item name,
empty type and location, isPresetable false, and PlayStatus empty, while the
previously playing stream carries on. Nothing about the speaker's audio
changes; only /now_playing does.

Its fallback browser is Play URL, which is the page in this app that emits
that source: HandlePlayURL builds a ContentItem with
Source "LOCAL_INTERNET_RADIO".

ALEXA is advertised READY as well but stays unlisted: whether a bare select
resumes anything for it is still unverified.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-05 20:36:58 +02:00
Tobias GesellchenandClaude Opus 5 21af353472 fix(player): don't strand the speaker on a bare provider-source select
A speaker advertises RADIO_BROWSER and TUNEIN in /sources with
status="READY", so the player rendered them as ordinary source buttons and
issued the same bare /select it uses for AUX or SPOTIFY: source and account,
no ContentItem.

They are not selectable inputs. Playing one needs a station ContentItem
carrying a Location, which is what stations.ResolveContentItem builds and
what HandlePlayRadioBrowser sends. Given a bare select the speaker accepts
the command and parks on a stub now-playing instead, observed on real
hardware:

  <nowPlaying source="RADIO_BROWSER" sourceAccount="">
    <ContentItem source="RADIO_BROWSER" type="" location="" isPresetable="false">
      <itemName>RADIO_BROWSER</itemName>
    </ContentItem>
  </nowPlaying>

Empty type, empty location, itemName echoing the source name, and no
playStatus, while the previous audio keeps playing. The speaker then reports
that stub indefinitely, so the player shows RadioBrowser while Spotify is
audible. Worse, the readback sees the source it asked for and confirms
"Source selected" for a command that produced a dead state.

Clicking such a source now resumes its most recent station, using the
Recents entry's own ContentItem, which is the real item the speaker was
given and carries the Location a bare select cannot supply. With nothing to
resume, or if the lookup fails, the click navigates to that provider's
browser rather than issuing a select known to strand the speaker.

Only RADIO_BROWSER and TUNEIN are treated this way. LOCAL_INTERNET_RADIO and
ALEXA are advertised READY too, but whether a bare select resumes anything
for them is unverified, so they keep today's behaviour.

api.playChecked mirrors api.selectSource: the command path needs a write
whose failure it can see, while api.play keeps its response-level behaviour
for the callers that already rely on it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-05 20:36:58 +02:00
Tobias GesellchenandClaude Opus 5 7bd2a3d9f5 perf(player): confirm a source with one light readback, not three heavy ones
Selecting a source cost up to ~21 speaker requests. Two multipliers, both
removed here.

A match on the first readback only marked the command provisional and left
the later deadlines running, so the happy path always spent all three. The
reason to keep watching is real: /select answers 200 even for a source the
speaker rejects seconds later, surfacing as a transition to an error source.
But the event stream already reports that transition as it happens, and the
effect watching nowPlayingUpdated already turns it into a failure. Polling
on top is re-asking a question we are subscribed to the answer of. The
remaining readbacks are now kept only when the readback itself reports no
live event stream, which is the case they are actually needed for.

Each readback also fetched the whole device, and HandleAPIDevice runs a full
UpdateDeviceStatus: six sequential speaker calls plus /getGroup on a
stereo-capable model, to answer one question, against a device the readback
may be checking on precisely because it is slow. GET
/devices/{id}/now-playing refreshes only /now_playing and returns the same
shape, under FieldNowPlaying's generation so it still orders against push
events and concurrent polls, and reporting to the health tracker like any
other HTTP round.

A confirmed selection on a speaker with a live event stream now costs one
speaker request instead of about twenty-one. A speaker whose events are not
arriving keeps the full three-readback window, at one request each.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-05 20:36:58 +02:00
Tobias GesellchenandClaude Opus 5 43d085b9e6 fix(player): mark sources stale on repeated failures, not on one
A failed /sources read went through CompleteFieldPoll, so it consumed the
field generation. That let a newer failed read discard an older, still
in-flight read that had succeeded, and since the player renders
disabled=${sourcesStale}, one transient hiccup could disable every source
button until the next fully successful poll, up to 30s later, even though a
valid inventory had just arrived.

A failure carries no inventory, so there is nothing to order and no reason
to spend the generation on it. ApplySourcesRead now splits the two:

  - a success is still fenced by generation, so two successful reads keep
    their ordering and an older one cannot overwrite a newer one, and it
    always clears the marker;
  - a failure is counted instead, and only staleSourcesFailureThreshold in
    a row marks the inventory unusable, matching how
    offlineFailureThreshold already debounces connectivity in this file.

A genuinely unreachable speaker is therefore stale one poll cycle later
than before, and a single dropped read costs nothing.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-05 20:36:58 +02:00
Tobias GesellchenandClaude Opus 5 128158e43e fix(player): reject a losing snapshot entry whole, don't mix it
When a `devices` snapshot lost the revision comparison, the merge kept the
newer status we already held but took the rest of the incoming entry. That
produced a self-inconsistent device: the server derives stereoPair from the
very status.Group the snapshot lost on, so a snapshot captured before a
pair was dissolved restored its projection alongside a status that had
already cleared the group. StereoPair.js then rendered a pair that no
longer exists, with an expectedGroupId pointing at a deleted group.

Keep the entry we hold instead. The cost is that info riding the same
snapshot waits for the next one, bounded at 5s by the periodic devices
frame and in practice much shorter, because whatever produced the newer
status also queued a device-list broadcast.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-05 20:36:58 +02:00
Tobias GesellchenandClaude Opus 5 eec8975633 fix(player): stamp a connection epoch so revisions stay comparable
DeviceStatus.Revision is per-connection and restarts at 0. The browser
compares revisions to decide which frame wins, but nothing in the frame
said which revision sequence it belonged to.

So a device id backed by a fresh DeviceConnection published revisions
starting at 0 while an open tab still held a high revision for that id, and
the tab rejected every later frame for it: a status frozen until reload.
HandleDeleteDevice broadcasts after removal, which covers the ordinary
remove-then-rediscover path, but a discovery sweep re-adding the host
inside that window yields a snapshot that already contains the device at
revision 0, so no device-less snapshot is ever sent.

Every status now carries an Epoch identifying the connection that produced
it, stamped by both SetStatus and UpdateStatus. The browser compares epochs
first and only falls back to revisions within one epoch, so a newer
connection is accepted regardless of its revision and a frame still in
flight from the replaced connection is rejected regardless of its.

nextStatusEpoch is seeded from the wall clock and forced strictly
increasing, so epochs also keep rising across a service restart, where a
plain counter would restart at 0 and reintroduce the same problem. It is
in milliseconds because the browser compares it as a JSON number and a
nanosecond timestamp exceeds Number.MAX_SAFE_INTEGER.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-05 20:36:58 +02:00
Tobias GesellchenandClaude Opus 5 4fe5ad50b4 fix(player): don't retract a push-confirmed source selection
A nowPlayingUpdated event is authoritative evidence that the speaker
switched, and the effect watching it promotes a pending command to
provisional-confirmed. The last readback then overwrote the command
wholesale with outcome: 'unverified', in both its no-match branch and its
catch, without looking at what the command had already become.

So a selection the speaker confirmed by push at ~1s was reported as
"Source selection unverified" when the 10s readback happened to fail or
returned a now-playing that had since moved on.

The readback window closing now settles such a command as confirmed rather
than retracting it; only a command still pending, which nothing ever
confirmed, becomes unverified. A failed command stays failed.

The push-event promotion also has to record confirmedRevision, which it
previously left unset: without it the later-authoritative-source check
would compare against undefined and the projection would never clear.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-05 20:36:58 +02:00
Tobias GesellchenandClaude Opus 5 a3b4036627 test(player): cover NowPlayingRevision advancing for both poll and event
The rebase derives NowPlayingRevision from the existing FieldNowPlaying
generation rather than a counter of its own. Nothing asserted that it
advances down both paths that write the field, a completed poll and a push
event, which is the property the player's readback loop depends on to tell
a fresh now-playing write from an unrelated field's merge.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-05 20:36:58 +02:00
Tobias GesellchenandClaude Opus 5 a4177ec6f2 fix(player): keep verifying a source write unless the refusal is definitive
Selecting a source posts once and then confirms by bounded readback. When
the POST itself failed, the write error was captured into active.writeError
and never surfaced: a rejected command reported nothing for the full 10s
readback window and then a bare "Source selection unverified".

Surfacing it needs a distinction the API layer did not make. checkedReq
collapsed every failure into one Error, but the two cases differ:

  - 4xx: every 4xx on these endpoints is produced before AfterTouch calls
    the speaker (unknown device, unparseable body, empty source, unknown
    action), so the command provably never went out. Nothing can confirm
    it; report the failure at once, with the server's reason.
  - 5xx and transport errors: handleSourceControl reports a failed
    Client.SelectSource through sendControlResponse, which maps any
    speaker-call error to 500. A request that timed out after the speaker
    already switched is indistinguishable from one it never received, so
    the readbacks must keep running and the reason is carried into
    whatever outcome they reach.

checkedReq now tags thrown errors with `definitive`, and only a definitive
refusal cancels the readbacks. Outcome text appends the reason when there
is one, so a firmware rejection also names the error source it saw.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-05 20:36:58 +02:00
Tobias GesellchenandClaude Opus 5 2836ee6494 test(player): use the existing frontend_test wiring for api.test.mjs
main already runs the player's static JS unit tests via `make
test-frontend` over pkg/service/soundtouchweb/frontend_test/*.test.mjs,
wired into .github/workflows/browser-tests.yml. That target was added by
PR #672 and this change predates it.

api.test.mjs was placed in static/js/ instead and run by a second `node
--test` invocation bolted onto test-browser, behind a new NODE variable.
That is a parallel test-wiring convention for one file, and it also mixes
a test into the directory that is served to browsers as static assets.

Move the file next to the other frontend tests and revert the Makefile
change; the existing target and CI job pick it up unchanged.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-05 20:36:58 +02:00
Tobias GesellchenandClaude Opus 5 098cc1c66f fix(player): order refreshDevices like the WebSocket frames
The `devices` and `status_update` frames are now applied by revision, but
refreshDevices() still replaced the whole map with whatever the REST call
returned. A refresh issued before a socket update but answered after it
would put the older status back.

Route it through mergeDevicesSnapshot as well. Entries the snapshot omits
are still dropped, so device removal keeps working, and info/stereoPair
still come from the snapshot even when its status loses the comparison.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-05 20:36:58 +02:00
Tobias GesellchenandClaude Opus 5 8eb719c4d4 fix(player): don't render an empty sources section
Removing the `ready.length === 0` early return meant a device that has not
been polled yet rendered a "Sources" heading with an empty list and a
"Source list unavailable" notice. That reports a problem where there is
none: having read no inventory is not the same as having one we distrust.

The section is hidden again when there is nothing to offer, and the
availability notice is now reserved for the case it was meant for, an
inventory we hold but refuse to act on.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-05 20:36:58 +02:00
Tobias GesellchenandClaude Opus 5 2511b7860e fix(player): mark sources stale on a failed read, not on a timer
The source inventory was expired by a 30s read-time TTL evaluated inside
DeviceConnection.Status(). The status poll ticker is also exactly 30s
(discovery.go), so the two ran in lockstep and every source button went
disabled for the moment before each refresh.

Deriving staleness per read had a second cost: two reads at the same
Revision could disagree about sourcesStale, which is the only reason the
browser needed mergeDerivedStatus to carry that one bit across an
otherwise-rejected frame. Dropping the TTL removes that whole special
case, and with it a class of "equal revision, different derived state"
reasoning.

Sources are now stale exactly when the last /sources read failed, recorded
at merge time so every change advances Revision. An unreachable device
fails its polls, so the case the TTL was meant to cover is still covered.
The newer-failure-fences-older-success ordering is unchanged: the merge
still runs through CompleteFieldPoll(FieldSources, ...).

Drops SourcesReadAt, sourceCacheTTL, sourceCacheStatusAt, the Status()
projection and mergeDerivedStatus. The TTL-boundary tests go with them;
the fencing and recovery tests stay, and one of them no longer has to pin
its read times to the wall clock to avoid the TTL.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-05 20:36:58 +02:00
Lukáš LipinskýandClaude Opus 5 5ea96d0cfa Harden player source selection reconciliation
Use a single POST command with bounded delayed readback, revision-order
REST and WebSocket state, and explicit pending, confirmed, unverified, or
failed outcomes. Mark stale source inventories unusable until a successful
refresh and preserve legacy API error handling outside this command path.

Rebased onto main. One conflict could not be resolved by picking a side:
this change introduced its own per-field fencing (fieldRevision,
deviceStatusFieldRevisions, MergeNowPlaying/MergeVolume/MergePresets/
MergeSources/MergeBass/MergeIsConnected) over the same six fields that
main's StatusField mechanism (BeginFieldPoll/CompleteFieldPoll/
ApplyFieldEvent, added by the #654/#668 stack) already orders. Landing
both would leave two independent generation counters guarding the same
state, which is the divergent-fencing bug class that stack already had to
fix three times. Resolved in favour of main's mechanism:

  - the Merge* methods and their counters are dropped; the WebSocket
    handlers and updateDeviceStatus use main's calls;
  - NowPlayingRevision is derived from the existing FieldNowPlaying
    generation via recordFieldRevision, not a second counter;
  - SetStatus derives Revision from the stored status and supersedes all
    field generations, so a replacement cannot reset the revision a
    browser is ordering on;
  - updateSourcesCache records a failed /sources read through
    CompleteFieldPoll, keeping the newer-failure-fences-older-success
    property the original had via MergeSourcesFailure;
  - the three tests that called the dropped API were ported to it.

Everything else in this change is unmodified.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-05 20:36:58 +02:00
Tobias GesellchenandClaude Sonnet 5 c4e3ad33ad fix(player): use live device name in zone-candidate list
HandleGetZoneCandidates read entry.Device.DeviceInfo (the immutable
discovery-time snapshot) directly instead of Info(), missed when every
other handler in this file was migrated to the live-name accessor. A
speaker renamed after discovery kept showing its stale name in the
"add to my zone" picker indefinitely.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-05 17:30:23 +02:00
Tobias GesellchenandClaude Sonnet 5 268a7f7ac8 fix(player): don't report a successful stereo-pair mutation as failed
refreshDevices() now throws on a failed /api/devices fetch (needed by
removeDeviceAndRefresh's success/failure contract), but StereoPair's
run() awaited it inside the same try that already notified success --
so a refresh/device-list-fetch hiccup after a successful create/rename/
dissolve rolled the UI back into an "operation failed" error state even
though the mutation itself succeeded. Isolate the post-success refresh
so its failure only surfaces as its own, separate notice.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-05 17:30:23 +02:00
Tobias GesellchenandClaude Sonnet 5 4cb6044259 fix(player): stop transport-state observation from fencing FieldConnectivity polls
OnTransportState routed connect/disconnect through
applyConnectionStateEvent, which calls ApplyFieldEvent(FieldConnectivity,
...). That unconditionally bumps FieldConnectivity's applied generation,
so a transient WebSocket transport blip could invalidate a
concurrently-completing, genuinely successful HTTP poll's IsConnected
merge -- exactly the kind of cross-mechanism staleness bug the per-field
fencing was built to prevent. ObserveEventStreamTransport already derives
IsConnected (via applyConnectivityLocked) from the same transport signal,
so the extra call was redundant as well as unsafe.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-05 17:30:23 +02:00
Lukáš Lipinský d045812288 fix(player): accept removal completed by reseed 2026-09-05 17:30:23 +02:00
Lukáš Lipinský b07375d23d fix(player): satisfy projection lint 2026-09-05 17:30:23 +02:00
Lukáš Lipinský 60788bfa90 fix(player): harden device state projection 2026-09-05 17:30:23 +02:00
Tobias GesellchenandClaude Sonnet 5 4d6bf9e731 refactor(tunein): unify the type-dispatch switch, surface unrecognized types instead of dropping or guessing
tuneInSearchSection, TuneInSearchNext, and tuneInProfileNavItem each
implemented the same Station/PlayItem/Topic/Program/Profile dispatch,
but disagreed on what to do with anything else: the first two had no
default case and silently dropped the item entirely (hiding real
content with no trace it existed), while the third treated any
unrecognized type as directly playable with no indication that was a
guess.

Consolidated into one tuneInClassifyItem, used by all three, that
takes a middle path instead of picking one side: an unrecognized type
still gets a playback link (nothing is silently hidden), but its
Subtitle is marked ("Unrecognized type, may not play") so a playback
attempt that doesn't pan out reads as an unsupported content type
rather than a mystery broken link. The player UI (TuneInBrowser.js)
already renders Subtitle verbatim, so this is visible to users, not
just present in the API response.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-05 14:24:10 +02:00
Tobias GesellchenandClaude Sonnet 5 7b2c10507f refactor(tunein): route HandleTuneInNavigate through stations.Navigate
handlers_bmx_tunein.go's parseTuneInNavigatePath and stations.go's
navigateTuneIn were byte-for-byte identical (confirmed both already
independently implemented the same "profiles" parsing before PR #677;
that fix was applied consistently to both copies rather than
introducing new duplication). Removed the duplicate: HandleTuneInNavigate
now delegates to stations.Navigate(stations.ProviderTuneIn, wildcard),
the same path stations' own callers already use.

Added TestNavigateTuneIn_ProfilesPathDispatch, covering the single-
segment (current), legacy multi-segment, and empty-URI shapes -- this
package (and the handler package's now-removed copy) had zero test
coverage for this logic before.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-05 14:24:10 +02:00
Tobias GesellchenandClaude Sonnet 5 c56fc6189c refactor(tunein): consolidate the Items/body fallback into one helper
TuneInSearch, TuneInSearchNext, and TuneInNavigateProfile each
duplicated the identical "Items" (v1.3)-then-"body" (legacy) fallback.
Extracted tuneInRawItems, used by all three. Pure refactor, no
behavior change.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-05 14:24:10 +02:00
Tobias GesellchenandClaude Sonnet 5 cb6929ebec fix(tunein): correct profile-container regressions from the navigate-500 fix
The navigate-500 fix replaced a working call to the existing
tuneInSearchSection helper with new hand-rolled container-handling
logic that silently dropped several things the original already did
correctly:

- An empty container (no "Type" field, only "ContainerType") fell
  through to a default branch that treats any unrecognized type as
  directly playable, turning the container's own non-playable GuideId
  into a bogus playback link. Now skipped instead.
- The Pivots.More.Url "load more" pagination cursor was never read, so
  a container with more children than fit on one page silently showed
  only the first page. Extracted the cursor-link logic already in
  tuneInSearchSection into a shared tuneInMoreCursorLink helper, used
  by both.
- The legacy lowercase "children" key (tuneInSearchSection's own
  fallback for "Children") was dropped entirely.
- The response's self link used "/v1/navigate/profile/" (singular);
  no route dispatcher recognizes that, breaking re-navigation via the
  link itself.
- base64.URLEncoding (padded) vs. RawURLEncoding (no padding), two
  lines apart building the same kind of href -- decodeBase64URI
  already tolerates both, which is why this never surfaced as a
  decode failure. Standardized on RawURLEncoding, matching every
  other encode site in the file.
- The "profiles" path case (duplicated pre-existing in both
  handlers_bmx_tunein.go and stations.go) had no fallback for an empty
  encoded URI, unlike the sibling "sub" case a few lines above. Both
  copies now fall back the same way "sub" does.

Added TestTuneInNavigateProfileHandlesContainerShapes covering all
four tunein.go fixes against a fixture server modeling the real
two-fetch profile/contents shape.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-05 14:03:13 +02:00
yongxin-ms 76c2b97e3e fix(tunein): profile navigate 500, empty content, and broken episode playback 2026-09-05 14:03:13 +02:00
Tobias GesellchenandClaude Sonnet 5 791ae0e26f refactor(client): share one-shot HTTP client setup between mutatingGet variants
Splitting mutatingGet into two functions during the main rebase
(mutatingGet keeps the existing result-unmarshaling form used by
/removeGroup; the new mutatingGetConfirmStatus validates a <status>
echo instead, for endpoints with no meaningful response body) left
~30 lines of one-shot-transport setup (clone, disable keep-alives,
CheckRedirect) duplicated between them. Extracted newOneShotHTTPClient
so a future fix to that mechanism can't be applied to one copy and
forgotten in the other.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-05 13:32:57 +02:00
Lukáš Lipinský 0748e4042c feat(client): add capability-driven device settings 2026-09-05 13:32:57 +02:00
Tobias GesellchenandClaude Sonnet 5 369887f642 fix(cli): stop pushing to Marge, make its preflight read advisory
Same fix as -service and -player, for consistency: newGroupCoordinator
proactively pushed group cleanup/rename to an external Marge backend
the CLI doesn't own. A speaker's own firmware already self-reports
that create/rename/teardown to whatever Marge backend it's configured
with -- that's the entire reason HandleMargeAddGroup/HandleMargeModifyGroup/
HandleMargeDeleteGroup exist, they're only ever called by speakers.

Extracted the wiring into cliStereoPairGenerationPersistence (mirrors
the -service/-player equivalents): cleanup and rename are now no-ops,
and preflight's read-only dangling-generation check stays but its
failure no longer blocks Create -- it's printed as a warning instead.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-05 12:29:37 +02:00
Tobias GesellchenandClaude Sonnet 5 cb87d86a0b fix(player): stop pushing to Marge, make its preflight read advisory
Same reasoning as the -service fix: NewWebApp's default generation-
lifecycle wiring (used by the standalone player, and by embedded
-service until SetStereoPairGenerationPersistence overrides it)
proactively pushed group cleanup/rename to an external Marge backend
the player doesn't own. A speaker's own firmware already self-reports
that create/rename/teardown to whatever Marge backend it's configured
with -- that's the entire reason HandleMargeAddGroup/HandleMargeModifyGroup/
HandleMargeDeleteGroup exist, they're only ever called by speakers.

Extracted the wiring into playerStereoPairGenerationPersistence
(mirroring cmd/soundtouch-service's own testable helper): cleanup and
rename are now no-ops, and preflight's read-only dangling-generation
check stays but its failure no longer blocks Create.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-05 12:29:37 +02:00
Tobias GesellchenandClaude Sonnet 5 66eedeefd3 fix(stereo): stop pushing to external Marge, make its preflight read advisory
A speaker's own firmware self-reports its group create/rename/teardown
to whatever Marge backend it's configured with -- that's the entire
reason HandleMargeAddGroup/HandleMargeModifyGroup/HandleMargeDeleteGroup
exist, they're only ever called by speakers, never by us. Coordinator
cleanup/rename proactively pushing the same update to an external
(non-local) Marge target duplicated that self-report against a backend
we generally can't authenticate to anyway (real Bose cloud, another
instance, ...), for zero benefit.

The one part of the external path with a real, distinct purpose --
preflight's read-only check for a dangling stale generation before a
new Create -- stays, but its failure (network error, wrong
credentials, an unreachable backend) no longer blocks Create. It's a
best-effort safety net on top of the coordinator's own physical
preflight (capability/zone/reachability checks against the live
speakers), not the primary guard, and the live speakers' own state is
the authoritative signal either way.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-05 12:29:37 +02:00
Tobias GesellchenandClaude Sonnet 5 00803d0947 fix(stereo): recognize DNS-hijacked speakers as local for generation preflight
A speaker migrated at the DNS level keeps reporting its own MargeURL as
the literal Bose cloud hostname (e.g. https://streaming.bose.com) --
DNS migration only changes how that hostname resolves on the network,
never the device's own advertised URL. embeddedStereoPairGenerationPersistence's
isLocal() only matched against this service's own advertised server
URL, so it misclassified such a speaker as "external" and sent the
stereo-pair Create preflight's generation-conflict check out over the
real internet instead of checking the local datastore. Bose's cloud is
still live enough to answer (just not to authenticate us), so the
check failed with HTTP 401 and hard-blocked Create for an otherwise
perfectly normal DNS-migrated setup.

Added a DNS-hijack-aware check: if this service's own DNS hijack is
active and the reported MargeURL's host is one of the known redirected
Bose hostnames (discovery.InterceptedBoseHosts), treat it as local too.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-05 12:29:37 +02:00
Tobias GesellchenandClaude Sonnet 5 d987fa8c9f test(player): wire frontend_test/ into CI
#672 added pkg/service/soundtouchweb/frontend_test/*.test.mjs (Node's
built-in test runner) but never hooked them into anything: not the
Makefile, not any GitHub Actions workflow. They only ran if someone
happened to invoke `node --test` manually, so CI would stay green even
if isSoundTouch10StereoPair or the DeviceDetail notice markup broke.
Added `make test-frontend` and a step in browser-tests.yml (which
already runs the Go-side chromedp player tests) to run them on every
push/PR, matching the pinned actions/setup-node version already used
by update-static-deps.yml.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-05 11:56:18 +02:00
Lukáš Lipinský cdd06e367a docs(player): explain stereo-pair AirPlay limit 2026-09-05 11:56:18 +02:00
Lukáš Lipinský cb441da981 style(player): satisfy zone handler lint 2026-09-05 11:38:49 +02:00
Lukáš Lipinský ca2e2f9257 fix(player): guard multiroom zone creation 2026-09-05 11:38:49 +02:00
Lukáš Lipinský aa16d1040b Prevent long now-playing metadata overflow
Constrain device and playback metadata across narrow layouts while retaining complete values through tooltips and a touch-friendly details disclosure, including RAOP tracks.
2026-09-05 11:36:54 +02:00
Tobias GesellchenandClaude Sonnet 5 96b1de2163 fix(handlers): stop TestCheck443Reachability_LANProbeMatchesListenerOutcome dialing a real IP
The test's own comment said "we point the LAN host at 127.0.0.1", but
the resolver stub actually returned 1.2.3.4 -- a real, internet-
routable address, not 127.0.0.1 -- since the test was first written.
Probing an arbitrary internet destination's reachability depends on
the tester's own network path: a transparent proxy, a DPI middlebox,
or "known test IP" sinkholing can all make 1.2.3.4:443 appear
reachable, failing the test's core assumption outside a sandboxed CI
network. See #683.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-05 11:13:47 +02:00
Tobias GesellchenandClaude Sonnet 5 b534bc5615 refactor(models): consolidate group-role topology equality
pkg/stereopair's sameRoles and pkg/service/datastore's
sameGroupGenerationTopology independently reimplemented the same
Role-keyed topology comparison, but normalized IP addresses
differently (net.ParseIP-only vs. plain string equality) -- exactly
the class of disagreement models.SameGroup was already created to fix
for order-sensitivity. Both now delegate their per-role comparison to
a new models.SameGroupRoles, which treats equal-but-differently-
formatted IPs as a match without regressing the common
both-addresses-unset case either implementation relied on.
models.SameGroup and datastore's sameStereoPair stay distinct
(commented why): both are intentionally ID/IP-agnostic for reasons
unrelated to this consolidation.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-05 11:08:04 +02:00
Tobias GesellchenandClaude Sonnet 5 dbedef614b fix(service): stop async stereo-pair refresh from clobbering a fresher projection
completeStereoPairMutation's refreshStereoPairMembersAsync ran after
applyStereoPairProjection, and its UpdateDeviceStatus call always
minted a strictly newer group generation via BeginGroupRefresh -- so
ApplyPolledGroup's staleness guard could never reject it, even if its
/getGroup read raced a slower path and was stale relative to the
mutation that had already completed. The refresh now snapshots each
member's post-projection generation and only applies its own read via
ApplyPolledGroupIfBaseline, which requires nothing else (no other
event or poll) to have changed group state in the meantime. Other
UpdateDeviceStatus callers keep their existing always-newer semantics.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-05 11:08:04 +02:00
Tobias GesellchenandClaude Sonnet 5 aa49d904c4 fix(stereo): retry dissolve on a member that never converges
applyDissolve tore down LEFT/RIGHT as independent goroutines with no
compensation path: a partial failure (one member's RemoveGroup
succeeds, the other never verifies empty even after the existing
reverification retries) only ever produced StatusDegraded, requiring
manual operator intervention. compensateDissolve retries RemoveGroup
on any member still unverified, giving it the same chance to converge
that Create's compensateCreate already gets on its own partial
failures.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-05 11:08:04 +02:00
Tobias GesellchenandClaude Sonnet 5 2014c95ac4 fix(marge): delete stored groups on account teardown
HandleMargeDeleteAccountGroups (DELETE /streaming/account/{id}/group/,
no group ID) had become a pure acknowledgement, leaving
DeleteAllGroupsForAccount dead code. This is the exact request real
firmware sends on factory reset/teardown; skipping the delete leaves a
stale Group_*.xml behind, permanently rejecting the next legitimate
Create for those devices via EnsureNoGroupsForDevices with no operator
remedy.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-05 11:08:04 +02:00
Tobias GesellchenandClaude Sonnet 5 536b465a51 fix(stereo): drop unjustified same-Marge-account Create restriction
validateCreateCandidates rejected LEFT/RIGHT pairs whenever their
MargeAccountUUID differed, even though no hardware or design
requirement calls for it: the pre-lifecycle CLI's direct /addGroup
calls never checked Marge accounts, and the existing read/display
projection has no account awareness either. Confirmed via live
hardware testing that this newly rejects a real, previously-working
cross-account stereo pair.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-05 11:08:04 +02:00
Tobias Gesellchen 3cdd20883d chore(player): fix import grouping after rebase conflict resolution
goimports grouping regressed to a flat alphabetical block during the
i655 rebase's conflict resolution; restore stdlib/third-party grouping.
2026-09-05 11:08:04 +02:00
Lukáš Lipinský 8e4520c292 fix(stereo): simplify cleanup classification 2026-09-05 11:08:04 +02:00
Lukáš Lipinský c8f0d9e580 fix(stereo): preserve persistence conflicts 2026-09-05 11:08:04 +02:00
Lukáš Lipinský 6324448e64 fix(stereo): recover from partial rename drift 2026-09-05 11:08:04 +02:00
Lukáš Lipinský f4ef38fc3b fix(player): publish stereo lifecycle projections 2026-09-05 11:08:04 +02:00
Lukáš Lipinský 58c8baa113 fix(stereo): accept right-role group masters 2026-09-05 11:08:04 +02:00
Lukáš Lipinský 2d074d4ecf fix(stereo): skip inspection on unsupported models 2026-09-05 11:08:04 +02:00
Lukáš Lipinský 4bd548915f refactor(stereo): satisfy cleanup lint 2026-09-05 11:08:04 +02:00
Lukáš Lipinský 22586da982 fix(stereo): verify wrapped missing-group cleanup 2026-09-05 11:08:04 +02:00
Lukáš Lipinský a0d1fa7a04 feat: manage stereo pair lifecycle 2026-09-05 11:08:04 +02:00
Lukáš Lipinský fc585e98a0 fix(player): publish speaker events immediately 2026-09-05 11:08:04 +02:00
Tobias Gesellchen ca50451f9c fix(player): ignore port when checking WebSocket handshake origin
Gorilla's default same-origin CheckOrigin compares Origin and Host as
raw strings, port included. This repo's own documented nginx reverse-
proxy config forwards a portless Host header (nginx's $host never
includes the port, unlike $http_host) regardless of what public port
the proxy listens on. That's harmless on the scheme's default port
(the browser's Origin also omits it there), but on a non-default
public port (e.g. :8443, a realistic multi-service-hosting shape) the
browser's Origin keeps the port while the forwarded Host doesn't --
gorilla's strict compare then 403s every WebSocket handshake, silently
breaking the player's live updates in a deployment topology the docs
actively recommend.

Add checkWebSocketOrigin/sameHostIgnoringPort: gorilla's own default
policy, but comparing hostname only. Same-origin and cross-hostname
behavior is unchanged; only a port mismatch on an otherwise-matching
hostname is now tolerated. Also extracted newTestWebSocketServer,
shared by dialTestWebSocket and the origin-policy test, instead of the
origin test re-implementing the same httptest scaffolding inline.

Found in code review of PR #669 (findings #1, #2).
2026-09-04 22:34:35 +02:00
Lukáš Lipinský 32c1040554 fix(player): enforce same-origin WebSockets 2026-09-04 22:34:35 +02:00
Tobias Gesellchen 6689bbbe23 fix(service): make status-poll vs. push-event ordering per field, not per connection
BeginStatusPoll/ApplySpeakerEvent/CompleteStatusPoll gated an entire
poll's merge (NowPlaying/Volume/Presets/Sources/Bass/IsConnected) behind
one shared speakerEventGeneration counter. Any unrelated push event
during the poll's flight discarded the whole result -- not just the
field that event touched. Sources has no push event at all, so it could
go stale indefinitely under ordinary event traffic, defeating both call
sites that depend on this poll (the 30s fallback poll and the
post-reconnect refresh).

Replace it with StatusField + BeginFieldPoll/CompleteFieldPoll/
ApplyFieldEvent: the same two-counter (issued/applied) pattern already
used for Group, generalized to one instance per independently-racing
field via a small fixed-size array. A poll or event for one field can
now only ever supersede that same field, never a different one. This
also removes the map-based generation bookkeeping the old mechanism
needed (issue/lookup/prune per poll) and the unreachable
"unknown generation" branch it required.

applyGroupUpdatedEvent now shares the same queueBroadcastIfChanged
helper as the other five event types, instead of duplicating the
"broadcast if changed" check inline.

Found in code review of PR #666 (findings #1, #2, #3, #4).
2026-09-04 21:56:37 +02:00
Tobias Gesellchen 4b7c088a50 test(player): adapt browser-WS tests to the per-connection write lock
Two tests from PR #666 simulated "the browser WS write path is busy" by
holding the global webSocketWriteMu, which #665's fix removed. Adapt
them to the per-connection replacement: register a connection and hold
its own lock directly (same technique as the #665 test suite), rather
than a lock that no longer exists.
2026-09-04 21:56:37 +02:00
Lukáš Lipinský faaa75d614 test(websocket): group imports 2026-09-04 21:56:37 +02:00
Lukáš Lipinský 01ce3f2df1 fix(player): publish speaker events immediately 2026-09-04 21:56:37 +02:00
Tobias Gesellchen a951758463 fix(player): serialize browser WebSocket writes per-connection, not globally
webSocketWriteMu serialized writes across ALL browser WebSocket
connections, not just the single connection gorilla actually requires.
HandleDeleteDevice's synchronous BroadcastDeviceList call, and every
other client's own periodic update, all contended on one lock -- directly
contradicting the PR's own goal that a stalled client cannot block
healthy ones.

Replace it with a per-connection *sync.Mutex stored in WSClients
(withConnWrite). Registration is fully decoupled from discovery-status
publication: a new connection reads whatever discoveryStatus.Load()
currently returns and is never blocked by an in-flight publication,
which stays safe because Store() always commits before a publication
takes its client snapshot. BroadcastDeviceList/BroadcastDiscoveryStatus
now write each client under only that client's own lock.

Found in code review of PR #665 (finding #1).
2026-09-04 21:13:44 +02:00
Tobias Gesellchen d0e8194ab5 fix(player): normalize stereo-pair member Role/DeviceID in JSON
newStereoPairView emitted raw, un-normalized role.DeviceID/role.Role
while validMasterGroup/registeredMembersAgree/sameGroupClaim trim and
uppercase those same fields for internal comparison. Normalize before
assigning so a future frontend feature reading member.Role/.DeviceID
directly doesn't need to re-normalize it itself.

Found in code review of PR #665 (finding #8).
2026-09-04 21:13:44 +02:00
Tobias Gesellchen fd62f6fbeb fix(player): route HandleAPIDevice through stereo-pair projection
The singular GET /api/control/devices/{id} bypassed the projection that
HandleAPIDevices and both WebSocket frames already apply. A hidden
stereo-pair member was absent from the list but still fully fetchable,
unprojected, by its own id. Add deviceViewForID and return 404 for a
hidden member's own id, consistent with it already being absent from
the list.

Found in code review of PR #665 (finding #3).
2026-09-04 21:13:44 +02:00
Tobias Gesellchen 78c847f929 fix(service): invalidate polled Group by completion order, not start order
BeginGroupRefresh/ApplyPolledGroup keyed invalidation off "has any newer
poll started" via groupGeneration equality. A later poll that starts but
never applies (its own GetGroup fails) still discarded an earlier poll's
still-arriving successful result, even though nothing newer ever actually
landed. Add groupAppliedGeneration and gate on "strictly newer than the
last applied", not "equal to the latest issued".

Found in code review of PR #665 (finding #2).
2026-09-04 21:13:44 +02:00
Lukáš Lipinský 4b3b455d52 fix(player): serialize browser WebSocket writes 2026-09-04 21:13:44 +02:00
Tobias GesellchenandClaude Sonnet 5 8de3d56d4b Fix HTTP-client integration test mock images and improve failure logging
The mock services in docker-compose.ci.yml were pinned to
golang:1.27.0-alpine, which is now too old to run against go.mod's
1.27.1 requirement (GOTOOLCHAIN=local makes this an immediate, silent
container crash: "go.mod requires go >= 1.27.1 (running go 1.27.0)").
Bump all three mock images to 1.27.1-alpine to match.

Also make `make test-http-client` dump docker compose logs (and tear
down) whenever `docker compose up --wait` itself fails, not only
after the .http test run — that path previously aborted with no
diagnostic output at all. Switch the post-run log dump to plain
`docker compose logs` (all services) instead of three hardcoded
per-service calls, which had silently omitted tunein-mock.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-03 19:14:12 +02:00
Tobias GesellchenandClaude Sonnet 5 ec02b24e8c Bump Go toolchain requirement to 1.27.1
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-03 19:14:12 +02:00
Tobias GesellchenandClaude Sonnet 5 1752e7c79d Raise chromedp websocket-URL read timeout to reduce CI flakiness
The default 20s wsURLReadTimeout in chromedp's exec allocator can be
too tight on a loaded shared CI runner spawning headless Chrome,
surfacing as an unrelated "websocket url timeout reached" test
failure. Raise it to 45s and widen the per-test context to match.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-03 19:04:51 +02:00
dependabot[bot] a3d4f0701b deps(deps): bump filippo.io/age from 1.3.1 to 1.3.2
Bumps [filippo.io/age](https://github.com/FiloSottile/age) from 1.3.1 to 1.3.2.
- [Release notes](https://github.com/FiloSottile/age/releases)
- [Commits](https://github.com/FiloSottile/age/compare/v1.3.1...v1.3.2)

---
updated-dependencies:
- dependency-name: filippo.io/age
  dependency-version: 1.3.2
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-09-03 18:54:36 +02:00
dependabot[bot] 0c64136967 docker(deps): bump golang from 1.27.0-alpine to 1.27.1-alpine
Bumps golang from 1.27.0-alpine to 1.27.1-alpine.

---
updated-dependencies:
- dependency-name: golang
  dependency-version: 1.27.1-alpine
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-09-03 18:43:51 +02:00
Tobias Gesellchen 42419a12ae Bump golang.org/x/crypto to v0.56.0
See https://pkg.go.dev/vuln/GO-2026-6354 and https://pkg.go.dev/vuln/GO-2026-6355
2026-09-03 18:43:35 +02:00
dependabot[bot] 1d6014f24f ci(deps): bump the codeql-action group with 3 updates
Bumps the codeql-action group with 3 updates: [github/codeql-action/init](https://github.com/github/codeql-action), [github/codeql-action/analyze](https://github.com/github/codeql-action) and [github/codeql-action/upload-sarif](https://github.com/github/codeql-action).


Updates `github/codeql-action/init` from 4.37.8 to 4.37.9
- [Release notes](https://github.com/github/codeql-action/releases)
- [Changelog](https://github.com/github/codeql-action/blob/main/CHANGELOG.md)
- [Commits](https://github.com/github/codeql-action/compare/db488ddef3bf6cb639b32c2e9a7c0a7ea8271d28...cdf488f595d80d6e07e03d4674febd5ab45fa938)

Updates `github/codeql-action/analyze` from 4.37.8 to 4.37.9
- [Release notes](https://github.com/github/codeql-action/releases)
- [Changelog](https://github.com/github/codeql-action/blob/main/CHANGELOG.md)
- [Commits](https://github.com/github/codeql-action/compare/db488ddef3bf6cb639b32c2e9a7c0a7ea8271d28...cdf488f595d80d6e07e03d4674febd5ab45fa938)

Updates `github/codeql-action/upload-sarif` from 4.37.8 to 4.37.9
- [Release notes](https://github.com/github/codeql-action/releases)
- [Changelog](https://github.com/github/codeql-action/blob/main/CHANGELOG.md)
- [Commits](https://github.com/github/codeql-action/compare/db488ddef3bf6cb639b32c2e9a7c0a7ea8271d28...cdf488f595d80d6e07e03d4674febd5ab45fa938)

---
updated-dependencies:
- dependency-name: github/codeql-action/init
  dependency-version: 4.37.9
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: codeql-action
- dependency-name: github/codeql-action/analyze
  dependency-version: 4.37.9
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: codeql-action
- dependency-name: github/codeql-action/upload-sarif
  dependency-version: 4.37.9
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: codeql-action
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-09-01 21:42:27 +02:00
dependabot[bot] dfff1459b6 ci(deps): bump softprops/action-gh-release from 3.0.2 to 3.0.3
Bumps [softprops/action-gh-release](https://github.com/softprops/action-gh-release) from 3.0.2 to 3.0.3.
- [Release notes](https://github.com/softprops/action-gh-release/releases)
- [Changelog](https://github.com/softprops/action-gh-release/blob/master/CHANGELOG.md)
- [Commits](https://github.com/softprops/action-gh-release/compare/3d0d9888cb7fd7b750713d6e236d1fcb99157228...efb35369e0ad2afab669f228072c1b0d510eae64)

---
updated-dependencies:
- dependency-name: softprops/action-gh-release
  dependency-version: 3.0.3
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-09-01 21:42:06 +02:00
Tobias GesellchenandClaude Sonnet 5 4478863d7c test(service): update router route snapshot for the new zone candidates endpoint
TestPrintRoutes compares against a checked-in route list; the new
GET .../zone/candidates route (added for Zone.js's candidate source
fix) needs to be reflected there too.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-30 22:27:56 +02:00
Tobias GesellchenandClaude Sonnet 5 862ddbb87d fix(player): follow Library's selected device to its pair master
If the device selected in the Library tab disappeared from the
devices map (e.g. it just became a hidden stereo-pair member per
device_projection.go), the sync effect fell back to entries[0][0] --
whichever key happens to sort first in the map -- silently redirecting
the user's Library browsing session to an unrelated speaker.

Now checks first whether the vanished device reappears as a member of
some other device's stereoPair (the pair's master, which now
represents the same physical speaker for control purposes) and
follows it there. Only falls back to an arbitrary device when the
selection is gone for a genuinely unrelated reason (removed, discovery
gap), matching the prior behavior for that case.

No JS unit-test framework exists in this repo for component-level
logic (consistent with the rest of the client-side code), so this is
verified by manual trace rather than an automated regression test.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-30 22:27:56 +02:00
Tobias GesellchenandClaude Sonnet 5 78c6c1bfca fix(player): make Zone.js source zone candidates independently of Group projection
Zone.js derived its "add speaker" candidate list from the `devices`
prop, which is app.js's projected/collapsed device list. Once the
stereo-pair projection (device_projection.go) started hiding a pair's
non-master member from that list, it silently became impossible to
add that physical device to an unrelated multiroom zone, even though
the backend's HandleZoneAdd/HandleZoneRemove already operate on the
raw device registry directly and never cared about pairing at all.
Zone.js's own file wasn't touched by that change; its effective input
just changed underneath it.

Added GET /api/control/devices/{id}/zone/candidates, deliberately
bypassing deviceViewSnapshot's projection and deliberately not
excluding {id} itself -- which candidates to exclude is a caller
concern (Zone.js already does this via the existing zoneIps set,
which includes the zone master's own IP even when standalone, per
models.ZoneInfo.IsStandalone). Zone and Group are separate, unrelated
groupings and should stay that way.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-30 22:27:56 +02:00
Tobias GesellchenandClaude Sonnet 5 522c6b8cb6 fix(models): make group-equality order-insensitive everywhere
sameGroupClaim (device_projection.go, used to validate a member's
claim agrees with the master's) compared roles via a device-ID-keyed
map, making it order-insensitive. webtypes.replaceGroup's change
detection used reflect.DeepEqual on the whole *Group, which is
order-sensitive for Roles.Roles. Both the polled /getGroup response
and the pushed groupUpdated event populate Roles.Roles directly from
XML unmarshaling in wire order, so nothing guarantees a pair's roles
list in the same order across two reads -- DeepEqual could then report
a spurious "changed" for a pair that didn't actually change.

Extracted the order-insensitive comparison into models.SameGroup as
the single shared implementation (also handles the nil/nil case
correctly, unlike the old sameGroupClaim, which mattered for
replaceGroup's existing "no prior group" path). Both call sites now
use it; the duplicate sameGroupClaim is gone.

Added TestApplyGroupEventIgnoresRoleOrder, verified to fail against
the prior DeepEqual-based logic and pass with this fix.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-30 22:27:56 +02:00
Tobias GesellchenandClaude Sonnet 5 9629d3e057 fix(player): don't mark a device connected on GetGroup success alone
statusUpdated (which drives IsConnected) ORed in
"stereoCapable && groupErr == nil" alongside the five substantive
status fetches. Since GetGroup is gated to stereo-capable models and
trivially succeeds even when a device is struggling (an empty
<group/> is a near-guaranteed reply, per Client.GetGroup's doc
comment), a round where NowPlaying/Volume/Presets/Sources/Bass all
fail but GetGroup alone succeeds would still report the device
connected -- masking a real status-refresh failure specifically on
ST10 hardware.

Removed the extra OR term entirely: IsConnected now depends only on
the five substantive fetches, matching the comment's own stated
intent ("mirrors prior behaviour"). GetGroup's own success/failure
still drives whether Group gets refreshed (unchanged, see
ApplyPolledGroup below), just no longer feeds the connectivity signal.

Added TestUpdateDeviceStatusNotConnectedWhenOnlyGroupSucceeds,
verified to fail against the prior logic and pass with this fix.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-30 22:27:56 +02:00
Tobias GesellchenandClaude Sonnet 5 b180796ed4 docs(client): capture exact ST20 /getGroup failure mode and supportedURLs caveat
Refines the previous commit's doc fix with more precise, hardware-
verified detail: the ST20 doesn't just silently drop the connection --
its own firmware ("AllegroWebserver") eventually returns an explicit
"AllegroWebserver timeout: /getGroup" plain-text error after an
internal delay of several+ seconds, well past what client.get()'s
timeout will tolerate.

Also documents a dead-end a future contributor might otherwise try:
the ST20's own /supportedURLs response lists /getGroup (and the other
group endpoints) despite not actually servicing it, confirmed against
the same real hardware. A supportedURLs-based capability probe would
not have caught this either -- the model-name check has to stay.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-30 22:27:56 +02:00
Tobias GesellchenandClaude Sonnet 5 68d8719be0 docs(client): correct GetGroup's non-ST10 behavior claim
The exported GetGroup doc comment claimed non-ST10 devices reply to
/getGroup "harmlessly" with an empty group. Verified against real
hardware this is wrong: a SoundTouch 20 does not reply at all -- the
request hangs until the client's own timeout (10-30s depending on how
the Client was constructed) instead of returning quickly. Confirmed
by direct request against a real ST20 (curl, 8s timeout, zero bytes
back) and cross-checked against two actively-paired real ST10 units,
which both replied in ~30-40ms with full group data.

This matters beyond prose accuracy: the newer stereoPairCapable gate
in websocket.go's UpdateDeviceStatus is load-bearing, not an
optimization. A future contributor trusting the old (wrong, and more
prominent/exported) doc could reasonably "simplify" by removing that
gate, reintroducing a 10-30s hang on every poll cycle for every
SoundTouch 20/30 on the network. Rewrote the doc to state the real
behavior and point at the gate that depends on it; the websocket.go
comment now defers to this doc instead of independently (and
incorrectly worded) restating it.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-30 22:27:56 +02:00
Lukáš Lipinský 342cd47e6a fix(player): snapshot device timestamps safely 2026-08-30 22:27:56 +02:00
Lukáš Lipinský 3b04a6eb57 fix(player): skip stereo polling on unsupported models 2026-08-30 22:27:56 +02:00
Lukáš Lipinský ce66b103b4 fix(player): keep stereo updates coherent and compatible 2026-08-30 22:27:56 +02:00
Lukáš Lipinský 2f2d886e24 docs(player): mark stereo pair projection implemented 2026-08-30 22:27:56 +02:00
Lukáš Lipinský b01ab1e1bb fix(player): project stereo pairs as logical devices 2026-08-30 22:27:56 +02:00
Tobias GesellchenandClaude Sonnet 5 79eb5dd038 docs(player): restore the async=false rationale and its regression test
PR #664 rewrote the comment explaining why the dynamically-inserted
es-module-shims script sets async = false, replacing the actual
execution-order reasoning (it must run before the deferred
type="module" script below, without blocking the parser for browsers
that never reach this branch) with a vaguer, inaccurate description
("inspects module graphs that fail static linking") that doesn't
explain the async choice at all. It also dropped the regression test
asserting .async = false is present, so a future "cleanup" removing
that line would go uncaught -- and the misleading comment no longer
warns against doing so.

The actual .async = false code was untouched by #664; this only
restores the documentation and its test coverage.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-30 21:05:02 +02:00
Lukáš Lipinský d3893dff76 chore(player): tighten shim dependency handling 2026-08-30 21:05:02 +02:00
Tobias GesellchenandClaude Sonnet 5 14437fd568 fix(example-dlna-server): sanitize logged request values
CodeQL alert 313 (go/log-injection). The access-log middleware logged
r.URL.Path and SOAP-body-derived objectID/browseFlag verbatim, without
stripping newlines -- an attacker-controlled request could inject fake
log lines or control characters. Add the same sanitizeLog helper this
repo already uses in ~18 other packages for exactly this class of
finding.

Alert 312 (go/reflected-xss, same file/area) was investigated and left
open deliberately: objectID is only ever used as a lookup key in
pkg/dlna/dlnatest, never echoed into the response, and every actual
output field goes through xmlEsc/xmlAttr (encoding/xml.EscapeText)
before being written -- looks like a CodeQL false positive rather than
a real gap, but not dismissing it yet per discussion.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-29 23:19:32 +02:00
Tobias GesellchenandClaude Sonnet 5 966214c5a0 fix(player): use hasOwnProperty for the device-status guard
CodeQL alert 318 (js/remote-property-injection). setDevices guarded
the status_update write with a plain "!prev[msg.deviceId]" truthy
check; a deviceId of "__proto__" or "constructor" resolves through
the prototype chain to a truthy value, so it would pass the guard
despite not being a real known device, letting the spread write a
bogus own-property (not actual prototype pollution -- computed keys
in object literals use [[DefineOwnProperty]], not the legacy __proto__
setter -- but still corrupts the rendered device list). Use
Object.prototype.hasOwnProperty.call for a real own-property check;
avoided Object.hasOwn (ES2022, Safari 15.4+) given #649's recent
Safari-15.0 compatibility work.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-29 23:19:32 +02:00
Tobias GesellchenandClaude Sonnet 5 ed09141175 fix(player): remove leftover debug console.log
CodeQL alert 319 (js/log-injection). This logged the WebSocket
discovery_status payload verbatim to the browser console under a
"[DEBUG_LOG]" tag -- development-only cruft left in, not something
that serves any product purpose.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-29 23:19:32 +02:00
Tobias GesellchenandClaude Sonnet 5 3dbf4bcd7e fix(admin-ui): remove unused session variable
CodeQL alert 320 (js/unused-local-variable). The interactions table
never had a session column; i.session/i.Session was extracted but
never referenced anywhere in the row template.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-29 23:19:32 +02:00
Tobias GesellchenandClaude Sonnet 5 cff94d5d96 fix(bmx): correct misleading docs, restore TuneIn token request validation
HandleTuneInToken's docstring described the minted token as "fresh",
but datastore.GenerateSerialSecret("tunein") is a pure function of a
hardcoded literal -- it returns the identical value for every device
and every call, not a per-session secret. Correct the framing and
document why the constant value is safe today (Authorization gate
disabled for all TuneIn handlers, nothing validates uniqueness), so a
future change relying on per-device uniqueness doesn't get misled.

Also restore request-body validation dropped when the handler stopped
using the body's values: a genuine bootstrap call is still well-formed
JSON (confirmed against a captured real request), just with an empty
refresh_token, so decoding-but-discarding the body still rejects only
truly malformed requests with 400, without reintroducing the original
echo bug.

Also fixes 4 pre-existing wsl_v5 lint findings in the reordered
children-check in bmx/tunein.go (whitespace only, no behavior change).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-29 17:46:52 +02:00
YongXin 22d77c23f3 add TestTuneInSectionsAshx_UntypedContainerSurfacesStations 2026-08-29 17:46:52 +02:00
Will c1342360e0 Update test 2026-08-29 17:46:52 +02:00
Will 03c42d1909 Fix TuneIn browse can't be played 2026-08-29 17:46:52 +02:00
Will 255023702e update 2026-08-29 17:46:52 +02:00
Will 1c7f32e736 Fix the bug of the child not showing 2026-08-29 17:46:52 +02:00
Tobias GesellchenandClaude Sonnet 5 734b921ac1 ci: exclude vendored static JS libs from CodeQL analysis
es-module-shims.js (vendored verbatim from npm) tripped 3 CodeQL
findings (js/incomplete-sanitization, js/bad-code-sanitization x2) --
real escaping-order bugs in the library's own source, verified by hand,
but not reachable in how this project uses it (no dynamic import()
built from untrusted input, no CSP nonce ever set). Reported upstream
separately.

The javascript-typescript CodeQL matrix entry had no path exclusions at
all, unlike the existing Go config's paths-ignore for vendor/generated
code, so preact.module.js and htm.module.js were exposed to the same
risk even though neither had tripped a finding yet. Add a JS-specific
config excluding pkg/service/soundtouchweb/static/lib/** -- we don't
control or modify these files, so findings there aren't actionable
from this repo.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-29 17:09:36 +02:00
Tobias GesellchenandClaude Sonnet 5 de30aa8d7f ci(player): add a separate workflow for browser compatibility tests
Runs the opt-in "make test-browser" chromedp tests (added in the
previous commit) on their own, independent of ci.yml's main test job,
since they need a Chrome/Chromium binary in the runner. Mirrors
ci.yml's checkout/setup-go/cache steps and pinned action versions, and
verifies google-chrome is present with a clear error message before
running, rather than surfacing a cryptic chromedp allocator failure if
the runner image ever stops shipping it preinstalled.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-29 17:09:36 +02:00
Tobias GesellchenandClaude Sonnet 5 efcb96998f test(player): add browser-level compatibility tests via chromedp
Adds two opt-in tests (build tag "browsertest", run via `make
test-browser`) that drive a real headless Chrome instead of only
asserting on the raw HTML/JS source:

- TestPlayerRendersNatively confirms the shipped page still renders
  normally and never injects es-module-shims on a browser with native
  import map support.
- TestPlayerRendersUnderForcedShimMode forces es-module-shims into its
  own shimMode (importmap-shim/module-shim, per the library's docs),
  routing the real app.js and vendored dependencies through the
  library's actual polyfill resolution. This exercises the old-Safari
  code path directly in CI/local headless Chrome, without needing
  physical iPadOS 15 hardware.

Not wired into `test`/`check`/CI yet, since chromedp has not
previously been exercised as part of this repo's test suite (only in
the standalone doc-screenshot tool) and needs a Chrome/Chromium binary
available to the runner.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-29 17:09:36 +02:00
Tobias GesellchenandClaude Sonnet 5 966d9962a5 fix(player): feature-detect es-module-shims instead of always loading it
Loading es-module-shims unconditionally would charge every browser an
~80KB uncompressed download on every page view, including the vast
majority that already support import maps natively -- the static
asset server here applies no compression. Feature-detect
HTMLScriptElement.supports('importmap') instead, so only a browser
that actually lacks support ever fetches it.

Insert the script via the DOM with async = false rather than
document.write: document.write is deprecated and subject to browser
interventions that can silently drop externally-sourced scripts it
injects, where DOM insertion with async explicitly disabled gives the
same before-the-deferred-module-script execution guarantee without
that risk.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-29 17:09:36 +02:00
Tobias GesellchenandClaude Sonnet 5 6b2c3b5c7c fix(player): use es-module-shims instead of removing import maps
Restores the import map and bare-specifier imports across all
components, and instead polyfills import map support for Safari on
iPadOS 15 (which has ES modules but not import maps) via
es-module-shims, loaded unconditionally since it detects native
support and no-ops there.

The previous approach converted every component to relative imports
through a dependencies.js facade and permanently sed-patched the
vendored preact-hooks build, baking the compatibility workaround into
the whole codebase instead of keeping it encapsulated in index.html.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-29 17:09:36 +02:00
Lukáš Lipinský 6752f71e67 refactor(player): centralize static dependencies 2026-08-29 17:09:36 +02:00
Lukáš Lipinský c97e9958f6 fix(player): support browsers without import maps 2026-08-29 17:09:36 +02:00
Tobias GesellchenandClaude Sonnet 5 35724bcffc test(service): fix flaky serialization test by filtering non-/info requests
The handler counted every request regardless of path, so the background
status-update goroutine AddDeviceByHost spawns after a successful probe
could land before the assertion and be mistaken for a second concurrent
seed probe, occasionally failing with request count 2 instead of 1. Filter
by path like the existing TestDiscoverDevicesRetriesConfiguredHosts test.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-29 16:14:56 +02:00
Tobias GesellchenandClaude Sonnet 5 a21b4d71ae fix(service): address code-review findings on PR #652's startup retry
Fixes correctness issues found reviewing the bounded device-seed retry
loop before merging: a datastore read failure could make the readiness
check trivially pass; stale-host pruning only considered hosts inserted
in the current attempt and only ran inside the retry loop, not the
plain SeedExtraDevices path; the retry loop and a devices-changed-hook
seed could probe the same offline host concurrently; and a zero-change
startup window silently dropped the previously-unconditional device-list
broadcast. Also makes the retry interval/window configurable instead of
hardcoded, following the existing discovery-interval flag pattern.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-29 16:14:56 +02:00
Lukáš Lipinský 0d15bce96f fix(service): retry persisted player devices after startup 2026-08-29 16:14:56 +02:00
dependabot[bot] 719cc446e6 ci(deps): bump the codeql-action group with 3 updates
Bumps the codeql-action group with 3 updates: [github/codeql-action/init](https://github.com/github/codeql-action), [github/codeql-action/analyze](https://github.com/github/codeql-action) and [github/codeql-action/upload-sarif](https://github.com/github/codeql-action).


Updates `github/codeql-action/init` from 4.37.7 to 4.37.8
- [Release notes](https://github.com/github/codeql-action/releases)
- [Changelog](https://github.com/github/codeql-action/blob/main/CHANGELOG.md)
- [Commits](https://github.com/github/codeql-action/compare/ff2f1c621b7f889edc0d3c761ac2e6a3f8cdb0dd...db488ddef3bf6cb639b32c2e9a7c0a7ea8271d28)

Updates `github/codeql-action/analyze` from 4.37.7 to 4.37.8
- [Release notes](https://github.com/github/codeql-action/releases)
- [Changelog](https://github.com/github/codeql-action/blob/main/CHANGELOG.md)
- [Commits](https://github.com/github/codeql-action/compare/ff2f1c621b7f889edc0d3c761ac2e6a3f8cdb0dd...db488ddef3bf6cb639b32c2e9a7c0a7ea8271d28)

Updates `github/codeql-action/upload-sarif` from 4.37.7 to 4.37.8
- [Release notes](https://github.com/github/codeql-action/releases)
- [Changelog](https://github.com/github/codeql-action/blob/main/CHANGELOG.md)
- [Commits](https://github.com/github/codeql-action/compare/ff2f1c621b7f889edc0d3c761ac2e6a3f8cdb0dd...db488ddef3bf6cb639b32c2e9a7c0a7ea8271d28)

---
updated-dependencies:
- dependency-name: github/codeql-action/init
  dependency-version: 4.37.8
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: codeql-action
- dependency-name: github/codeql-action/analyze
  dependency-version: 4.37.8
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: codeql-action
- dependency-name: github/codeql-action/upload-sarif
  dependency-version: 4.37.8
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: codeql-action
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-08-25 20:47:35 +02:00
Tobias GesellchenandClaude Sonnet 5 b10e6dfe8f docs(troubleshooting): add entry for reboot preset wipe on shared-account setups
Closes the loop on #614: presets wiped after a reboot when a speaker
shares its Marge account with other devices. Root cause stays
unconfirmed (firmware-internal), but removing the other devices from
the account is a reporter-confirmed workaround.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-25 20:34:16 +02:00
Tobias GesellchenandClaude Sonnet 5 44830790b8 test(player): adopt httptest.NewTestServer (Go 1.27) in the new discovery test
NewTestServer registers its own t.Cleanup(Close) instead of needing a
manual defer, and fails the test on a handler panic instead of just
logging it. It defaults to an in-memory transport reachable only via
Server.Client(), which wouldn't work here since our production
client.NewClient dials a real address rather than using that client --
calling Start() instead of Client() opts back into a real loopback
listener, identical to the old NewServer, confirmed by reading the
actual go1.27.0 source (server.go's Start implementation).

This is a proactive adoption of a new stdlib idiom, not one of the
review findings from the previous commit; it doesn't change the
goroutine-drain fix from that commit, which is a separate concern
Close()'s "wait for outstanding requests" guarantee doesn't fully
cover (a goroutine that hasn't started its request yet at Close() time
isn't "outstanding").

Verified: 10x -count re-run under -race, full suite + lint clean.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-23 22:00:11 +02:00
Tobias GesellchenandClaude Sonnet 5 df62073ab0 fix(player): address code review findings on #644
Five findings from reviewing gesellix/Bose-SoundTouch#644
(Retry configured player devices during discovery):

1. Source-labeling used exact string equality
   (device.DiscoveryMethod == "Configuration"), which breaks once a
   configured host is also found via mDNS/UPnP in the same sweep:
   mergeDeviceData concatenates methods into e.g.
   "Configuration+mDNS/Bonjour", so the check silently failed and the
   device got labeled "discovered" instead of "manual". Extracted the
   decision into classifySource() and switched to a substring match.
   Added TestClassifySource, which fails against the old exact-equality
   logic on exactly the composite-string cases (verified) and would
   have caught this before merge -- the PR's own test disables
   mDNS/UPnP, so it never exercised this path.

2. Manually configured devices no longer registered immediately at
   startup -- they now wait for the full mDNS/UPnP sweep (up to the
   10s discovery timeout) to complete, since the PR removed the
   synchronous registration loop and relies entirely on
   PreferredDevices. Restored the immediate loop alongside (not
   instead of) folding manualHosts into PreferredDevices, so a
   currently-online configured device registers immediately as
   before, while an offline one still gets retried on every
   subsequent discovery pass -- the actual value this PR adds.

3. The new PreferredDevices-seeding loop didn't dedupe against hosts
   already loaded from PREFERRED_DEVICES, so setting both for the same
   host produced duplicate entries. Currently harmless (absorbed by
   AddDeviceByHost's fast path) but fragile. Added dedup by host.

4. NewDiscoveryService's doc comment didn't mention the new
   configuredHosts parameter or its retry-on-every-sweep behavior.
   Documented.

5. The new test's second DiscoverDevices call spawns a one-shot
   status-update goroutine and a 30s-ticker poll loop with no
   guaranteed drain before the deferred server.Close(), risking
   benign but real -race/CI flakiness. Added a bounded settle delay
   after RemoveDevice.

Verified: full build/vet/race test suite/lint clean; the new
TestClassifySource fails against the pre-fix logic and passes with
it; TestDiscoverDevicesRetriesConfiguredHosts re-run 20x under -race
with no flakiness.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-23 22:00:11 +02:00
Lukáš Lipinský 7015e04556 fix(player): retry configured devices during discovery 2026-08-23 22:00:11 +02:00
Tobias GesellchenandClaude Sonnet 5 d3c5ad2d8e style(install): align the environment variables table columns
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-23 20:50:27 +02:00
Tobias GesellchenandClaude Sonnet 5 ef8bfdc74b docs(install): add an environment variables reference table
The nine env vars install.sh reads were only ever mentioned inline,
scattered across the file, or not documented at all
(AFTERTOUCH_FORCE_NO_BACKUP, GH_REPO, BINARY_URL, INIT_SCRIPT_URL,
FALLBACK_VERSION, AFTERTOUCH_LAN_PORT). Collect them into one table.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-23 20:50:27 +02:00
Tobias GesellchenandClaude Sonnet 5 fdc08d2745 docs(install): replace stale "we're unsure how to update" note
The Space Limitation section still described update safety as an open
problem ("we are currently working on this"). Replace it with what the
installer now actually does: gzip-compressed backups, a preflight
disk-space check with an interactive confirm-or-abort before skipping
the backup, and a hard abort before downloading anything if there
isn't even room for the update itself.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-23 20:50:27 +02:00
Tobias GesellchenandClaude Sonnet 5 ec340847f2 fix(install): preflight disk-space check before replacing the live binary
The gzip fix in the previous commit only helps once a backup is being
made; it doesn't address the actual moment that broke on real hardware:
the cross-device mv/copy of the new binary into place ran out of space
mid-write, leaving a truncated, non-executable binary as the live one.
UBIFS is a log-structured flash filesystem, so space "freed" by
overwriting the old binary isn't guaranteed reusable in time for the new
one to land -- this happened on a device with 15.7MB available against
a ~14.8MB binary.

Add a preflight check before downloading anything: fetch the new
binary's real size via a HEAD request (adapts automatically as binaries
grow, instead of a threshold that goes stale every release) and compare
against available space plus a flat 5MB safety margin.

- Comfortably enough room for old + new + a compressed backup: proceed
  exactly as before, silently.
- Enough for old + new but not enough extra for a backup: warn
  interactively and require explicit confirmation before proceeding
  without one. Reads from /dev/tty since the script is normally piped
  via `curl | sh` (stdin is consumed by the script itself). Defaults to
  the safe choice (abort) on empty input, matching the [y/N] prompt.
  AFTERTOUCH_FORCE_NO_BACKUP=yes overrides for non-interactive/scripted
  use.
- Not enough room even for the replace itself: abort before starting
  the download, rather than attempting a doomed download/replace that
  could leave a truncated live binary.
- No TTY available and the operator didn't set the override: abort
  rather than silently guessing.
- HEAD request fails for any reason: skip the check with a warning
  rather than blocking the install on it.

Verified: all five decision branches (plenty of room, warn+decline,
warn+confirm, warn+forced-override, hard abort) produce the correct
result under both dash and a real BusyBox v1.38.0 container, including
the gzip/gunzip streaming backup and glob-based pruning from the
previous commit. The HEAD-request size lookup was separately verified
against a live GitHub release URL with real curl -- catching and fixing
a bug where naively taking the first "content-length:" header grabbed
the 302 redirect's (0), not the actual asset's, size. Not yet re-tested
end-to-end on real hardware.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-23 20:50:27 +02:00
Tobias GesellchenandClaude Sonnet 5 e8f1b53992 fix(install): gzip the rollback backup to reduce on-device disk pressure
Binaries are tens of MB and only growing (Go 1.27 alone added ~655KB
to soundtouch-service via its own new stdlib defaults, unrelated to
this project's code), while the on-device install target (/mnt/nv) is
only tens of MB total. A user already hit "no space left on device"
attempting an update on real hardware.

Stream the pre-update backup straight through gzip instead of cp-then-
gzip: at that point in the script the old binary is still live and the
newly-downloaded one is already sitting in the temp dir, so writing an
intermediate uncompressed backup copy would briefly need three full
binary-sized copies on disk at once. Streaming avoids ever creating
that intermediate copy. Falls back to a plain uncompressed backup if
gzip is unavailable or the stream fails partway, matching prior
behavior exactly.

Both GC loops (pre- and post-install) now also prune stale
*.backup.gz artefacts, and the README's documented rollback command
covers both the compressed and (fallback) uncompressed cases.

Verified locally (not yet on real hardware): streaming path produces
no uncompressed intermediate, the gzip-unavailable fallback still
produces a plain backup, and the documented gunzip+chmod rollback
restores a byte-identical, executable binary.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-23 20:50:27 +02:00
Tobias GesellchenandClaude Sonnet 5 424631b93a build(lint): point golangci-lint install at the v2 module path
go install github.com/golangci/golangci-lint/cmd/golangci-lint@latest
silently resolves to the latest v1.x release (v1.64.8) -- Go's semantic
import versioning treats v2+ as a completely separate module path
(.../v2/cmd/golangci-lint), so the unsuffixed path's @latest can never
see v2 releases. That mismatched v1 binary can't even load this
repo's v2-format .golangci.yml, and separately doesn't understand the
go1.27.0 toolchain declared in go.mod.

Fix the install hint in `make lint`'s not-found message to use the /v2
path, and refresh the now-current version noted in .golangci.yml's
header comment (installed locally as v2.13.1, built with go1.27.0).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-23 14:50:33 +02:00
Tobias GesellchenandClaude Sonnet 5 97c28b5516 test(router): update route-name snapshot for Go 1.27, drop redundant special case
Go 1.27 changed how runtime.FuncForPC reports the symbol for
HandleWeb()'s returned closure: it now correctly attributes it to its
defining function (handlers.(*Server).HandleWeb) instead of leaking the
inlining call site's enclosing function name (setupRouter) the way
older Go versions did. The registered route itself is unchanged -- this
is purely a difference in the introspected debug name.

The test's cleanup logic had a dedicated special case for stripping a
leading "setupRouter" prefix, added to work around exactly that
inlining artifact. Verified empirically (temporarily instrumented with
the raw runtime.FuncForPC output, then diffed the full 299-route table
with the special case removed) that the general prefix-stripping loop
already produces an identical result for the remaining legitimate
cases (closures actually defined inline in setupRouter, e.g.
/favicon.ico) -- so the dedicated case was already redundant before
this Go bump and can be dropped.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-23 14:50:33 +02:00
Tobias Gesellchen fb69ce29e0 Bump Golang to 1.27.0 2026-08-23 14:50:33 +02:00
Tobias GesellchenandClaude Sonnet 5 18e6c32220 fix(web): show a sources count in Sync results, render as a list
syncSources never reported how many sources it actually saved, so the
Admin UI's success message always said the meaningless "sources:
synced" regardless of outcome. syncSources now returns the count saved
(-1 if the fetch failed), threaded through SyncResult.SourcesCount.

Also replaces the single run-on results string (which visually mashed
presets/recents/sources together with no separator) with a real <ul>
list, one <li> per resource, matching the presets/recents diff lines.
Built via DOM APIs rather than innerHTML string concatenation, since
preset/recent names ultimately come from user-editable station names
on the speaker.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-23 14:19:40 +02:00
Tobias GesellchenandClaude Sonnet 5 7fa70d725a fix(web): surface Sync's destructive-confirm gate in the admin UI
startSync() used to POST once and, on any 2xx, render a hardcoded
"Presets: OK / Recents: OK / Sources: OK" regardless of what the
response actually said -- exactly why a silent partial data loss (see
the previous commit) would have looked like success to the user.

Now: on a 409 (destructive) response, build a specific confirm message
from the diff (e.g. "presets: 6 -> 5: Ici Roussillon") and gate via
window.confirm(), matching the existing QuickFix confirm UX; on
confirm, retry with ?confirmed=true. On success, render the actual
per-resource counts from the response body instead of a canned string.

Adds an HTTP-level regression test
(TestHandleInitialSync_DestructiveSyncReturns409ThenAppliesWhenConfirmed)
covering the same refuse-then-confirm flow through the real handler and
router, complementing the lower-level setup package test.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-23 14:19:40 +02:00
Tobias GesellchenandClaude Sonnet 5 2bac4fb208 fix(setup): require confirmation before Sync would shrink stored data
SyncDeviceData's syncPresets/syncRecents unconditionally overwrote the
datastore with whatever the speaker's live :8090 API returned at that
instant, with no check against what's already stored. If the speaker's
own local cache was stale or incomplete at that moment (e.g. right
after a burst of preset writes, or shortly after a reboot before the
speaker resyncs with Marge), Sync would silently persist that bad
snapshot over good data. A reporter's fresh #614 repro showed the
account's /full response dropping from 6 to 5 presets right after a
Sync click, consistent with this mechanism.

SyncDeviceData now diffs a fresh live fetch against what's stored
before writing anything; if applying would shrink either list, it
returns the diff (via the new SyncResourceDiff/SyncResult types)
without writing unless the caller passes confirmed=true.
HandleInitialSync surfaces this as a 409 with the diff JSON; every call
(confirmed or not) re-fetches live from the speaker, so a confirmed
retry re-checks reality rather than replaying a stale snapshot. Sources
sync is left unconditional, as before -- lower risk in practice and
out of scope for this fix.

fetchLivePresets/fetchLiveRecents are extracted pure-fetch helpers;
syncPresets/syncRecents keep their unconditional-apply behavior (used
directly by existing tests) since the button-driven path now goes
through the diff/confirm guard instead.

Adds TestSyncDeviceData_DestructiveSyncRequiresConfirmation covering
both the refusal and the confirmed-retry path.

Frontend wiring (script.js's startSync + real per-resource result
rendering, replacing the current hardcoded "OK" text) is a follow-up
commit on this branch.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-23 14:19:40 +02:00
Tobias GesellchenandClaude Sonnet 5 d4f4b4fb80 style(marge): fix wsl_v5 lint finding in AddRecent's MutateRecents call
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-23 14:08:24 +02:00
Tobias GesellchenandClaude Sonnet 5 9eed1e11c5 fix(marge): route preset/recent/source read-modify-write through Mutate*
Converts the remaining GetX-then-SaveX call sites (UpdatePreset,
RemovePreset, AddRecent's recent + learned-source persistence, AddSource)
to the new datastore.Mutate{Presets,Recents,ConfiguredSources} helpers,
closing the lost-update race for good on the actual write path the
speaker hits on every preset/recent store.

Adds a regression test that fires 6 concurrent UpdatePreset calls (same
shape as #614's rapid-fire repro) and asserts none are lost. Verified it
reliably fails against the pre-fix code (consistently drops presets
across repeated runs) and passes reliably with the fix, including under
-race.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-23 14:08:24 +02:00
Tobias GesellchenandClaude Sonnet 5 28de254f77 fix(datastore): add lock-spanning Mutate helpers for presets/recents/sources
GetX-then-SaveX call sites did an unguarded read-modify-write: two
concurrent callers could each read the same starting list, mutate
different entries, and the second writer's Save clobber the first's
update. This is exactly what dropped a preset during #614's rapid-fire
preset-programming repro (overlapping PUT .../preset/N requests).

Add MutatePresets/MutateRecents/MutateConfiguredSources, each holding a
single write lock across the whole read-mutate-write cycle, and switch
resolvePresetSource's auto-add-canonical-source path (the same race,
for sources) to use the new MutateConfiguredSources.

Part of the #614 follow-up; more call sites (UpdatePreset's own preset
write, recents, other sources writers) still need converting.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-23 14:08:24 +02:00
dependabot[bot]andlnx01 8c0f8b0592 docker(deps): bump golang from 1.26.6-alpine to 1.27.0-alpine (#637)
Bumps golang from 1.26.6-alpine to 1.27.0-alpine.


[![Dependabot compatibility
score](https://dependabot-badges.githubapp.com/badges/compatibility_score?dependency-name=golang&package-manager=docker&previous-version=1.26.6-alpine&new-version=1.27.0-alpine)](https://docs.github.com/en/github/managing-security-vulnerabilities/about-dependabot-security-updates#about-compatibility-scores)

Dependabot will resolve any conflicts with this PR as long as you don't
alter it yourself. You can also trigger a rebase manually by commenting
`@dependabot rebase`.

[//]: # (dependabot-automerge-start)
[//]: # (dependabot-automerge-end)

---

<details>
<summary>Dependabot commands and options</summary>
<br />

You can trigger Dependabot actions by commenting on this PR:
- `@dependabot rebase` will rebase this PR
- `@dependabot recreate` will recreate this PR, overwriting any edits
that have been made to it
- `@dependabot show <dependency name> ignore conditions` will show all
of the ignore conditions of the specified dependency
- `@dependabot ignore this major version` will close this PR and stop
Dependabot creating any more for this major version (unless you reopen
the PR or upgrade to it yourself)
- `@dependabot ignore this minor version` will close this PR and stop
Dependabot creating any more for this minor version (unless you reopen
the PR or upgrade to it yourself)
- `@dependabot ignore this dependency` will close this PR and stop
Dependabot creating any more for this dependency (unless you reopen the
PR or upgrade to it yourself)


</details>

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-08-23 13:56:50 +02:00
dependabot[bot]andlnx01 85fa9ede2f ci(deps): bump docker/setup-buildx-action from 4.2.0 to 4.3.0 in the setup-actions group (#640)
Bumps the setup-actions group with 1 update:
[docker/setup-buildx-action](https://github.com/docker/setup-buildx-action).

Updates `docker/setup-buildx-action` from 4.2.0 to 4.3.0
<details>
<summary>Release notes</summary>
<p><em>Sourced from <a
href="https://github.com/docker/setup-buildx-action/releases">docker/setup-buildx-action's
releases</a>.</em></p>
<blockquote>
<h2>v4.3.0</h2>
<ul>
<li>Bump <code>@​docker/actions-toolkit</code> from 0.92.0 to 0.95.0 in
<a
href="https://redirect.github.com/docker/setup-buildx-action/pull/595">docker/setup-buildx-action#595</a></li>
<li>Bump brace-expansion from 1.1.13 to 1.1.18 in <a
href="https://redirect.github.com/docker/setup-buildx-action/pull/600">docker/setup-buildx-action#600</a></li>
<li>Bump js-yaml from 5.2.0 to 5.3.0 in <a
href="https://redirect.github.com/docker/setup-buildx-action/pull/585">docker/setup-buildx-action#585</a></li>
<li>Bump postcss from 8.5.10 to 8.5.25 in <a
href="https://redirect.github.com/docker/setup-buildx-action/pull/598">docker/setup-buildx-action#598</a></li>
<li>Bump undici from 6.27.0 to 6.28.0 in <a
href="https://redirect.github.com/docker/setup-buildx-action/pull/601">docker/setup-buildx-action#601</a></li>
</ul>
<p><strong>Full Changelog</strong>: <a
href="https://github.com/docker/setup-buildx-action/compare/v4.2.0...v4.3.0">https://github.com/docker/setup-buildx-action/compare/v4.2.0...v4.3.0</a></p>
</blockquote>
</details>
<details>
<summary>Commits</summary>
<ul>
<li><a
href="https://github.com/docker/setup-buildx-action/commit/37fe631027851001ddb9b187196cc803df7f5f0e"><code>37fe631</code></a>
Merge pull request <a
href="https://redirect.github.com/docker/setup-buildx-action/issues/595">#595</a>
from docker/dependabot/npm_and_yarn/docker/actions-to...</li>
<li><a
href="https://github.com/docker/setup-buildx-action/commit/b5c4f91922681cc7c58d15ab7838986951f09d19"><code>b5c4f91</code></a>
[dependabot skip] chore: update generated content</li>
<li><a
href="https://github.com/docker/setup-buildx-action/commit/3e93b637c6430ba8fa896fad44d3aa6821899d63"><code>3e93b63</code></a>
build(deps): bump <code>@​docker/actions-toolkit</code> from 0.92.0 to
0.95.0</li>
<li><a
href="https://github.com/docker/setup-buildx-action/commit/e527031b32c86649307d5d492506855f90470604"><code>e527031</code></a>
Merge pull request <a
href="https://redirect.github.com/docker/setup-buildx-action/issues/600">#600</a>
from docker/dependabot/npm_and_yarn/brace-expansion-1...</li>
<li><a
href="https://github.com/docker/setup-buildx-action/commit/c68814b33cb66f1f7538e546190d410ae557a640"><code>c68814b</code></a>
[dependabot skip] chore: update generated content</li>
<li><a
href="https://github.com/docker/setup-buildx-action/commit/3f891b01bd5012a434f582800366972569aa1886"><code>3f891b0</code></a>
build(deps): bump brace-expansion from 1.1.13 to 1.1.18</li>
<li><a
href="https://github.com/docker/setup-buildx-action/commit/787db26fcde8ddcabd49a81472318028f7113962"><code>787db26</code></a>
Merge pull request <a
href="https://redirect.github.com/docker/setup-buildx-action/issues/585">#585</a>
from docker/dependabot/npm_and_yarn/js-yaml-5.2.1</li>
<li><a
href="https://github.com/docker/setup-buildx-action/commit/f7793687c711790ca336bd4934f1b1bf5f778e17"><code>f779368</code></a>
[dependabot skip] chore: update generated content</li>
<li><a
href="https://github.com/docker/setup-buildx-action/commit/7d5e60413489a33d28077e11d71c668580cfaf8d"><code>7d5e604</code></a>
build(deps): bump js-yaml from 5.2.0 to 5.3.0</li>
<li><a
href="https://github.com/docker/setup-buildx-action/commit/292c2fb3837a12d3ac2d1e47bbc5c00712bad939"><code>292c2fb</code></a>
Merge pull request <a
href="https://redirect.github.com/docker/setup-buildx-action/issues/590">#590</a>
from docker/dependabot/github_actions/actions/setup-n...</li>
<li>Additional commits viewable in <a
href="https://github.com/docker/setup-buildx-action/compare/bb05f3f5519dd87d3ba754cc423b652a5edd6d2c...37fe631027851001ddb9b187196cc803df7f5f0e">compare
view</a></li>
</ul>
</details>
<br />


[![Dependabot compatibility
score](https://dependabot-badges.githubapp.com/badges/compatibility_score?dependency-name=docker/setup-buildx-action&package-manager=github_actions&previous-version=4.2.0&new-version=4.3.0)](https://docs.github.com/en/github/managing-security-vulnerabilities/about-dependabot-security-updates#about-compatibility-scores)

Dependabot will resolve any conflicts with this PR as long as you don't
alter it yourself. You can also trigger a rebase manually by commenting
`@dependabot rebase`.

[//]: # (dependabot-automerge-start)
[//]: # (dependabot-automerge-end)

---

<details>
<summary>Dependabot commands and options</summary>
<br />

You can trigger Dependabot actions by commenting on this PR:
- `@dependabot rebase` will rebase this PR
- `@dependabot recreate` will recreate this PR, overwriting any edits
that have been made to it
- `@dependabot show <dependency name> ignore conditions` will show all
of the ignore conditions of the specified dependency
- `@dependabot ignore <dependency name> major version` will close this
group update PR and stop Dependabot creating any more for the specific
dependency's major version (unless you unignore this specific
dependency's major version or upgrade to it yourself)
- `@dependabot ignore <dependency name> minor version` will close this
group update PR and stop Dependabot creating any more for the specific
dependency's minor version (unless you unignore this specific
dependency's minor version or upgrade to it yourself)
- `@dependabot ignore <dependency name>` will close this group update PR
and stop Dependabot creating any more for the specific dependency
(unless you unignore this specific dependency or upgrade to it yourself)
- `@dependabot unignore <dependency name>` will remove all of the ignore
conditions of the specified dependency
- `@dependabot unignore <dependency name> <ignore condition>` will
remove the ignore condition of the specified dependency and ignore
conditions


</details>

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-08-23 13:51:28 +02:00
dependabot[bot]andlnx01 b6ce6e79ac deps(deps): bump github.com/go-chi/chi/v5 from 5.3.1 to 5.3.2 (#639)
Bumps [github.com/go-chi/chi/v5](https://github.com/go-chi/chi) from
5.3.1 to 5.3.2.
<details>
<summary>Release notes</summary>
<p><em>Sourced from <a
href="https://github.com/go-chi/chi/releases">github.com/go-chi/chi/v5's
releases</a>.</em></p>
<blockquote>
<h2>v5.3.2</h2>
<h2>What's Changed</h2>
<ul>
<li>feat(middleware): add text/markdown, text/csv, text/vtt to default
compressible types by <a
href="https://github.com/VojtechVitek"><code>@​VojtechVitek</code></a>
in <a
href="https://redirect.github.com/go-chi/chi/pull/1151">go-chi/chi#1151</a></li>
<li>docs: deployment recipe for
middleware.ClientIPFromXFFTrustedProxies() by <a
href="https://github.com/VojtechVitek"><code>@​VojtechVitek</code></a>
in <a
href="https://redirect.github.com/go-chi/chi/pull/1111">go-chi/chi#1111</a></li>
<li>fix: don't drop handlers that collide with a Mount()/Route() pattern
by <a
href="https://github.com/VojtechVitek"><code>@​VojtechVitek</code></a>
in <a
href="https://redirect.github.com/go-chi/chi/pull/1148">go-chi/chi#1148</a></li>
<li>Don't duplicate methods in Allow: header for 405 responses by <a
href="https://github.com/flimzy"><code>@​flimzy</code></a> in <a
href="https://redirect.github.com/go-chi/chi/pull/1029">go-chi/chi#1029</a></li>
<li>fix(middleware): reject catch-all compress wildcards by <a
href="https://github.com/VojtechVitek"><code>@​VojtechVitek</code></a>
in <a
href="https://redirect.github.com/go-chi/chi/pull/1156">go-chi/chi#1156</a>
<ul>
<li><code>middleware.NewCompressor(level, &quot;/*&quot;)</code> never
worked and silently compressed nothing. Instead of turning it into a
compress-everything catch-all (as proposed in <a
href="https://redirect.github.com/go-chi/chi/issues/868">go-chi/chi#868</a>
and <a
href="https://redirect.github.com/go-chi/chi/pull/1121">go-chi/chi#1121</a>),
we decided to reject both &quot;/<em>&quot; and &quot;</em>/*&quot; at
construction and panic. Compressing every response wastes CPU on
already-compressed types (zip, jpeg, png), which is why the middleware
keeps a curated default list. Users should pass explicit content
types.</li>
</ul>
</li>
</ul>
<p><strong>Full Changelog</strong>: <a
href="https://github.com/go-chi/chi/compare/v5.3.1...v5.3.2">https://github.com/go-chi/chi/compare/v5.3.1...v5.3.2</a></p>
</blockquote>
</details>
<details>
<summary>Commits</summary>
<ul>
<li><a
href="https://github.com/go-chi/chi/commit/38939062c5df4d3e8814aad1a488983112627ced"><code>3893906</code></a>
fix(middleware): reject catch-all compress wildcards &quot;/<em>&quot;
and &quot;</em>/*&quot; (<a
href="https://redirect.github.com/go-chi/chi/issues/1156">#1156</a>)</li>
<li><a
href="https://github.com/go-chi/chi/commit/9b6ddcddb96aa14648702e7eaabef38e14e65157"><code>9b6ddcd</code></a>
Don't duplicate methods in Allow: header for 405 responses (<a
href="https://redirect.github.com/go-chi/chi/issues/1029">#1029</a>)</li>
<li><a
href="https://github.com/go-chi/chi/commit/29164f023bf9319e74d5961a21a712653bb98c83"><code>29164f0</code></a>
fix: don't drop handlers that collide with a Mount()/Route() pattern (<a
href="https://redirect.github.com/go-chi/chi/issues/1148">#1148</a>)</li>
<li><a
href="https://github.com/go-chi/chi/commit/bc02284e9db220c644912320fe1db6bc9b4a087c"><code>bc02284</code></a>
docs: deployment recipe + verify checklist for
ClientIPFromXFFTrustedProxies ...</li>
<li><a
href="https://github.com/go-chi/chi/commit/60ecea54191a4cad3d5a96568708dad996509b17"><code>60ecea5</code></a>
feat(middleware): add text/markdown, text/csv, text/vtt to default
compressib...</li>
<li>See full diff in <a
href="https://github.com/go-chi/chi/compare/v5.3.1...v5.3.2">compare
view</a></li>
</ul>
</details>
<br />

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-08-23 13:51:03 +02:00
dependabot[bot]andlnx01 7a6b5866fe deps(deps): bump github.com/miekg/dns from 1.1.72 to 1.1.73 (#638)
Bumps [github.com/miekg/dns](https://github.com/miekg/dns) from 1.1.72
to 1.1.73.
<details>
<summary>Commits</summary>
<ul>
<li><a
href="https://github.com/miekg/dns/commit/d854399da1ee385b432e8b07f79e53bbfc1ab1b0"><code>d854399</code></a>
Release 1.1.73</li>
<li><a
href="https://github.com/miekg/dns/commit/aed10f489b2a2507477a39b70ef8f22c2c71db75"><code>aed10f4</code></a>
go.mod: add tool directive to replace tools.go</li>
<li><a
href="https://github.com/miekg/dns/commit/76c682a2649fa559ca5a94a6e727714959c2cabe"><code>76c682a</code></a>
Fix gogen diff</li>
<li><a
href="https://github.com/miekg/dns/commit/000bd62913f2dd478fdbd452387777be1c423b47"><code>000bd62</code></a>
Bump the all group with 4 updates (<a
href="https://redirect.github.com/miekg/dns/issues/1726">#1726</a>)</li>
<li><a
href="https://github.com/miekg/dns/commit/24ce5ef354706374797e4aa197977e45a272a24b"><code>24ce5ef</code></a>
Bump the all group with 4 updates (<a
href="https://redirect.github.com/miekg/dns/issues/1725">#1725</a>)</li>
<li><a
href="https://github.com/miekg/dns/commit/fa041eedc7a8991bb4bc515b95bda793f5776b29"><code>fa041ee</code></a>
Bump the all group with 3 updates (<a
href="https://redirect.github.com/miekg/dns/issues/1723">#1723</a>)</li>
<li><a
href="https://github.com/miekg/dns/commit/3124152ebe810d79ce60e09c8aba7b356ff698b0"><code>3124152</code></a>
MD5: remove keytag calculation (<a
href="https://redirect.github.com/miekg/dns/issues/1724">#1724</a>)</li>
<li><a
href="https://github.com/miekg/dns/commit/d1539a788a12830620381c4cc6617762994f3fa1"><code>d1539a7</code></a>
Bump the all group with 4 updates (<a
href="https://redirect.github.com/miekg/dns/issues/1713">#1713</a>)</li>
<li><a
href="https://github.com/miekg/dns/commit/cd053176d80a0143a56f61f5e5d06bdd94a610e9"><code>cd05317</code></a>
Shorter v2 announcement</li>
<li><a
href="https://github.com/miekg/dns/commit/ce76cb6c9b5f3b75ff44996597994ae6f13eae28"><code>ce76cb6</code></a>
Bump the all group with 3 updates (<a
href="https://redirect.github.com/miekg/dns/issues/1703">#1703</a>)</li>
<li>Additional commits viewable in <a
href="https://github.com/miekg/dns/compare/v1.1.72...v1.1.73">compare
view</a></li>
</ul>
</details>
<br />


[![Dependabot compatibility
score](https://dependabot-badges.githubapp.com/badges/compatibility_score?dependency-name=github.com/miekg/dns&package-manager=go_modules&previous-version=1.1.72&new-version=1.1.73)](https://docs.github.com/en/github/managing-security-vulnerabilities/about-dependabot-security-updates#about-compatibility-scores)

Dependabot will resolve any conflicts with this PR as long as you don't
alter it yourself. You can also trigger a rebase manually by commenting
`@dependabot rebase`.

[//]: # (dependabot-automerge-start)
[//]: # (dependabot-automerge-end)

---

<details>
<summary>Dependabot commands and options</summary>
<br />

You can trigger Dependabot actions by commenting on this PR:
- `@dependabot rebase` will rebase this PR
- `@dependabot recreate` will recreate this PR, overwriting any edits
that have been made to it
- `@dependabot show <dependency name> ignore conditions` will show all
of the ignore conditions of the specified dependency
- `@dependabot ignore this major version` will close this PR and stop
Dependabot creating any more for this major version (unless you reopen
the PR or upgrade to it yourself)
- `@dependabot ignore this minor version` will close this PR and stop
Dependabot creating any more for this minor version (unless you reopen
the PR or upgrade to it yourself)
- `@dependabot ignore this dependency` will close this PR and stop
Dependabot creating any more for this dependency (unless you reopen the
PR or upgrade to it yourself)


</details>

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-08-23 13:39:54 +02:00
Tobias GesellchenandClaude Sonnet 5 245032e005 fix(setup): accept non-numeric account IDs reported by third-party pairing tools (#634)
Speakers paired via non-AfterTouch tooling (e.g. the USB-stick SSH-enable
method) can report a margeAccountUUID that isn't Bose's own 7-digit
numeric format, such as "stick@local". Discovery persisted this value
unvalidated, and the datastore's identifier check rejected it outright,
so the device was silently never saved.

Widens datastore.IsSafeIdentifier to accept any identifier that's safe
as a path component, XML value, and telnet-command token (still
excluding whitespace, control characters, and HTML/XML/shell
metacharacters), and makes it the single account-ID validator,
replacing setup's separate, stricter 7-digit-only IsValidAccountID.

Also closes related gaps found while widening the validator:
- postSetMargeAccount now XML-escapes the account ID instead of raw
  string interpolation.
- SaveAccountInfo/HandleMargeCreateAccount now validate the account ID
  the same way SaveDeviceInfo already did.
- handlers_export.go URL-escapes account/device IDs before building
  outbound diagnostic-fetch URLs.
- pkg/service/health gained the sanitizeLog helper every other package
  already has, applied to log lines carrying speaker-reported values.
- The admin web UI (script.js) renders account/device IDs via DOM APIs
  instead of innerHTML/inline event-handler string interpolation,
  closing a stored-XSS path, and a duplicate escape helper was
  consolidated into one.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-22 11:31:08 +02:00
Tobias Gesellchen 790a20d49b fix(datastore): log previously-silent empty-preset/recent reads (#614)
readPresetsLocked's os.IsNotExist branch and GetRecents' equivalent
branch silently returned an empty result with no log line at all,
unlike their sibling 0-byte/malformed-XML branches which already log.
When a reporter's speaker got served an empty preset list at reboot
despite an intact on-disk Presets.xml, there was no durable record of
it anywhere except a live capture at the exact moment.

Also log the per-device preset count going into every /full response
in CreateAccountDevice, distinguishing a disk read that came back
empty from one where source-mapping silently dropped presets
afterward.

Diagnostic only, no behavior change - the actual trigger for the
empty response is still open.
2026-08-21 08:59:36 +02:00
Tobias GesellchenandClaude Sonnet 5 21043d542a feat(release): add real per-platform download links to release notes
Release notes previously pointed at the flat, alphabetical Assets
list, forcing readers to hunt for their platform's soundtouch-service
or soundtouch-cli build. Generate direct per-platform links (with
inline checksum links, one row per OS/arch) from the deterministic
asset naming convention, and wire it into both release paths: the
auto-generated notes (create_release) and the hand-authored notes a
maintainer publishes via the GitHub web UI (update_release, which now
replaces the Downloads footer line in place). The footer-replace logic
always goes through the same strip-then-append path so re-running the
job for the same tag stays byte-for-byte idempotent.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-18 21:52:56 +02:00
dependabot[bot] 27bb738751 ci(deps): bump the codeql-action group with 3 updates
Bumps the codeql-action group with 3 updates: [github/codeql-action/init](https://github.com/github/codeql-action), [github/codeql-action/analyze](https://github.com/github/codeql-action) and [github/codeql-action/upload-sarif](https://github.com/github/codeql-action).


Updates `github/codeql-action/init` from 4.37.6 to 4.37.7
- [Release notes](https://github.com/github/codeql-action/releases)
- [Changelog](https://github.com/github/codeql-action/blob/main/CHANGELOG.md)
- [Commits](https://github.com/github/codeql-action/compare/5595ccaf912efad79be6eef63a5619ff05969be3...ff2f1c621b7f889edc0d3c761ac2e6a3f8cdb0dd)

Updates `github/codeql-action/analyze` from 4.37.6 to 4.37.7
- [Release notes](https://github.com/github/codeql-action/releases)
- [Changelog](https://github.com/github/codeql-action/blob/main/CHANGELOG.md)
- [Commits](https://github.com/github/codeql-action/compare/5595ccaf912efad79be6eef63a5619ff05969be3...ff2f1c621b7f889edc0d3c761ac2e6a3f8cdb0dd)

Updates `github/codeql-action/upload-sarif` from 4.37.6 to 4.37.7
- [Release notes](https://github.com/github/codeql-action/releases)
- [Changelog](https://github.com/github/codeql-action/blob/main/CHANGELOG.md)
- [Commits](https://github.com/github/codeql-action/compare/5595ccaf912efad79be6eef63a5619ff05969be3...ff2f1c621b7f889edc0d3c761ac2e6a3f8cdb0dd)

---
updated-dependencies:
- dependency-name: github/codeql-action/init
  dependency-version: 4.37.7
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: codeql-action
- dependency-name: github/codeql-action/analyze
  dependency-version: 4.37.7
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: codeql-action
- dependency-name: github/codeql-action/upload-sarif
  dependency-version: 4.37.7
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: codeql-action
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-08-18 19:55:50 +02:00
Tobias Gesellchen e57708ea11 fix(setup): gate setup pair --mode=full on configuration status (#615)
A speaker can be reachable, named, and already account-paired yet still
report SOUNDTOUCH_NOT_CONFIGURED, leaving the "install the Bose app"
prompt on screen (reported for ST30 Series II/III in #615). Only a full
pass through the WebSocket setup state machine clears it, but running
that unconditionally risks re-running the bracket on speakers that
don't need or support it.

Add Manager.PreflightInitPlan: checks /supportedURLs for
/setMargeAccount, then requires /soundTouchConfigurationStatus to read
exactly SOUNDTOUCH_NOT_CONFIGURED before ExecuteInitPlan runs.
Already-configured devices are a no-op; an unsupported route or an
unrecognised status value aborts instead of guessing.
2026-08-17 21:29:39 +02:00
Tobias GesellchenandClaude Sonnet 5 d873d88b4f fix(admin-ui): clarify CA/TLS and HTTPS test are optional for HTTP plans
The default Suggested Plan (both XML-over-SSH and Telnet) migrates the
speaker over plain HTTP and never touches CA/TLS at all, but the CA/TLS
precondition always showed a red not-installed marker and the HTTPS
Connection Test panel was always rendered, regardless of whether the
current Target URL actually needs HTTPS. Both read as mandatory steps
even when nothing needed doing.

CA/TLS and HTTPS only matter when the Target URL is https:// or the
Customize form's DNS-interception method is chosen (that one always
targets https://*.bose.com).

- caVerdict() now takes whether the Target URL is HTTPS: shows a
  neutral marker with a "not needed" note for HTTP targets, keeps the
  red marker with a sharper "required" note for HTTPS targets.
- The HTTPS Connection Test panel gets a small note under its heading
  ("Optional for your current plan (HTTP)" / "Required ... (HTTPS)"),
  computed from the same check. Stays visible either way so someone can
  still run it if they want.

Frontend-only — showSummary already had the Target URL in scope.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-17 20:58:51 +02:00
Tobias GesellchenandClaude Sonnet 5 57c0895063 fix(admin-ui): make Migration tab action buttons consistently reachable
Follow-up on #621: the Reboot Speaker button (plus Revert to Defaults,
Enable SSH, Disable SSH) was reachable only after expanding the
collapsed "Customize this migration" section and scrolling past three
fieldsets and the XML/telnet diff panes. Meanwhile every other real
action elsewhere in the admin UI (Save Settings, Apply Suggested Plan,
Start Sync, ...) is visible by default.

- Move Revert to Defaults and Reboot Speaker into an always-visible
  "Speaker controls" row directly under the Migration State card.
- Move Enable/Disable SSH into the Preconditions table, inline with the
  SSH (remote_services) status row, sized like the existing "Trust CA
  Now" button next to the CA/TLS row. script.js now only rewrites the
  inner status span on re-render (matching the CA/TLS pattern) so the
  buttons survive summary refreshes.
- Add shared .btn-primary/.btn-danger CSS classes so button color
  consistently means the same thing everywhere (primary = confirm,
  danger = destructive) instead of ad-hoc inline colors; applied to
  Save Settings, Apply Suggested/Custom Plan, Enable/Disable SSH,
  Revert to Defaults, and Trust CA Now. Removed decorative gray from
  Reboot Speaker and the connection/DNS test buttons.
- Replace the "Cancel" button (which only hid the whole summary panel,
  not any of the actions it sat beside) with a "✕ Hide" control next
  to the "Migration Summary for <device>" heading, alongside a new
  "↻ Reload" shortcut for refreshSummary().
- Remove the now-unneeded force-open-the-details hack in migrate()
  since Reboot no longer lives inside any collapsed container.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-17 20:58:51 +02:00
Tobias GesellchenandClaude Sonnet 5 fb8eab27c3 docs(readme): remove obsolete Go Report Card badge
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-17 20:58:38 +02:00
Tobias GesellchenandClaude Sonnet 5 2218a28179 docs: add ST30 III reset note and post-update version-check guidance
Two doc notes from dunha's #621 follow-up: the factory-reset button
combo is confirmed identical on the SoundTouch 30 Series III, and
checking the reported version right after an on-device update can
still show stale info until the speaker (or an open Admin UI tab) is
rebooted, even though the new binary is already running.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-17 19:57:02 +02:00
Tobias GesellchenandClaude Sonnet 5 9e56c4f3f4 fix(setup,admin-ui,install): three bugs from #621 follow-up feedback
- setup: resync all four boseurls (not just marge/swUpdate) over telnet
  after an SSH-XML migration. `envswitch boseurls set` persists whatever
  is currently in the runtime layer, so leaving stats/bmx untouched froze
  their stale pre-migration values into the persistence layer permanently
  -- surviving reboot and previously requiring a factory reset to clear.
- admin-ui: Migrate tab's Target Domain edits now propagate into the four
  service URL fields (tracked via a dataset.autofilled flag so real manual
  edits still aren't clobbered), closing the gap where changing Target
  Domain to a new value left the four fields pointed at a stale default.
- install.sh: prune stale binary backups before the download too, not
  only after a successful install, so a backup left by a previously
  aborted (out-of-space) run gets cleaned up instead of compounding.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-17 19:57:02 +02:00
dependabot[bot] ba45d997cf deps(deps): bump the golang group with 3 updates
Bumps the golang group with 3 updates: [golang.org/x/mod](https://github.com/golang/mod), [golang.org/x/net](https://github.com/golang/net) and [golang.org/x/tools](https://github.com/golang/tools).


Updates `golang.org/x/mod` from 0.39.0 to 0.40.0
- [Commits](https://github.com/golang/mod/compare/v0.39.0...v0.40.0)

Updates `golang.org/x/net` from 0.57.0 to 0.58.0
- [Commits](https://github.com/golang/net/compare/v0.57.0...v0.58.0)

Updates `golang.org/x/tools` from 0.48.0 to 0.49.0
- [Release notes](https://github.com/golang/tools/releases)
- [Commits](https://github.com/golang/tools/compare/v0.48.0...v0.49.0)

---
updated-dependencies:
- dependency-name: golang.org/x/mod
  dependency-version: 0.40.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: golang
- dependency-name: golang.org/x/net
  dependency-version: 0.58.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: golang
- dependency-name: golang.org/x/tools
  dependency-version: 0.49.0
  dependency-type: indirect
  update-type: version-update:semver-minor
  dependency-group: golang
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-08-17 19:44:44 +02:00
Tobias GesellchenandClaude Sonnet 5 eec57cbc10 fix(admin-ui): Migrate tab blocked on-device localhost by default
validateURL() unconditionally rejected the hostnames "localhost" and
"127.0.0.1" for the four Migrate-tab plan URL fields, with no awareness
of deployment mode. Since the Suggested Plan's URLs are derived from the
page's own configured Target URL, a fresh on-device install (whose
server_url is now correctly http://localhost:8000, since #546) loaded
the Migrate tab with "Apply Suggested Plan" and "Pre-flight" disabled
by default, before the user touched anything -- directly contradicting
the on-device docs' "Migrate -> accept the suggested plan -> apply"
instructions.

Found while investigating why a #614 reporter used the non-standard
"localhost.localdomain" as a workaround, and why a #621 reporter got
stuck with "Migration Status: Migrated (URL mismatch)" trying to follow
the (correct) on-device localhost guidance.

Fix: a loopback URL is only flagged when it doesn't match the plan's
own Target URL origin. A field that's exactly what the service itself
is already configured to answer as (the on-device case) is accepted;
a stray "localhost" typed into one field while Target URL is a real LAN
address (the external-host mistake the check exists to catch) is still
flagged, since the origins differ.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-16 20:41:40 +02:00
Tobias GesellchenandClaude Sonnet 5 3e730c983f docs(troubleshooting): Settings tab alone never updates an already-migrated speaker
Confirmed via code review: the Settings-save handler only updates the
service's own serverURL/settings.json, never contacts a device, and
neither migration method (telnet or XML/SSH) leaves anything behind that
would make a speaker later re-fetch a new address on its own. Both write
once, at migrate time.

Adds a Troubleshooting entry for this, and a cross-reference from the
Migration Guide's Step 2 (Target Domain) pointing at it, plus a step-
number fix (Migrate is Step 5, not Step 4).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-16 20:23:13 +02:00
Tobias GesellchenandClaude Sonnet 5 81b915bfca fix(on-device): stop leaking the speaker's own hostname into BMX/TLS URLs
On an on-device install, soundtouch-service defaulted its server URL to
os.Hostname() when --server-url wasn't set. Since the service runs on the
speaker's own Linux, that returns the speaker's internal variant codename
(e.g. "spotty", "mojo") -- never resolvable, not even by the speaker itself
-- breaking TuneIn/BMX playback with CURL ErrorCode 6 (issue #546).

Add a --deployment-mode/DEPLOYMENT_MODE flag (on-device, private-network,
public-network) so the fallback is chosen deliberately instead of guessed:
on-device defaults to localhost, public-network refuses to start rather
than guess a public address, and the previous hostname-guessing behavior
is kept for private-network/unset installs, now with a startup warning.

The on-device init script sets DEPLOYMENT_MODE=on-device automatically and
now auto-exports aftertouch.conf into the daemon's environment generally,
which also unblocks discussion #610 (setting MGMT_USERNAME/MGMT_PASSWORD
on-device) without any further code change.

Verified end-to-end on real ST20 hardware: service now resolves
http://localhost:8000, a re-migrate updates the speaker's own runtime
config to match, and TuneIn playback works again.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-16 20:12:44 +02:00
Tobias GesellchenandClaude Opus 5 de6172de17 docs: fix two broken links to the model support matrix
The site sets disablePathToLower, so published URLs keep the source
filename's case, but the README link was lowercased and 404'd.

The walkthrough link was wrong in a second way: it used ../../reference/
with no extension, while cross-document links from guides/ resolve as
../reference/NAME.md.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-16 16:01:34 +02:00
Tobias GesellchenandClaude Opus 5 b8427b0bbe feat(on-device): reach AfterTouch from the LAN without an SSH tunnel
On-device installs were only reachable through an SSH tunnel, and the
docs blamed it on the service binding loopback-only. That was wrong.

Some SoundTouch chassis carry a BCO ("SMSC") Wi-Fi/Bluetooth
co-processor, and inbound LAN traffic reaches the main Linux SoC only
for a fixed set of Bose's own service ports, a list that appears to be
compiled into the co-processor firmware. AfterTouch's :8000 was never
part of that design, so connections never arrive at the SoC at all.
Confirmed on an ST20: a port sweep from a LAN client showed Bose's
:82/:8080/:8090/:8091/:8200/:17000 all answering while :8000 failed,
and tcpdump on the speaker's own eth0 recorded zero packets for it.
Ruled out along the way: iptables (empty), nft/ebtables (absent), the
router, Wi-Fi isolation, and the binding itself (0.0.0.0 is correct).

The init script now redirects one of the relayed ports to AfterTouch,
so http://<speaker-ip>:17008 works with no tunnel. 17008 is Bose's
software-update listener, whose cloud no longer exists. Only external
traffic is matched, so anything on the speaker still reaches :8000 as
before. Auto-enabled only where has-bco reports the co-processor, and
configurable via AFTERTOUCH_LAN_PORT (auto/none/port) in
aftertouch.conf. The rule is re-applied on every start and removed on
stop and uninstall, so it needs no watchdog; unlike prior art it is not
pinned to the LAN IP, so it also survives DHCP changes.

Credit for the REDIRECT technique goes to the STR / SoundTouch Reborn
project, which documented and shipped it first.

Also de-hardcodes the service port, which was baked independently into
the daemon args, the readiness poll and status, and makes install.sh
print the speaker's real address instead of a <your-device-ip>
placeholder it never filled in.

Adds a model support matrix, since the repo had no per-model
compatibility record and this behaviour is entirely chassis-dependent.
Only the verified ST20 row is filled in; everything else is marked
unknown rather than inferred.

Verified on hardware: auto-detection, idempotency across restarts,
teardown and restore, persistence across a full reboot, and LAN access
returning the service's health JSON.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-16 15:54:00 +02:00
Tobias GesellchenandClaude Sonnet 5 1396bb32dc docs(troubleshooting): mark the setup revert dial-storm entry as fixed
The SSH connection-reuse fix (a4c0539) landed after this entry was
originally written describing the bug as open. Update the entry to
reflect the fix, confirmed on the same real hardware that surfaced
the original failure.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-16 15:54:00 +02:00
Tobias GesellchenandClaude Sonnet 5 4a1e722069 fix(on-device-install): daemon restart never replaced the running process
Three stacked bugs, found and confirmed on real hardware while
downgrading a speaker: the new binary landed on disk correctly, but
the running service kept reporting the old version indefinitely.

- install.sh called `/etc/init.d/aftertouch start`, not `restart`,
  after installing. start-stop-daemon silently refuses to launch a
  second instance when one is already running, and the init script
  never checked its exit status, so the old process was never
  replaced.

- The init script started the daemon through a `sh -c "exec ... |
  logger"` pipeline, on the assumption that `exec` lets --make-pidfile
  record the daemon's own PID. POSIX forks each side of a pipe into
  its own process, so the wrapper shell (not the daemon) was the one
  actually tracked. `stop` killed the wrapper, which doesn't forward
  SIGTERM to its children, orphaning the real daemon to keep running
  and keep holding :8000 forever.

- Once the wrapper correctly tracked the daemon's own PID, a further
  race surfaced: start-stop-daemon's own "already running?" check
  matched on generic `/bin/sh` identity, so a `restart`'s `start`
  phase could catch the previous wrapper still mid-teardown and
  silently refuse to launch a new one (masked by --quiet, looking
  like a 120s hang).

Fixed by calling `restart` instead of `start` in install.sh, and by
having the wrapper shell record the daemon's real PID itself (via $!)
while keying start-stop-daemon's own check on that same pidfile
instead of process identity.

Verified on hardware: three consecutive restart cycles, each fast,
each with the pidfile matching the live daemon PID and daemon output
flowing through syslog again via logread.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-16 15:54:00 +02:00
Tobias Gesellchen a8f4469eb0 fix(docs): VERSION= env var must go on sh, not curl, in the install pipe
Confirmed on real hardware: `VERSION=0.123.0 curl -sSL .../install.sh | sh`
silently does NOT pin the version, despite the docs claiming it "works
with pipe-to-sh". Shell variable-assignment prefixes only apply to the one
command they're attached to; in a pipe each command is its own process, so
the env var was set for `curl` (which never reads it) and never reached
`sh` (which does). A real attempt to pin v0.123.0 for a #614 pure
reproduction silently installed "latest" instead.

Verified the fix with a minimal repro (VERSION=X cat file | sh vs.
cat file | VERSION=X sh) before changing anything.

Moves the VERSION= prefix onto sh -- the last command in the pipe, the one
that actually reads it -- in ON-DEVICE-INSTALL-WALKTHROUGH.md (both
occurrences), scripts/on-device-install/README.md, and
scripts/on-device-install/install.sh's own header comment.

RASPBERRY-PI.md/EXTERNAL-HOST-WALKTHROUGH.md's `sudo VERSION=x ... bash
install.sh` pattern is unaffected -- that's a direct invocation, not a
pipe, so the env var already reaches the right process there.
2026-08-16 15:54:00 +02:00
Tobias Gesellchen 2b0172c21b fix(ssh): reuse one SSH connection across RevertMigration's ~17 calls
Confirmed on real hardware (192.168.178.28): RevertMigration's full call
graph (revertXMLConfig/revertHosts/revertResolvConf/revertAftertouchHook/
removeRcLocalHooks/revertCACert) makes 17 separate client.Run() calls, and
pkg/ssh.Client.Run/UploadContent each dialed a brand-new SSH connection
per call with no reuse. Hitting a resource-constrained speaker with 17
rapid reconnects overwhelmed it -- confirmed via a follow-up plain SSH
command timing out at the TCP level, and the speaker going visibly
unresponsive.

Gives pkg/ssh.Client an opt-in persistent connection: Connect() dials
once and caches it, Close() releases it, and a shared dial() helper makes
Run/UploadContent reuse the cached connection when one's open, falling
back to today's per-call dial otherwise. RevertMigration now calls
Connect() once and defer Close(), collapsing 17 connections into 1. The
other ~21 m.NewSSH() call sites in pkg/service/setup never call Connect,
so their behavior is completely unchanged -- this only touches the one
function that was actually causing real-world problems.

SSHClient interface gained Connect()/Close(); both test mocks
(pkg/service/setup/setup_test.go, pkg/service/handlers/handlers_setup_test.go)
got no-op stubs. Added TestClose_NoOpWithoutConnect and
TestConnect_DialFailureLeavesConnNil in pkg/ssh/ssh_test.go -- these don't
prove connection reuse against a real server (Client.Run hardcodes :22,
no configurable port for a test listener), so that specific behavior is
verified by code review (a single `if c.conn != nil` branch) plus the
real-hardware confirmation above, not an automated integration test.

Also fixes the web UI's "Revert to Defaults" button, which calls the same
RevertMigration code path.
2026-08-16 15:54:00 +02:00
Tobias Gesellchen 96a62a847d feat(cli,docs): setup migrate URL overrides + real-hardware recovery notes
Prompted by recovering a real speaker (192.168.178.28) that had gone
stressed/unresponsive: it had previously been through `setup enable-ssh`
without --service-url (leaving margeServerUrl persisted as the
aftertouch.invalid placeholder, firmware retry-looping a failing DNS/curl
lookup against it, #546-shaped), compounded by a burst of SSH connections
from a `setup revert` attempt (see the dial-storm finding, tracked
separately, not fixed here).

Added --marge-url/--stats-url/--sw-update-url/--bmx-url override flags to
`setup migrate`. No new backend logic: telnetURLsFromOptions
(pkg/service/setup/telnet_migration.go) already supported per-field
overrides end-to-end, shared with the XML method's applyURLOverrides --
the CLI just never exposed it. Lets a speaker's URLs be pointed anywhere
(back to AfterTouch, or back to the genuine original Bose cloud) via a
single telnet connection, no SSH and no .original backup required --
confirmed recovering the real speaker above (migration committed, a
previously-timing-out plain SSH command returned instantly afterward).

Documented in TROUBLESHOOTING.md: the enable-ssh placeholder-persistence
gotcha (now with the escape hatch above) and the setup revert dial-storm
risk as a known, not-yet-fixed issue with a workaround (prefer this
lighter telnet-only migrate over repeated revert attempts). Documented the
new flags in CLI-REFERENCE.md's setup migrate section.
2026-08-16 15:54:00 +02:00
Tobias Gesellchen 76dc390b2a feat(cli,docs): setup sync/revert commands + on-device install doc gaps
Prompted by writing a #614 self-test guide (on-device install walkthrough)
and by helping fully revert a real speaker a factory reset didn't fully
clean up.

New soundtouch-cli commands (cmd/soundtouch-cli/cmd_setup.go):
- `setup sync` — wraps POST /api/setup/sync/{deviceId}, the same operation
  as the web UI's Devices -> Sync Data button. Read-only towards the
  speaker (presets/recents/sources into the datastore); never writes back.
- `setup revert` — wraps setup.Manager.RevertMigration, the same operation
  as the web UI's "Revert to Defaults" button. Restores
  SoundTouchSdkPrivateCfg.xml/hosts/resolv.conf from their .original
  backups and strips the AfterTouch CA cert from the trust bundle. No
  --service-url needed; pure SSH against the speaker. Deliberately leaves
  SSH persistence and account pairing untouched, matching the web UI
  button (use `setup remote-services --remove` / `account unpair` for
  those).

Both are thin wrappers with no new business logic, matching the existing
migrate/pair/reboot pattern. Tests added for setup sync's HTTP plumbing
(auth-retry, device-scoped URL, error propagation); no CLI-level test for
setup revert, consistent with reboot/migrate/pair also having none --
RevertMigration itself is already tested in pkg/service/setup/setup_test.go.

Documentation gaps closed:
- ON-DEVICE-INSTALL-WALKTHROUGH.md never showed the Migrate step at all --
  jumped from install/reboot straight to the pairing QuickFix as if the
  speaker were already pointed at itself. Added an explicit Migrate step
  (web-UI and CLI paths), a CLI alternative for the pairing QuickFix, a
  no-USB-stick `enable-ssh` (#471) alternative to the physical stick
  procedure, and a "testing a pre-release build" section for cross-
  compiling and manually swapping an unreleased binary (soundtouch-cli
  deploy step included, mirroring the already-covered soundtouch-service
  swap).
- MIGRATION-GUIDE.md's "never use localhost" Target Domain warning had no
  on-device exception, even though loopback is exactly correct there since
  the speaker and the service are the same machine. Added the callout, and
  the same enable-ssh alternative to its SSH-enablement step.
- DEVICE-INITIAL-SETUP.md's AP-mode Wi-Fi provisioning commands were
  macOS-only (networksetup, dns-sd) with no Linux/Windows equivalents,
  unlike the rest of the docs. Added nmcli/netsh wlan alongside.
- CLI-REFERENCE.md's entire `setup <subcommand>` group was undocumented
  (--help was the only reference) -- wrote a full "Setup & Migration"
  section covering all 16 subcommands, and added the also-undocumented
  `account unpair` to the existing Music Service Account Management
  section.
2026-08-16 15:54:00 +02:00
Tobias Gesellchen ba43b9ac16 docs/scripts: bump stale 0.111.x example versions to current release
Repo-wide sweep of example version strings still pinned around 0.111.2/
0.111.3 (four releases behind) across install-script comments, README
walkthroughs, the FALLBACK_VERSION defaults in on-device-install and
raspberry-pi install scripts, and the bug-report issue template's version
placeholder. Bumped to 0.123.0, the current release.

Left untouched: RFC-5737 example IPs and Go test fixtures that happened to
match the same version-number pattern, dated blog posts, and the Hugo
theme's own unrelated version pin.
2026-08-16 15:54:00 +02:00
dependabot[bot] 9244c00cff deps(deps): bump the golang group with 4 updates
Bumps the golang group with 4 updates: [golang.org/x/crypto](https://github.com/golang/crypto), [golang.org/x/mod](https://github.com/golang/mod), [golang.org/x/image](https://github.com/golang/image) and [golang.org/x/text](https://github.com/golang/text).


Updates `golang.org/x/crypto` from 0.54.0 to 0.55.0
- [Commits](https://github.com/golang/crypto/compare/v0.54.0...v0.55.0)

Updates `golang.org/x/mod` from 0.38.0 to 0.39.0
- [Commits](https://github.com/golang/mod/compare/v0.38.0...v0.39.0)

Updates `golang.org/x/image` from 0.44.0 to 0.45.0
- [Commits](https://github.com/golang/image/compare/v0.44.0...v0.45.0)

Updates `golang.org/x/text` from 0.40.0 to 0.41.0
- [Release notes](https://github.com/golang/text/releases)
- [Commits](https://github.com/golang/text/compare/v0.40.0...v0.41.0)

---
updated-dependencies:
- dependency-name: golang.org/x/crypto
  dependency-version: 0.55.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: golang
- dependency-name: golang.org/x/mod
  dependency-version: 0.39.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: golang
- dependency-name: golang.org/x/image
  dependency-version: 0.45.0
  dependency-type: indirect
  update-type: version-update:semver-minor
  dependency-group: golang
- dependency-name: golang.org/x/text
  dependency-version: 0.41.0
  dependency-type: indirect
  update-type: version-update:semver-minor
  dependency-group: golang
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-08-15 14:46:29 +02:00
Tobias Gesellchen b0b8b9a475 feat(health): offer a sourcesUpdated pull alongside the presets push
Adds a second QuickFix to the speaker_presets_count warning, reusing
the existing postSourcesUpdated fix (checks_refresh_sources.go). It
nudges the speaker to re-fetch /full, which is confirmed (both from
marge.AccountFullToXML and a genuine captured Bose-cloud response) to
carry presets alongside sources.

Whether firmware actually re-applies /full's preset section back onto
its own local table is unconfirmed — issue253_regression_test.go
already flags that exact link as untested. So this is offered as a
free, non-destructive thing to try first, with the guaranteed
restore_presets_to_speaker push as the fallback. Gives both directions
(pull-style nudge, direct push) rather than only the one.

Refs #614
2026-08-15 14:39:49 +02:00
Tobias Gesellchen 0f452357e2 feat(health): add "Restore presets to speaker" QuickFix
When the speaker shows 0 preset slots while the service's Presets.xml
has entries (the #614 pattern), replays each stored preset onto the
speaker via :8090/storePreset (client.StorePreset), one slot at a
time. Doesn't require a reboot and doesn't need the content playing
first, unlike a physical preset-button save.

Sync only ever reads from the speaker; this is the missing write
direction, and lets a reporter try recovering presets without
re-entering all 6 by hand.

Refs #614
2026-08-15 14:39:49 +02:00
Tobias Gesellchen bcb819dccd fix(health): correct preset-count guidance, drop wrong citation
The speaker_presets_count check told users a power-cycle "usually
re-syncs" missing presets. #614 shows a power-cycle is itself one of
the two reported triggers for the speaker wiping its own presets, so
that advice was actively harmful for this failure mode.

Also fixes the comment's citation: it claimed this was a known pattern
from discussion #295 and #235, but neither actually discusses preset
loss (#295 is a cloud-hosting question, #235 a closed Spotify
preset-save bug). That reference was wrong from the original commit
(7d46ae2); #614 is the first confirmed instance.

Refs #614
2026-08-15 14:39:49 +02:00
Tobias Gesellchen 888348ac55 chore: bump Go to 1.26.6, refresh example go.mod pins
Bumps the go directive to 1.26.6 across the main module and both
standalone example modules (preset-management, navigation-station-demo),
plus the builder image in Dockerfile and the three mock-service images
in docker-compose.ci.yml.

Also refreshes the examples' require github.com/gesellix/bose-soundtouch
pin from the stale v0.118.0 to the current v0.123.0 release tag (the
replace directive means they build against local source regardless,
but the pin should still track reality). go mod tidy run in all three
modules; no other dependency changes.
2026-08-15 14:35:10 +02:00
dependabot[bot] 998054e993 ci(deps): bump the codeql-action group with 3 updates
Bumps the codeql-action group with 3 updates: [github/codeql-action/init](https://github.com/github/codeql-action), [github/codeql-action/analyze](https://github.com/github/codeql-action) and [github/codeql-action/upload-sarif](https://github.com/github/codeql-action).


Updates `github/codeql-action/init` from 4.37.4 to 4.37.6
- [Release notes](https://github.com/github/codeql-action/releases)
- [Changelog](https://github.com/github/codeql-action/blob/main/CHANGELOG.md)
- [Commits](https://github.com/github/codeql-action/compare/f205ea1c3313d32999d8d6a48b4f6530d4437b38...5595ccaf912efad79be6eef63a5619ff05969be3)

Updates `github/codeql-action/analyze` from 4.37.4 to 4.37.6
- [Release notes](https://github.com/github/codeql-action/releases)
- [Changelog](https://github.com/github/codeql-action/blob/main/CHANGELOG.md)
- [Commits](https://github.com/github/codeql-action/compare/f205ea1c3313d32999d8d6a48b4f6530d4437b38...5595ccaf912efad79be6eef63a5619ff05969be3)

Updates `github/codeql-action/upload-sarif` from 4.37.4 to 4.37.6
- [Release notes](https://github.com/github/codeql-action/releases)
- [Changelog](https://github.com/github/codeql-action/blob/main/CHANGELOG.md)
- [Commits](https://github.com/github/codeql-action/compare/f205ea1c3313d32999d8d6a48b4f6530d4437b38...5595ccaf912efad79be6eef63a5619ff05969be3)

---
updated-dependencies:
- dependency-name: github/codeql-action/init
  dependency-version: 4.37.6
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: codeql-action
- dependency-name: github/codeql-action/analyze
  dependency-version: 4.37.6
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: codeql-action
- dependency-name: github/codeql-action/upload-sarif
  dependency-version: 4.37.6
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: codeql-action
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-08-11 12:09:20 +02:00
Tobias Gesellchen 68c2f6400e docs(cli): document the update-check command in CLI-REFERENCE.md
#591 added `soundtouch-cli update-check` / `soundtouch-backup
update-check` (PR #611) but the CLI command reference never got the
matching entry.
2026-08-11 09:40:38 +02:00
Tobias Gesellchen 97caea112f feat(cli): add on-demand update-check command to soundtouch-cli and soundtouch-backup
Answers #591's open question 2: CLI-only users get no update notice
from soundtouch-service's periodic background check. Both binaries
gain a soundtouch-cli/soundtouch-backup update-check command that
does a single, on-demand GitHub Releases check via the existing
pkg/service/updatecheck package. Running the command is itself the
opt-in, so unlike the service there's no config flag or persisted
state.

pkg/service/updatecheck.Checker was already designed decoupled from
handlers.Server/main.go specifically so other binaries could import
it directly; this is that follow-through.
2026-08-11 09:27:54 +02:00
Tobias Gesellchen 4b245d264e chore 2026-08-10 23:17:36 +02:00
Tobias Gesellchen df5eb1af01 fix(admin): add matching syntax help to the Discovery Interval field
The update-check interval field just got an info-toggle explaining Go
duration syntax; Discovery Interval takes the exact same syntax and
had no such help, which would read as inconsistent on the same
Settings page. Pre-existing gap, unrelated to #591 itself, but small
enough to fix alongside it while the pattern is fresh.
2026-08-10 23:17:36 +02:00
Tobias Gesellchen b138892c2a fix(admin): add syntax help for the update-check interval field
Reuses the existing info-toggle/info-details pattern (already used for
the HTTPS override, TLS extra hosts, and DNS upstream fields) rather
than inventing a new affordance, so users aren't left guessing at Go's
duration syntax when typing a custom interval.
2026-08-10 23:17:36 +02:00
Tobias Gesellchen 9f61d00b2d feat(admin): live Settings-page toggle for the opt-in update check
Follow-up to #591: UpdateCheckEnabled/UpdateCheckInterval are now
persisted, live-reloaded Settings fields (mirroring the discovery
enabled/interval pattern), editable from the admin Settings page
without a restart. The env var/CLI flag remains the seed value for a
fresh install with no settings.json yet.

The background goroutine now always runs and polls the live settings
every minute (updateCheckPollTick), instead of being started only if
enabled at process launch, so flipping the toggle takes effect within
a minute rather than requiring a restart.
2026-08-10 23:17:36 +02:00
Tobias Gesellchen e6cd031ea4 fix(player): render literal x instead of HTML entity for dismiss button
htm/Preact template literals insert text as a DOM text node rather than
parsing it as HTML, so the &times; entity was never decoded and showed
up literally in the player UI's announcement banner. The admin UI's
equivalent button is unaffected because it's built as an HTML string
inserted via innerHTML, where the browser does decode entities.

Fixes the player-UI regression noted in #591.
2026-08-10 22:31:44 +02:00
Tobias GesellchenandClaude Sonnet 5 f899dbaa89 test(marge): guard source XML shape; feat(library): merge speaker-side 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>
2026-08-10 22:27:13 +02:00
Tobias GesellchenandClaude Sonnet 5 401a546482 docs+fix(telnet): label getpdo output as runtime-layer-only, not proof of persistence
Addresses recommendations 4 and 7 from #515 comment 5231931569: a
green-looking getpdo readback only confirms the sys configuration
writes were accepted, not that they'll survive a reboot (that's what
the envswitch-persisted layer decides). Labels the getpdo line in both
migrateViaTelnet and runTelnetInjection's CLI/log output accordingly,
softens migrateViaTelnet's "succeeded" wording to "accepted", and adds
the same one-line caveat to TELNET-MIGRATION-METHOD.md #2.3 (previously
only in TELNET-COMMAND-REFERENCE.md).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-09 20:46:51 +02:00
Tobias GesellchenandClaude Sonnet 5 b7a2a7bdb1 docs(telnet): document per-port reboot readiness for enable-ssh retries
Confirmed on hardware (#471 comments 5231997551, 5232046477): after a
reboot, HTTP :8090 and the diagnostic telnet :17000 shell become ready
at very different times, up to ~92s. Our own enable-ssh retry guidance
tells users to power-cycle and re-run immediately, which can hit the
device mid-boot and surface as a raw connection-refused error. Adds a
troubleshooting entry plus the underlying measurement in
TELNET-COMMAND-REFERENCE.md; no code change.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-09 20:46:51 +02:00
Tobias GesellchenandClaude Sonnet 5 d36cd75d26 docs(telnet): correct envswitch/getpdo claims from #515/#471 measurements
Community hardware testing (bitranox, JRpersonal) on 2026-08-09 retracted
the earlier "inter-command delay is necessary" theory and established that
envswitch boseurls set commits the whole runtime layer (not just its two
arguments), has no read form, and doesn't ack with "OK". Corrects
TELNET-MIGRATION-METHOD.md and TELNET-COMMAND-REFERENCE.md accordingly,
retracts the stale "confirmed necessary" command-delay claim in
enable_ssh.go/cmd_setup.go, and lowers DefaultTelnetCommandDelay 5s -> 3s
as a smaller hedge now that the delay itself is known not to be the
mechanism.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-09 20:46:51 +02:00
Tobias GesellchenandClaude Sonnet 5 27da179082 fix(models): surface DeviceError's name attribute, not just its message
ErrorsResponse.Error() only returned the <error> element's text body,
dropping the name attribute entirely. Some speaker error responses
have a Message that just restates Value as text (e.g. a bare "1047"
for SOURCE_ALREADY_REMOVED), so callers only ever saw the useless
numeric string. Found while live-debugging a Deezer account
add/remove cycle on real hardware, where the raw XML
(<error value="1047" name="SOURCE_ALREADY_REMOVED">1047</error>)
carried real information only in the name attribute.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-09 19:47:49 +02:00
Tobias GesellchenandClaude Sonnet 5 aae1673451 fix(service): trim whitespace from TuneIn path/query params
Guards against whitespace-only stationID/podcastID/encodedName path
segments and tightens the existing empty-string checks on the search
q/cursor query params. Spotted while reviewing stalkerquatre-oss's
fork diff for TuneIn handling improvements; their s0/Radio fallback
defaults were skipped as unprecedented invented values that would
mask malformed requests instead of erroring.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-09 19:47:49 +02:00
Tobias GesellchenandClaude Sonnet 5 3cfbd05d99 fix(cli): bump default enable-ssh --full-config command delay to 5s
Follow-up to the 3s default from earlier in #515: the reporter agreed
5s is a better trade-off (issue comment 5230881285) — more headroom
than the original guess, still comfortably under the ~7s gap their
manual A/B test used.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-09 11:55:54 +02:00
Tobias GesellchenandClaude Sonnet 5 3b7ba8bd2f feat(cli): auto-pair unpaired devices before the enable-ssh injection
#515 comment 5230833551: on a genuinely unpaired (factory-reset) device,
margeServerUrl is reportedly never polled at all, so the boseurls
SSH-enable injection has no read cycle to fire on regardless of any
command delay. enable-ssh now checks /info first and, if
margeAccountUUID is empty, pairs the device via the existing
PairAccount helper (HTTP /setMargeAccount, telnet fallback) before
running the injection.

Adds setup.Manager.EnsureMargeAccountPaired plus --no-auto-pair (skip
entirely) and --account (use a specific 7-digit ID instead of a
generated one, e.g. to match one already in the datastore) flags on
enable-ssh. Pairing failure is a warning, not fatal, since the claim
is unconfirmed on this specific hardware and existing working flows
must not regress.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-09 11:55:54 +02:00
Tobias Gesellchen 73c0dfe88b ci(docs): treat non-404 4xx link-check failures as warnings, not errors
markdown-link-check has no concept of "warning" vs "error" — it's a
binary alive/dead per link, so a transient 429 from a rate-limiting site
we don't control (recently: blogspot.com, izndgroup.com) fails the whole
CI job exactly like a genuine dead link, with no way to tell them apart
from the job's exit code.

New scripts/check-doc-links.sh wraps the tool per file, parses its
"[✖] <url> → Status: <code>" output, and re-decides pass/fail per link:
404 still fails the build (a real dead link, worth fixing), other 4xx
(429, 401, 403, ...) become a GitHub Actions ::warning:: annotation
instead, and anything else (5xx, timeouts, DNS failures) still fails the
build same as before. De-dupes markdown-link-check's own doubled -v
output. Written for bash 3.2 (macOS's default /bin/bash) so it's testable
locally, not just on the ubuntu-latest runner.

Existing retry config in .github/markdown-link-check.json (retryOn429,
3 retries, 30s backoff) is untouched; this only changes what happens once
retries are exhausted.

Verified locally: 6 synthetic scenarios (404/429/500/mixed/clean/
duplicate-line) via a stubbed markdown-link-check, plus a real run
against the docs tree with the actual tool.
2026-08-09 11:29:49 +02:00
Tobias Gesellchen 69a21cdda7 feat(cli): configurable pause between enable-ssh --full-config commands
Per #515 comment 5228449448: on a real Lifestyle console, the same 6
commands (5 sys configuration/envswitch + reboot) sent back-to-back left
sshd down after reboot, but succeeded sent one at a time with ~7s gaps —
same commands, same order, same device, minutes apart. Sending fast may
not let the device fully process one command before the next arrives.

Adds --command-delay (setup.DefaultTelnetCommandDelay, 3s), threaded
through EnableSSHViaTelnetFullConfig/runTelnetInjection (pause after each
of the 5 commands) and runEnableSSHInjection (one more pause before the
reboot). 0 restores the old back-to-back behavior. The reporter didn't
try to find the true minimum, just confirmed ~7s works and speculated
"a second or two may well be enough" — 3s is a middle ground, tunable via
the flag if a specific device needs more.

Also prints an approximate total for the injection phase up front (6
steps x delay, ~18s at the default) so the command doesn't look hung —
separate from the existing --wait message for sshd coming up after
reboot, which can take much longer.

Refs #515
2026-08-09 11:14:03 +02:00
Tobias Gesellchen 06ad41412f fix(cli): stop ssh-check telling users telnet SSH-enable is impossible
setup ssh-check's failure message claimed "those commands were removed"
on FW 27.x and jumped straight to the USB-stick fallback — flatly
contradicted by setup enable-ssh, which exists specifically to bootstrap
SSH over telnet via the port-17000 envswitch trick (#471), and by
TELNET-COMMAND-REFERENCE.md's own notes that the injection is
field-confirmed on several FW 27.x models. Success is model/build-
dependent, not universally impossible — some devices (ST Portable, some
CineMate 520 units) need --full-config instead of the default injection.

Reordered the message to point at `setup enable-ssh` (with the
--full-config caveat) first, USB stick as the fallback if that doesn't
work on a given device — this was the exact point where a user hitting a
closed port 22 would previously be told to go find a USB stick without
ever learning the telnet route exists.

Refs #598
2026-08-09 11:14:03 +02:00
Tobias Gesellchen 69010f766e docs: document Lifestyle/console POWER-key behavior and input isolation
On Bose Lifestyle/CineMate consoles, the SoundTouch module is one input
among several, and #160 already established the input can't be switched
from the SoundTouch side. This adds a troubleshooting section covering:

- source="LOCAL" in /now_playing with LOCAL absent from /sources means
  the console is on a different input, not that the content is invalid.
- POST /key POWER is not a harmless "stop playback" on these devices like
  it is on a plain speaker — it puts the console into standby, and on
  waking it returns to the console's OWN input, not back to SoundTouch.
  A test loop that uses POWER between trials silently drops off
  SoundTouch after the first trial, so every later station reports
  INVALID_SOURCE regardless of whether it would actually play fine.

Also adds a cross-reference caveat to the `key power` row in
TELNET-COMMAND-REFERENCE.md, whose existing "no observable effect on FW
27.x" note is speaker-only phrasing that doesn't hold for these consoles.

Refs #597
2026-08-09 11:14:03 +02:00
Tobias Gesellchen 59a881e667 feat(announcements): support a proper link, not a raw URL in the message text
Follow-up to #591, prompted by the update-check notice showing a raw
https:// URL as plain text instead of a clickable link. Made it general
rather than a one-off fix, since future announcements may also want to
link to docs.

Added Announcement.LinkText/LinkURL (+ LinkURLFunc, the dynamic
counterpart, for the update-check entry's per-release URL) alongside the
existing Message/MessageFunc pair. Both frontends render it as a real
<a> element now: the admin UI (innerHTML) escapes Message/LinkText/LinkURL
via the existing escapeHtml() before composing the markup — previously
Message went into innerHTML unescaped, which this incidentally hardens;
the player (Preact/htm) templates an actual <a> rather than interpolating
a string, since Preact escapes string children by default and a raw
<a href=...> string would otherwise render as literal text, not a link.

Rephrased the #419 admin-gate announcement to use the new field too (was
a plain "See issue #419 for details." text mention).

Bug found while wiring this up: UpdateCheckState never persisted the
release URL, only the version — so after a restart, the announcement
would show a correct message but a broken/empty link until the next live
check completed (which can be up to a full interval away, since a fresh
check is skipped when the persisted last-check is still recent). Fixed by
adding UpdateCheckState.LastReleaseURL and threading it through
Checker.persist/NewChecker's seeding path, with a test
(TestNewChecker_SeedsFromPersistedState) that would have caught it.

Also fixed two gocritic rangeValCopy findings in
handlers_announcements.go (switched to index-based iteration) surfaced by
the Announcement struct growing with the new fields.

Refs #591
2026-08-09 10:18:19 +02:00
Tobias Gesellchen f33a306c47 chore: suppress math/rand Semgrep finding on two non-security use sites
Addresses the Semgrep finding on PR #599
(go.lang.security.audit.crypto.math_random.math-random-used) on the
update-check jitter delay, and applies the same treatment to the #419
activity-log filename suffix, which has the same non-security shape but
predates this PR's diff so it wasn't flagged.

Neither value is ever compared, kept secret, or otherwise security-
sensitive (a sleep duration and a filename-uniqueness suffix), so
crypto/rand would only add error-handling overhead for no real benefit.
Suppressed with the same // nosemgrep: <rule-id> pattern already used in
the mock-amazon/mock-spotify/mock-tunein servers, mirroring the existing
//nolint:gosec on the same lines.

Refs #591
2026-08-09 01:22:03 +02:00
Tobias Gesellchen afaa00e483 feat(version-info): expose update-check state; docs
Fifth and final piece of #591's initial implementation. Extends
/api/setup/version with update_available/latest_version/
latest_release_url (nil-safe via Server.UpdateCheckResult, defaults to
Available: false when the check was never enabled). Response switched from
map[string]string to map[string]interface{} to carry the new bool field;
updated the one existing test that decoded into the old stricter type.

Documents UPDATE_CHECK_ENABLED/UPDATE_CHECK_INTERVAL in the Configuration
Options reference table, explicit that this is the only network call
AfterTouch makes beyond speaker/provider traffic when enabled, and that it
defaults off.

This closes out the initial #591 implementation per the design doc
(_/i591/design-update-check.md): UpdateCheckState persistence, the
updatecheck.Checker package, background goroutine wiring with jitter/
backoff, reusing #419's Announcements mechanism instead of a second notice
UI, and this version-info exposure. `make check` passes end to end
(including the Docker HTTP integration suite).

Refs #591
2026-08-09 01:22:03 +02:00
Tobias Gesellchen 0c343f85c7 feat(announcements): reuse #419's mechanism for the update-check notice
Fourth piece of #591 — the "minimal and future-proof at once" move from
the design doc: no new notice UI, just one new entry in the #419
announcements list, which is already rendered in both the admin UI and the
player and already has per-ID dismissal.

Added Announcement.MessageFunc/DismissKeyFunc (nil = use the static
Message/ID, as before, so the existing #419 entry is unaffected) since
this entry's text names a specific version and its dismissal must be
per-version — dismissing the notice for v1.2.0 must not suppress a later
notice for v1.3.0. HandleListAnnouncements/HandleDismissAnnouncement now
compute the effective key through Announcement.dismissKey(s) rather than
reading the static ID field directly.

Refs #591
2026-08-09 01:22:03 +02:00
Tobias Gesellchen 1248a0bd1c feat(update-check): wire the Checker into the service, opt-in via env flags
Third piece of #591. --update-check-enabled/--update-check-interval
(UPDATE_CHECK_ENABLED/UPDATE_CHECK_INTERVAL), default off/24h, following
the same local main.go flag pattern as discovery-enabled — not pkg/config,
which soundtouch-service doesn't import at all (correction to the issue's
proposed location, see the design doc).

Background goroutine modeled on startDeviceDiscovery: startup jitter
(0-5min), skips the immediate check if the persisted last-check is still
fresh, backs off retries to no sooner than 1h after a failure, logs once
per newly-detected version. The decision logic (shouldCheckImmediately,
shouldSkipDueToBackoff, logUpdateIfNewlyAvailable) is split into pure,
directly-testable functions rather than living inline in the goroutine.

Server gets a SetUpdateChecker/UpdateCheckResult pair (nil-safe) so the
next two pieces (announcement, /api/setup/version) can read the current
state without importing updatecheck's construction details.

Manually verified against a running instance: enabled via flags, no panic,
service stays responsive (jitter means the actual first check can take up
to 5 minutes to fire, so this only confirms the wiring, not a live
GitHub response — that's covered by the previous commit's httptest-backed
unit tests).

Refs #591
2026-08-09 01:22:03 +02:00
Tobias Gesellchen c71f0623fb feat(updatecheck): add Checker package for GitHub release comparison
Second piece of #591. Standalone package (pkg/service/updatecheck):
GitHub releases API client, golang.org/x/mod/semver comparison (promoted
from indirect to direct dependency), persisted state via datastore's
UpdateCheckState. Dev/(devel)/dirty current versions skip the comparison
entirely rather than guessing; prereleases are excluded even though
GitHub's /releases/latest endpoint shouldn't return one anyway (defensive).

Deliberately decoupled from handlers.Server/main.go: repo and current
version are constructor arguments, not hardcoded, so a future CLI-side
check could reuse this as a plain import rather than a rewrite (open
question 2 in the design doc).

Not wired into the service yet — nothing calls NewChecker/CheckNow outside
tests.

Refs #591
2026-08-09 01:22:03 +02:00
Tobias Gesellchen 500f7850be feat(datastore): add UpdateCheckState persistence
First piece of #591 (opt-in periodic update check). A small persisted
state (last_checked_at, last_seen_version) under update-check.json,
mirroring Settings' Get/Save shape — separate from Settings itself since
this is runtime state, not operator-editable config.

Not wired to anything yet.

Refs #591
2026-08-09 01:22:03 +02:00
Tobias Gesellchen 29463fac98 feat(admin): show the resolved data directory in Settings
Prompted by not being able to tell where a locally-run instance's data dir
actually was without inspecting the running process (ps/lsof). Adds
data_dir to /api/setup/version's response, resolved to an absolute path so
it's unambiguous regardless of whether --data-dir/DATA_DIR was relative or
left at the default.

Shown as a read-only line at the top of the Settings tab, not the always-
visible footer — the footer is prime real estate seen on every tab/every
page load, and this is a rarely-needed piece of diagnostic info that
belongs alongside the rest of System Settings instead.

Also filled in a test-helper gap: the pkg/service/handlers package's
internal test router (main_test.go) never registered /setup/version at
all, unlike the real production router — added it so the new test (and any
future one exercising this endpoint) can actually run.

Unrelated to #419, but found while verifying that work against a running
instance.
2026-08-08 23:49:57 +02:00
Tobias Gesellchen bdcbd29e3b fix(admin): add the actual Settings control for admin_area_auth
The backend (field, validation, guard rail, live-reload middleware) has
worked since the first #419 commit, and the announcement banner correctly
told people "you can opt in now in Settings" — but there was never
actually a control in Settings to do that with. Caught by manual testing
against a running instance: the banner rendered fine, proving chunks 1-6
worked, but Settings had nothing to act on.

Adds a select (mirroring default_landing's pattern) with the tri-state
choices spelled out in plain language, wired into updateSettings()/
fetchSettings() alongside the other fields.

Verified end-to-end against a running instance: setting it to "enabled"
(with non-default credentials) persists, and immediately gates /admin
(401 without credentials, 200 with) without a restart.

Refs #419
2026-08-08 23:49:57 +02:00
Tobias Gesellchen d2ba3d757d feat(player): render announcement banners in soundtouch-player too
The design's three-area target model (chooser/app/admin) and the backend
(HandleListAnnouncements' target=app filtering) already supported this, but
nothing in soundtouch-player's frontend called it — chunk 5 only wired the
admin UI. New Announcements Preact component (static/js/components/), mounted
in App() above the main content so it's visible across every page, styled
with the app's existing CSS variables (dark-mode-aware, unlike the admin
UI's hardcoded inline colors). Currently renders nothing, since no
announcement in the list targets "app" yet (only the admin-gate notice,
targeting "admin") — this is just closing the parity gap so a future
app-targeted announcement has somewhere to show up.

Verified end-to-end against a running instance: the component is served
under /app/static/js/components/, app.js references it, and
/api/announcements?target=app responds correctly (empty today).

Refs #419
2026-08-08 23:49:57 +02:00
Tobias Gesellchen 50509ef729 feat(export): bundle the local activity log into diagnostic exports; docs
Seventh and final piece of #419's initial rollout. Adds addActivityLog to
buildDiagnosticArchive, walking stats/activity/ and bundling every event
file verbatim (same idea as the per-device XML bundling, mirroring
addSettingsJSON's placement). Without this, the privacy guarantee discussed
during design ("local-only, but included in an explicit diagnostic export")
would have been aspirational rather than true — caught before documenting
it as fact.

Documents the activity log in DIAGNOSTIC-EXPORT.md, anchored to the
existing "all data stays on your network" language in
SOUNDTOUCH-SERVICE-ANNOUNCEMENT.md.

This closes out the initial #419 implementation: AdminAreaAuth setting +
guard rail, BasicAuthAdmin gate, activity log + dismissal cache,
announcements + dismiss endpoint, admin UI banner, health check nudge, and
now diagnostic-export coverage + docs. Still opt-in only (AdminAreaAuth
defaults to unset) — flipping the default is a separate, later change per
the design doc's rollout plan.

Refs #419
2026-08-08 23:49:57 +02:00
Tobias Gesellchen 4bbcd4d178 feat(health): add admin_area_auth_available check
Sixth piece of #419. Visibility-only nudge, same spirit as
mgmt_default_credentials: surfaces on the Health tab that the admin-area
gate exists and is unset, for operators who dismissed the announcement
banner or never saw it on an older release. Does not gate anything.

Refs #419
2026-08-08 23:49:57 +02:00
Tobias Gesellchen 232adeb23f feat(admin): render dismissible announcement banners in the admin UI
Fifth piece of #419: wires up the announcements endpoint added in the
previous commit. A banner container sits outside the tab-content divs
(index.html) so it stays visible across all tabs, not just one. Fetched
once on page load alongside settings/version/devices; dismissing calls the
server-side dismiss endpoint (not a client-only localStorage flag, so it
stays dismissed across sessions/devices) and removes it from the DOM
immediately.

Manually verified end-to-end against a running instance: the banner
container renders, the admin-gate notice appears by default, dismissing it
removes it from subsequent /api/announcements responses. No Go test
coverage — this is frontend-only wiring of already-tested endpoints.

Refs #419
2026-08-08 23:49:57 +02:00
Tobias Gesellchen 8a4e4191a9 feat(admin): add announcements list + dismiss endpoint
Fourth piece of #419: a small in-code (not admin-authored) announcement
list, target-scoped ("app"/"admin", "chooser" reserved but not wired since
the landing page has no JS yet) and filterable by live server state via
ShowWhile. First entry: the admin-area-gate heads-up, shown on the admin
target while AdminAreaAuth is unset.

GET /api/announcements?target=... and POST /api/announcements/{id}/dismiss
are deliberately NOT behind BasicAuthAdmin — the whole point of the gate
notice is to reach operators who haven't set up credentials yet, the exact
audience an admin-only endpoint would exclude. The dismiss endpoint
validates id against the known announcement list before it reaches
RecordActivity, since this is the one call site where an id comes from an
HTTP request rather than a compile-time constant.

Updated the router snapshot (testdata/router_routes.txt) for the two new
routes.

Not wired into any UI yet — nothing calls these endpoints.

Refs #419
2026-08-08 23:49:57 +02:00
Tobias Gesellchen d930886f07 feat(admin): gate /admin + /api/setup behind BasicAuthAdmin when enabled
Third piece of #419. BasicAuthAdmin() mirrors BasicAuthMgmt but reads the
live AdminAreaAuth mode and credentials on every request instead of
capturing them once at router-setup time, so toggling the Settings-UI
switch takes effect immediately.

Split mountSetupAPI into mountSetupAPIShared (ca.crt, tts/speak, tts/config
— used directly by soundtouch-cli and soundtouch-player, must stay reachable
regardless of the gate) and mountSetupAPIAdmin (everything else). Wired the
gate around /admin and both mountSetupAPIAdmin mounts (/setup, /api/setup).
Stockholm's optional legacy setup wizard is intentionally left out of scope.

Also fixes two lint issues introduced in the prior commit (unchecked
json.Marshal in tests, HandleUpdateSettings over the cyclomatic complexity
threshold) since `make lint` wasn't run before that commit landed.

Refs #419
2026-08-08 23:49:57 +02:00
Tobias Gesellchen 5d12e7fac9 feat(admin): add local activity log + in-memory dismissal cache
Second piece of #419: a generic, local-only, append-only activity log
(datastore.RecordActivity/GetActivityRecords, one file per event under
stats/activity/<kind>/, same shape as SaveUsageStats) meant to back the
upcoming announcement-banner dismissals and be reusable for other admin-UI
action kinds later.

The read path never touches disk: a scoped startup scan folds prior
dismissals into an in-memory map once, RecordDismissal updates it
write-through. Same id can recur with a new timestamp (re-shown, dismissed
again) — it's a log, not a keyed store.

Not wired to anything user-facing yet — no announcements exist to dismiss.

Refs #419
2026-08-08 23:49:57 +02:00
Tobias Gesellchen 9090fad563 feat(admin): add tri-state AdminAreaAuth setting with default-creds guard rail
First piece of the #419 admin-area gate: a persisted, live-reloadable
tri-state setting ("" unset / "enabled" / "disabled") so a later release
can flip the default from open to gated without breaking an explicit
opt-out. Rejects enabling while MGMT_USERNAME/MGMT_PASSWORD are still the
published default, since that would give a false sense of security.

No behavior change yet — nothing reads this field to actually gate
anything. That's the next chunk.

Refs #419
2026-08-08 23:49:57 +02:00
Tobias Gesellchen bee0d25747 fix(admin): soften missing-account.json placeholder notice
The placeholder state is expected and harmless (every code path already
treats it safely, and it self-heals once language/provider settings are
saved), but the old wording read like a file-corruption error and leaked
the internal account.json filename to users. Reword it to explain the
actual (benign) state instead.

Refs #360
2026-08-08 21:31:20 +02:00
github-actions[bot] 811ce67972 chore: sync static dependencies with package.json 2026-08-06 11:40:59 +02:00
dependabot[bot] abd20f8c22 deps(deps): bump preact from 10.29.7 to 10.29.8
Bumps [preact](https://github.com/preactjs/preact) from 10.29.7 to 10.29.8.
- [Release notes](https://github.com/preactjs/preact/releases)
- [Commits](https://github.com/preactjs/preact/compare/10.29.7...10.29.8)

---
updated-dependencies:
- dependency-name: preact
  dependency-version: 10.29.8
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-08-06 11:40:59 +02:00
dependabot[bot] 5f64e4a185 ci(deps): bump docker/login-action from 4.5.2 to 4.6.0
Bumps [docker/login-action](https://github.com/docker/login-action) from 4.5.2 to 4.6.0.
- [Release notes](https://github.com/docker/login-action/releases)
- [Commits](https://github.com/docker/login-action/compare/371161bbe7024a29a25c5e19bfcbc0804fe9ad2c...dbcb813823bdd20940b903addbd779551569679f)

---
updated-dependencies:
- dependency-name: docker/login-action
  dependency-version: 4.6.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-08-06 08:19:28 +02:00
dependabot[bot] 028ad57ba8 ci(deps): bump the codeql-action group with 3 updates
Bumps the codeql-action group with 3 updates: [github/codeql-action/init](https://github.com/github/codeql-action), [github/codeql-action/analyze](https://github.com/github/codeql-action) and [github/codeql-action/upload-sarif](https://github.com/github/codeql-action).


Updates `github/codeql-action/init` from 4.37.3 to 4.37.4
- [Release notes](https://github.com/github/codeql-action/releases)
- [Changelog](https://github.com/github/codeql-action/blob/main/CHANGELOG.md)
- [Commits](https://github.com/github/codeql-action/compare/e4fba868fa4b1b91e1fdab776edc8cfbe6e9fb81...f205ea1c3313d32999d8d6a48b4f6530d4437b38)

Updates `github/codeql-action/analyze` from 4.37.3 to 4.37.4
- [Release notes](https://github.com/github/codeql-action/releases)
- [Changelog](https://github.com/github/codeql-action/blob/main/CHANGELOG.md)
- [Commits](https://github.com/github/codeql-action/compare/e4fba868fa4b1b91e1fdab776edc8cfbe6e9fb81...f205ea1c3313d32999d8d6a48b4f6530d4437b38)

Updates `github/codeql-action/upload-sarif` from 4.37.3 to 4.37.4
- [Release notes](https://github.com/github/codeql-action/releases)
- [Changelog](https://github.com/github/codeql-action/blob/main/CHANGELOG.md)
- [Commits](https://github.com/github/codeql-action/compare/e4fba868fa4b1b91e1fdab776edc8cfbe6e9fb81...f205ea1c3313d32999d8d6a48b4f6530d4437b38)

---
updated-dependencies:
- dependency-name: github/codeql-action/init
  dependency-version: 4.37.4
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: codeql-action
- dependency-name: github/codeql-action/analyze
  dependency-version: 4.37.4
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: codeql-action
- dependency-name: github/codeql-action/upload-sarif
  dependency-version: 4.37.4
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: codeql-action
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-08-06 08:19:04 +02:00
Tobias GesellchenandClaude Sonnet 5 0489dd39cb fix(admin): stop Save Settings from dropping fields the UI doesn't manage
HandleUpdateSettings and HandleUpdateLoggingSettings each built a fresh
datastore.Settings{} from scratch before saving, so any field not covered
by that handler's own DTO (e.g. hand-edited trust_forwarded_headers /
trusted_proxy_cidrs) was silently reset to its zero value on every save.
Load the persisted settings first and overlay only the fields each
handler actually owns, matching the pattern already used elsewhere
(addMargeHostToTLSFix).

Fixes #589

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-29 19:05:49 +02:00
dependabot[bot]andlnx01 44e364d33f ci(deps): bump docker/login-action from 4.4.0 to 4.5.2 (#588)
Bumps [docker/login-action](https://github.com/docker/login-action) from
4.4.0 to 4.5.2.
<details>
<summary>Release notes</summary>
<p><em>Sourced from <a
href="https://github.com/docker/login-action/releases">docker/login-action's
releases</a>.</em></p>
<blockquote>
<h2>v4.5.2</h2>
<ul>
<li>Surface Docker Hub OIDC error responses by <a
href="https://github.com/crazy-max"><code>@​crazy-max</code></a> in <a
href="https://redirect.github.com/docker/login-action/pull/1058">docker/login-action#1058</a></li>
</ul>
<p><strong>Full Changelog</strong>: <a
href="https://github.com/docker/login-action/compare/v4.5.1...v4.5.2">https://github.com/docker/login-action/compare/v4.5.1...v4.5.2</a></p>
<h2>v4.5.1</h2>
<ul>
<li>Support <code>dhi.io</code> as Docker Hub OIDC registry by <a
href="https://github.com/crazy-max"><code>@​crazy-max</code></a> in <a
href="https://redirect.github.com/docker/login-action/pull/1054">docker/login-action#1054</a></li>
</ul>
<p><strong>Full Changelog</strong>: <a
href="https://github.com/docker/login-action/compare/v4.5.0...v4.5.1">https://github.com/docker/login-action/compare/v4.5.0...v4.5.1</a></p>
<h2>v4.5.0</h2>
<ul>
<li><a href="https://github.com/docker/login-action#docker-hub">Docker
Hub OIDC</a> login support by <a
href="https://github.com/crazy-max"><code>@​crazy-max</code></a> in <a
href="https://redirect.github.com/docker/login-action/pull/1048">docker/login-action#1048</a></li>
<li>Bump <code>@​aws-sdk/client-ecr</code> and
<code>@​aws-sdk/client-ecr-public</code> to 3.1091.0 in <a
href="https://redirect.github.com/docker/login-action/pull/1037">docker/login-action#1037</a></li>
<li>Bump <code>@​docker/actions-toolkit</code> from 0.92.0 to 0.94.0 in
<a
href="https://redirect.github.com/docker/login-action/pull/1044">docker/login-action#1044</a>
<a
href="https://redirect.github.com/docker/login-action/pull/1050">docker/login-action#1050</a></li>
<li>Bump brace-expansion from 1.1.13 to 1.1.16 in <a
href="https://redirect.github.com/docker/login-action/pull/1046">docker/login-action#1046</a></li>
<li>Bump js-yaml from 5.2.0 to 5.2.1 in <a
href="https://redirect.github.com/docker/login-action/pull/1038">docker/login-action#1038</a></li>
</ul>
<p><strong>Full Changelog</strong>: <a
href="https://github.com/docker/login-action/compare/v4.4.0...v4.5.0">https://github.com/docker/login-action/compare/v4.4.0...v4.5.0</a></p>
</blockquote>
</details>
<details>
<summary>Commits</summary>
<ul>
<li><a
href="https://github.com/docker/login-action/commit/371161bbe7024a29a25c5e19bfcbc0804fe9ad2c"><code>371161b</code></a>
Merge pull request <a
href="https://redirect.github.com/docker/login-action/issues/1058">#1058</a>
from crazy-max/fix-dockerhub-oidc-error-handling</li>
<li><a
href="https://github.com/docker/login-action/commit/5dc73df38ebcfa6f96479901e253d172c3e35849"><code>5dc73df</code></a>
chore: update generated content</li>
<li><a
href="https://github.com/docker/login-action/commit/2aa1edee0b06c23880529064a4f7d7d3d2f9bc87"><code>2aa1ede</code></a>
surface Docker Hub OIDC error responses</li>
<li><a
href="https://github.com/docker/login-action/commit/abd2ef45e78c5afb21d64d4ca52ee8550d9572c7"><code>abd2ef4</code></a>
Merge pull request <a
href="https://redirect.github.com/docker/login-action/issues/1055">#1055</a>
from crazy-max/test-registry-auth-oidc</li>
<li><a
href="https://github.com/docker/login-action/commit/d49d3a9839fef51322fa44989a44fdc43fccfc22"><code>d49d3a9</code></a>
Merge pull request <a
href="https://redirect.github.com/docker/login-action/issues/1054">#1054</a>
from crazy-max/oidc-missing-dhi</li>
<li><a
href="https://github.com/docker/login-action/commit/b58b17c30b4db92a4ed049b213cae512b12e460b"><code>b58b17c</code></a>
test: cover Docker Hub OIDC with registry-auth</li>
<li><a
href="https://github.com/docker/login-action/commit/be646c21cec26cea303e29290d5f6ba6fde8e606"><code>be646c2</code></a>
chore: update generated content</li>
<li><a
href="https://github.com/docker/login-action/commit/d77c059cb9956cedaa427dc022d89f39acba678f"><code>d77c059</code></a>
support dhi.io as Docker Hub OIDC registry</li>
<li><a
href="https://github.com/docker/login-action/commit/06fb636fac595d6fb4b28a5dfcb21a6f5091859c"><code>06fb636</code></a>
Merge pull request <a
href="https://redirect.github.com/docker/login-action/issues/1037">#1037</a>
from docker/dependabot/npm_and_yarn/aws-sdk-dependen...</li>
<li><a
href="https://github.com/docker/login-action/commit/a8bc9539118a762b0e5788b53a50907977cc1b8d"><code>a8bc953</code></a>
[dependabot skip] chore: update generated content</li>
<li>Additional commits viewable in <a
href="https://github.com/docker/login-action/compare/af1e73f918a031802d376d3c8bbc3fe56130a9b0...371161bbe7024a29a25c5e19bfcbc0804fe9ad2c">compare
view</a></li>
</ul>
</details>
<br />


[![Dependabot compatibility
score](https://dependabot-badges.githubapp.com/badges/compatibility_score?dependency-name=docker/login-action&package-manager=github_actions&previous-version=4.4.0&new-version=4.5.2)](https://docs.github.com/en/github/managing-security-vulnerabilities/about-dependabot-security-updates#about-compatibility-scores)

Dependabot will resolve any conflicts with this PR as long as you don't
alter it yourself. You can also trigger a rebase manually by commenting
`@dependabot rebase`.

[//]: # (dependabot-automerge-start)
[//]: # (dependabot-automerge-end)

---

<details>
<summary>Dependabot commands and options</summary>
<br />

You can trigger Dependabot actions by commenting on this PR:
- `@dependabot rebase` will rebase this PR
- `@dependabot recreate` will recreate this PR, overwriting any edits
that have been made to it
- `@dependabot show <dependency name> ignore conditions` will show all
of the ignore conditions of the specified dependency
- `@dependabot ignore this major version` will close this PR and stop
Dependabot creating any more for this major version (unless you reopen
the PR or upgrade to it yourself)
- `@dependabot ignore this minor version` will close this PR and stop
Dependabot creating any more for this minor version (unless you reopen
the PR or upgrade to it yourself)
- `@dependabot ignore this dependency` will close this PR and stop
Dependabot creating any more for this dependency (unless you reopen the
PR or upgrade to it yourself)


</details>

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-07-28 18:08:16 +02:00
dependabot[bot]andlnx01 1721f46f9f ci(deps): bump the codeql-action group with 3 updates (#587)
Bumps the codeql-action group with 3 updates:
[github/codeql-action/init](https://github.com/github/codeql-action),
[github/codeql-action/analyze](https://github.com/github/codeql-action)
and
[github/codeql-action/upload-sarif](https://github.com/github/codeql-action).

Updates `github/codeql-action/init` from 4.37.1 to 4.37.3
<details>
<summary>Release notes</summary>
<p><em>Sourced from <a
href="https://github.com/github/codeql-action/releases">github/codeql-action/init's
releases</a>.</em></p>
<blockquote>
<h2>v4.37.3</h2>
<p>No user facing changes.</p>
<h2>v4.37.2</h2>
<ul>
<li>The new address format for the <code>config-file</code> input that
was introduced in CodeQL Action 4.37.0 is now enabled by default. In
addition to the format described there, the <code>remote=</code> prefix
can now be used to explicitly indicate that the input refers to a remote
file. All previous input formats continue to be accepted as well. <a
href="https://redirect.github.com/github/codeql-action/pull/4023">#4023</a></li>
<li>The CodeQL Action can now make use of <a
href="https://docs.github.com/en/code-security/how-tos/secure-at-scale/configure-organization-security/manage-usage-and-access/giving-org-access-private-registries">configured
private registries</a> in Default Setup to retrieve CodeQL configuration
files from remote repositories that require authentication. This will
allow customers to store their CodeQL configuration in a single
repository that can then be referenced by Default Setup workflows in
other repositories. We expect to roll this and other, related changes
out to everyone in July. <a
href="https://redirect.github.com/github/codeql-action/pull/4007">#4007</a></li>
</ul>
</blockquote>
</details>
<details>
<summary>Changelog</summary>
<p><em>Sourced from <a
href="https://github.com/github/codeql-action/blob/main/CHANGELOG.md">github/codeql-action/init's
changelog</a>.</em></p>
<blockquote>
<h1>CodeQL Action Changelog</h1>
<p>See the <a
href="https://github.com/github/codeql-action/releases">releases
page</a> for the relevant changes to the CodeQL CLI and language
packs.</p>
<h2>[UNRELEASED]</h2>
<ul>
<li>This version of the CodeQL Action adds support for the
<code>tools</code> input for the <code>codeql-action/init</code> step to
be specified using a <code>github-codeql-tools</code> <a
href="https://docs.github.com/en/organizations/managing-organization-settings/managing-custom-properties-for-repositories-in-your-organization">repository
property</a>. This feature will gradually be rolled out following the
release of this version. Once rolled out, this allows for the CodeQL CLI
version that is used in GitHub-managed workflows, such as Default Setup,
to be set to a custom value. For example, customers who run into issues
with rate limits when a new CodeQL CLI version is released can set the
value to <code>toolcache</code> to always use the CodeQL CLI version
that is available in the runner toolcache. For Advanced Setup workflows,
the value provided for <code>tools</code> in the workflow definition
always takes precedence unless the value of the repository property
starts with <code>!</code>. <a
href="https://redirect.github.com/github/codeql-action/pull/4037">#4037</a></li>
</ul>
<h2>4.37.3 - 22 Jul 2026</h2>
<p>No user facing changes.</p>
<h2>4.37.2 - 21 Jul 2026</h2>
<ul>
<li>The new address format for the <code>config-file</code> input that
was introduced in CodeQL Action 4.37.0 is now enabled by default. In
addition to the format described there, the <code>remote=</code> prefix
can now be used to explicitly indicate that the input refers to a remote
file. All previous input formats continue to be accepted as well. <a
href="https://redirect.github.com/github/codeql-action/pull/4023">#4023</a></li>
<li>The CodeQL Action can now make use of <a
href="https://docs.github.com/en/code-security/how-tos/secure-at-scale/configure-organization-security/manage-usage-and-access/giving-org-access-private-registries">configured
private registries</a> in Default Setup to retrieve CodeQL configuration
files from remote repositories that require authentication. This will
allow customers to store their CodeQL configuration in a single
repository that can then be referenced by Default Setup workflows in
other repositories. We expect to roll this and other, related changes
out to everyone in July. <a
href="https://redirect.github.com/github/codeql-action/pull/4007">#4007</a></li>
</ul>
<h2>4.37.1 - 16 Jul 2026</h2>
<ul>
<li><em>Upcoming breaking change</em>: Add a deprecation warning for
customers using CodeQL version 2.20.6 and earlier. These versions of
CodeQL were discontinued on 1 July 2026 alongside GitHub Enterprise
Server 3.16, and will be unsupported by the next minor release of the
CodeQL Action. <a
href="https://redirect.github.com/github/codeql-action/pull/3956">#3956</a></li>
<li>Update default CodeQL bundle version to <a
href="https://github.com/github/codeql-action/releases/tag/codeql-bundle-v2.26.1">2.26.1</a>.
<a
href="https://redirect.github.com/github/codeql-action/pull/4019">#4019</a></li>
</ul>
<h2>4.37.0 - 08 Jul 2026</h2>
<ul>
<li>Update default CodeQL bundle version to <a
href="https://github.com/github/codeql-action/releases/tag/codeql-bundle-v2.26.0">2.26.0</a>.
<a
href="https://redirect.github.com/github/codeql-action/pull/3995">#3995</a></li>
<li>In addition to the existing input format, the
<code>config-file</code> input for the <code>codeql-action/init</code>
step will soon support a new <code>[owner/]repo[@ref][:path]</code>
format. All components except the repository name are optional. If
omitted, <code>owner</code> defaults to the same owner as the repository
the analysis is running for, <code>ref</code> to <code>main</code>, and
<code>path</code> to <code>.github/codeql-action.yaml</code>. Support
for this format ships in this version of the CodeQL Action, but will
only be enabled over the coming weeks. <a
href="https://redirect.github.com/github/codeql-action/pull/3973">#3973</a></li>
</ul>
<h2>4.36.3 - 01 Jul 2026</h2>
<p>No user facing changes.</p>
<h2>4.36.2 - 04 Jun 2026</h2>
<ul>
<li>Cache CodeQL CLI version information across Actions steps. <a
href="https://redirect.github.com/github/codeql-action/pull/3943">#3943</a></li>
<li>Reduce requests while waiting for analysis processing by using
exponential backoff when polling SARIF processing status. <a
href="https://redirect.github.com/github/codeql-action/pull/3937">#3937</a></li>
<li>Update default CodeQL bundle version to <a
href="https://github.com/github/codeql-action/releases/tag/codeql-bundle-v2.25.6">2.25.6</a>.
<a
href="https://redirect.github.com/github/codeql-action/pull/3948">#3948</a></li>
</ul>
<h2>4.36.1 - 02 Jun 2026</h2>
<p>No user facing changes.</p>
<h2>4.36.0 - 22 May 2026</h2>
<ul>
<li><em>Breaking change</em>: Bump the minimum required CodeQL bundle
version to 2.19.4. <a
href="https://redirect.github.com/github/codeql-action/pull/3894">#3894</a></li>
<li>Add support for SHA-256 Git object IDs. <a
href="https://redirect.github.com/github/codeql-action/pull/3893">#3893</a></li>
<li>Update default CodeQL bundle version to <a
href="https://github.com/github/codeql-action/releases/tag/codeql-bundle-v2.25.5">2.25.5</a>.
<a
href="https://redirect.github.com/github/codeql-action/pull/3926">#3926</a></li>
</ul>
<h2>4.35.5 - 15 May 2026</h2>
<ul>
<li>We have improved how the JavaScript bundles for the CodeQL Action
are generated to avoid duplication across bundles and reduce the size of
the repository by around 70%. This should have no effect on the runtime
behaviour of the CodeQL Action. <a
href="https://redirect.github.com/github/codeql-action/pull/3899">#3899</a></li>
</ul>
<!-- raw HTML omitted -->
</blockquote>
<p>... (truncated)</p>
</details>
<details>
<summary>Commits</summary>
<ul>
<li><a
href="https://github.com/github/codeql-action/commit/e4fba868fa4b1b91e1fdab776edc8cfbe6e9fb81"><code>e4fba86</code></a>
Merge pull request <a
href="https://redirect.github.com/github/codeql-action/issues/4031">#4031</a>
from github/update-v4.37.3-72f6a9da0</li>
<li><a
href="https://github.com/github/codeql-action/commit/fb50ab5d62a274adf3ef3e22cfe750ae87a0ede7"><code>fb50ab5</code></a>
Update changelog for v4.37.3</li>
<li><a
href="https://github.com/github/codeql-action/commit/72f6a9da0def52d9193d6a758f0378b65091f8d1"><code>72f6a9d</code></a>
Merge pull request <a
href="https://redirect.github.com/github/codeql-action/issues/4030">#4030</a>
from github/mbg/fix/no-proxy</li>
<li><a
href="https://github.com/github/codeql-action/commit/3b5ee58597653d9cc6785f3f1277f796d81f3646"><code>3b5ee58</code></a>
Use default <code>request</code> options instead of
<code>undefined</code></li>
<li><a
href="https://github.com/github/codeql-action/commit/bfb6be4b5ecd3650f02f530571453e8c64ef0778"><code>bfb6be4</code></a>
Merge pull request <a
href="https://redirect.github.com/github/codeql-action/issues/4028">#4028</a>
from github/mergeback/v4.37.2-to-main-e0647621</li>
<li><a
href="https://github.com/github/codeql-action/commit/526ab84f9858816d9cf5f7b9df4dd5e2235f0eba"><code>526ab84</code></a>
Rebuild</li>
<li><a
href="https://github.com/github/codeql-action/commit/d6217b9b8c14166e4851db94c11155d03bd13c07"><code>d6217b9</code></a>
Update changelog and version after v4.37.2</li>
<li><a
href="https://github.com/github/codeql-action/commit/e0647621c2984b5ed2f768cb892365bf2a616ad1"><code>e064762</code></a>
Merge pull request <a
href="https://redirect.github.com/github/codeql-action/issues/4027">#4027</a>
from github/update-v4.37.2-385bcdc5a</li>
<li><a
href="https://github.com/github/codeql-action/commit/e0faed839190caa67a5cd42f1cc16246028ca3df"><code>e0faed8</code></a>
Add a couple of change notes</li>
<li><a
href="https://github.com/github/codeql-action/commit/73aad0eaa9df172668665a150d17b8bc5a650c20"><code>73aad0e</code></a>
Update changelog for v4.37.2</li>
<li>Additional commits viewable in <a
href="https://github.com/github/codeql-action/compare/7188fc363630916deb702c7fdcf4e481b751f97a...e4fba868fa4b1b91e1fdab776edc8cfbe6e9fb81">compare
view</a></li>
</ul>
</details>
<br />

Updates `github/codeql-action/analyze` from 4.37.1 to 4.37.3
<details>
<summary>Release notes</summary>
<p><em>Sourced from <a
href="https://github.com/github/codeql-action/releases">github/codeql-action/analyze's
releases</a>.</em></p>
<blockquote>
<h2>v4.37.3</h2>
<p>No user facing changes.</p>
<h2>v4.37.2</h2>
<ul>
<li>The new address format for the <code>config-file</code> input that
was introduced in CodeQL Action 4.37.0 is now enabled by default. In
addition to the format described there, the <code>remote=</code> prefix
can now be used to explicitly indicate that the input refers to a remote
file. All previous input formats continue to be accepted as well. <a
href="https://redirect.github.com/github/codeql-action/pull/4023">#4023</a></li>
<li>The CodeQL Action can now make use of <a
href="https://docs.github.com/en/code-security/how-tos/secure-at-scale/configure-organization-security/manage-usage-and-access/giving-org-access-private-registries">configured
private registries</a> in Default Setup to retrieve CodeQL configuration
files from remote repositories that require authentication. This will
allow customers to store their CodeQL configuration in a single
repository that can then be referenced by Default Setup workflows in
other repositories. We expect to roll this and other, related changes
out to everyone in July. <a
href="https://redirect.github.com/github/codeql-action/pull/4007">#4007</a></li>
</ul>
</blockquote>
</details>
<details>
<summary>Changelog</summary>
<p><em>Sourced from <a
href="https://github.com/github/codeql-action/blob/main/CHANGELOG.md">github/codeql-action/analyze's
changelog</a>.</em></p>
<blockquote>
<h1>CodeQL Action Changelog</h1>
<p>See the <a
href="https://github.com/github/codeql-action/releases">releases
page</a> for the relevant changes to the CodeQL CLI and language
packs.</p>
<h2>[UNRELEASED]</h2>
<ul>
<li>This version of the CodeQL Action adds support for the
<code>tools</code> input for the <code>codeql-action/init</code> step to
be specified using a <code>github-codeql-tools</code> <a
href="https://docs.github.com/en/organizations/managing-organization-settings/managing-custom-properties-for-repositories-in-your-organization">repository
property</a>. This feature will gradually be rolled out following the
release of this version. Once rolled out, this allows for the CodeQL CLI
version that is used in GitHub-managed workflows, such as Default Setup,
to be set to a custom value. For example, customers who run into issues
with rate limits when a new CodeQL CLI version is released can set the
value to <code>toolcache</code> to always use the CodeQL CLI version
that is available in the runner toolcache. For Advanced Setup workflows,
the value provided for <code>tools</code> in the workflow definition
always takes precedence unless the value of the repository property
starts with <code>!</code>. <a
href="https://redirect.github.com/github/codeql-action/pull/4037">#4037</a></li>
</ul>
<h2>4.37.3 - 22 Jul 2026</h2>
<p>No user facing changes.</p>
<h2>4.37.2 - 21 Jul 2026</h2>
<ul>
<li>The new address format for the <code>config-file</code> input that
was introduced in CodeQL Action 4.37.0 is now enabled by default. In
addition to the format described there, the <code>remote=</code> prefix
can now be used to explicitly indicate that the input refers to a remote
file. All previous input formats continue to be accepted as well. <a
href="https://redirect.github.com/github/codeql-action/pull/4023">#4023</a></li>
<li>The CodeQL Action can now make use of <a
href="https://docs.github.com/en/code-security/how-tos/secure-at-scale/configure-organization-security/manage-usage-and-access/giving-org-access-private-registries">configured
private registries</a> in Default Setup to retrieve CodeQL configuration
files from remote repositories that require authentication. This will
allow customers to store their CodeQL configuration in a single
repository that can then be referenced by Default Setup workflows in
other repositories. We expect to roll this and other, related changes
out to everyone in July. <a
href="https://redirect.github.com/github/codeql-action/pull/4007">#4007</a></li>
</ul>
<h2>4.37.1 - 16 Jul 2026</h2>
<ul>
<li><em>Upcoming breaking change</em>: Add a deprecation warning for
customers using CodeQL version 2.20.6 and earlier. These versions of
CodeQL were discontinued on 1 July 2026 alongside GitHub Enterprise
Server 3.16, and will be unsupported by the next minor release of the
CodeQL Action. <a
href="https://redirect.github.com/github/codeql-action/pull/3956">#3956</a></li>
<li>Update default CodeQL bundle version to <a
href="https://github.com/github/codeql-action/releases/tag/codeql-bundle-v2.26.1">2.26.1</a>.
<a
href="https://redirect.github.com/github/codeql-action/pull/4019">#4019</a></li>
</ul>
<h2>4.37.0 - 08 Jul 2026</h2>
<ul>
<li>Update default CodeQL bundle version to <a
href="https://github.com/github/codeql-action/releases/tag/codeql-bundle-v2.26.0">2.26.0</a>.
<a
href="https://redirect.github.com/github/codeql-action/pull/3995">#3995</a></li>
<li>In addition to the existing input format, the
<code>config-file</code> input for the <code>codeql-action/init</code>
step will soon support a new <code>[owner/]repo[@ref][:path]</code>
format. All components except the repository name are optional. If
omitted, <code>owner</code> defaults to the same owner as the repository
the analysis is running for, <code>ref</code> to <code>main</code>, and
<code>path</code> to <code>.github/codeql-action.yaml</code>. Support
for this format ships in this version of the CodeQL Action, but will
only be enabled over the coming weeks. <a
href="https://redirect.github.com/github/codeql-action/pull/3973">#3973</a></li>
</ul>
<h2>4.36.3 - 01 Jul 2026</h2>
<p>No user facing changes.</p>
<h2>4.36.2 - 04 Jun 2026</h2>
<ul>
<li>Cache CodeQL CLI version information across Actions steps. <a
href="https://redirect.github.com/github/codeql-action/pull/3943">#3943</a></li>
<li>Reduce requests while waiting for analysis processing by using
exponential backoff when polling SARIF processing status. <a
href="https://redirect.github.com/github/codeql-action/pull/3937">#3937</a></li>
<li>Update default CodeQL bundle version to <a
href="https://github.com/github/codeql-action/releases/tag/codeql-bundle-v2.25.6">2.25.6</a>.
<a
href="https://redirect.github.com/github/codeql-action/pull/3948">#3948</a></li>
</ul>
<h2>4.36.1 - 02 Jun 2026</h2>
<p>No user facing changes.</p>
<h2>4.36.0 - 22 May 2026</h2>
<ul>
<li><em>Breaking change</em>: Bump the minimum required CodeQL bundle
version to 2.19.4. <a
href="https://redirect.github.com/github/codeql-action/pull/3894">#3894</a></li>
<li>Add support for SHA-256 Git object IDs. <a
href="https://redirect.github.com/github/codeql-action/pull/3893">#3893</a></li>
<li>Update default CodeQL bundle version to <a
href="https://github.com/github/codeql-action/releases/tag/codeql-bundle-v2.25.5">2.25.5</a>.
<a
href="https://redirect.github.com/github/codeql-action/pull/3926">#3926</a></li>
</ul>
<h2>4.35.5 - 15 May 2026</h2>
<ul>
<li>We have improved how the JavaScript bundles for the CodeQL Action
are generated to avoid duplication across bundles and reduce the size of
the repository by around 70%. This should have no effect on the runtime
behaviour of the CodeQL Action. <a
href="https://redirect.github.com/github/codeql-action/pull/3899">#3899</a></li>
</ul>
<!-- raw HTML omitted -->
</blockquote>
<p>... (truncated)</p>
</details>
<details>
<summary>Commits</summary>
<ul>
<li><a
href="https://github.com/github/codeql-action/commit/e4fba868fa4b1b91e1fdab776edc8cfbe6e9fb81"><code>e4fba86</code></a>
Merge pull request <a
href="https://redirect.github.com/github/codeql-action/issues/4031">#4031</a>
from github/update-v4.37.3-72f6a9da0</li>
<li><a
href="https://github.com/github/codeql-action/commit/fb50ab5d62a274adf3ef3e22cfe750ae87a0ede7"><code>fb50ab5</code></a>
Update changelog for v4.37.3</li>
<li><a
href="https://github.com/github/codeql-action/commit/72f6a9da0def52d9193d6a758f0378b65091f8d1"><code>72f6a9d</code></a>
Merge pull request <a
href="https://redirect.github.com/github/codeql-action/issues/4030">#4030</a>
from github/mbg/fix/no-proxy</li>
<li><a
href="https://github.com/github/codeql-action/commit/3b5ee58597653d9cc6785f3f1277f796d81f3646"><code>3b5ee58</code></a>
Use default <code>request</code> options instead of
<code>undefined</code></li>
<li><a
href="https://github.com/github/codeql-action/commit/bfb6be4b5ecd3650f02f530571453e8c64ef0778"><code>bfb6be4</code></a>
Merge pull request <a
href="https://redirect.github.com/github/codeql-action/issues/4028">#4028</a>
from github/mergeback/v4.37.2-to-main-e0647621</li>
<li><a
href="https://github.com/github/codeql-action/commit/526ab84f9858816d9cf5f7b9df4dd5e2235f0eba"><code>526ab84</code></a>
Rebuild</li>
<li><a
href="https://github.com/github/codeql-action/commit/d6217b9b8c14166e4851db94c11155d03bd13c07"><code>d6217b9</code></a>
Update changelog and version after v4.37.2</li>
<li><a
href="https://github.com/github/codeql-action/commit/e0647621c2984b5ed2f768cb892365bf2a616ad1"><code>e064762</code></a>
Merge pull request <a
href="https://redirect.github.com/github/codeql-action/issues/4027">#4027</a>
from github/update-v4.37.2-385bcdc5a</li>
<li><a
href="https://github.com/github/codeql-action/commit/e0faed839190caa67a5cd42f1cc16246028ca3df"><code>e0faed8</code></a>
Add a couple of change notes</li>
<li><a
href="https://github.com/github/codeql-action/commit/73aad0eaa9df172668665a150d17b8bc5a650c20"><code>73aad0e</code></a>
Update changelog for v4.37.2</li>
<li>Additional commits viewable in <a
href="https://github.com/github/codeql-action/compare/7188fc363630916deb702c7fdcf4e481b751f97a...e4fba868fa4b1b91e1fdab776edc8cfbe6e9fb81">compare
view</a></li>
</ul>
</details>
<br />

Updates `github/codeql-action/upload-sarif` from 4.37.1 to 4.37.3
<details>
<summary>Release notes</summary>
<p><em>Sourced from <a
href="https://github.com/github/codeql-action/releases">github/codeql-action/upload-sarif's
releases</a>.</em></p>
<blockquote>
<h2>v4.37.3</h2>
<p>No user facing changes.</p>
<h2>v4.37.2</h2>
<ul>
<li>The new address format for the <code>config-file</code> input that
was introduced in CodeQL Action 4.37.0 is now enabled by default. In
addition to the format described there, the <code>remote=</code> prefix
can now be used to explicitly indicate that the input refers to a remote
file. All previous input formats continue to be accepted as well. <a
href="https://redirect.github.com/github/codeql-action/pull/4023">#4023</a></li>
<li>The CodeQL Action can now make use of <a
href="https://docs.github.com/en/code-security/how-tos/secure-at-scale/configure-organization-security/manage-usage-and-access/giving-org-access-private-registries">configured
private registries</a> in Default Setup to retrieve CodeQL configuration
files from remote repositories that require authentication. This will
allow customers to store their CodeQL configuration in a single
repository that can then be referenced by Default Setup workflows in
other repositories. We expect to roll this and other, related changes
out to everyone in July. <a
href="https://redirect.github.com/github/codeql-action/pull/4007">#4007</a></li>
</ul>
</blockquote>
</details>
<details>
<summary>Changelog</summary>
<p><em>Sourced from <a
href="https://github.com/github/codeql-action/blob/main/CHANGELOG.md">github/codeql-action/upload-sarif's
changelog</a>.</em></p>
<blockquote>
<h1>CodeQL Action Changelog</h1>
<p>See the <a
href="https://github.com/github/codeql-action/releases">releases
page</a> for the relevant changes to the CodeQL CLI and language
packs.</p>
<h2>[UNRELEASED]</h2>
<ul>
<li>This version of the CodeQL Action adds support for the
<code>tools</code> input for the <code>codeql-action/init</code> step to
be specified using a <code>github-codeql-tools</code> <a
href="https://docs.github.com/en/organizations/managing-organization-settings/managing-custom-properties-for-repositories-in-your-organization">repository
property</a>. This feature will gradually be rolled out following the
release of this version. Once rolled out, this allows for the CodeQL CLI
version that is used in GitHub-managed workflows, such as Default Setup,
to be set to a custom value. For example, customers who run into issues
with rate limits when a new CodeQL CLI version is released can set the
value to <code>toolcache</code> to always use the CodeQL CLI version
that is available in the runner toolcache. For Advanced Setup workflows,
the value provided for <code>tools</code> in the workflow definition
always takes precedence unless the value of the repository property
starts with <code>!</code>. <a
href="https://redirect.github.com/github/codeql-action/pull/4037">#4037</a></li>
</ul>
<h2>4.37.3 - 22 Jul 2026</h2>
<p>No user facing changes.</p>
<h2>4.37.2 - 21 Jul 2026</h2>
<ul>
<li>The new address format for the <code>config-file</code> input that
was introduced in CodeQL Action 4.37.0 is now enabled by default. In
addition to the format described there, the <code>remote=</code> prefix
can now be used to explicitly indicate that the input refers to a remote
file. All previous input formats continue to be accepted as well. <a
href="https://redirect.github.com/github/codeql-action/pull/4023">#4023</a></li>
<li>The CodeQL Action can now make use of <a
href="https://docs.github.com/en/code-security/how-tos/secure-at-scale/configure-organization-security/manage-usage-and-access/giving-org-access-private-registries">configured
private registries</a> in Default Setup to retrieve CodeQL configuration
files from remote repositories that require authentication. This will
allow customers to store their CodeQL configuration in a single
repository that can then be referenced by Default Setup workflows in
other repositories. We expect to roll this and other, related changes
out to everyone in July. <a
href="https://redirect.github.com/github/codeql-action/pull/4007">#4007</a></li>
</ul>
<h2>4.37.1 - 16 Jul 2026</h2>
<ul>
<li><em>Upcoming breaking change</em>: Add a deprecation warning for
customers using CodeQL version 2.20.6 and earlier. These versions of
CodeQL were discontinued on 1 July 2026 alongside GitHub Enterprise
Server 3.16, and will be unsupported by the next minor release of the
CodeQL Action. <a
href="https://redirect.github.com/github/codeql-action/pull/3956">#3956</a></li>
<li>Update default CodeQL bundle version to <a
href="https://github.com/github/codeql-action/releases/tag/codeql-bundle-v2.26.1">2.26.1</a>.
<a
href="https://redirect.github.com/github/codeql-action/pull/4019">#4019</a></li>
</ul>
<h2>4.37.0 - 08 Jul 2026</h2>
<ul>
<li>Update default CodeQL bundle version to <a
href="https://github.com/github/codeql-action/releases/tag/codeql-bundle-v2.26.0">2.26.0</a>.
<a
href="https://redirect.github.com/github/codeql-action/pull/3995">#3995</a></li>
<li>In addition to the existing input format, the
<code>config-file</code> input for the <code>codeql-action/init</code>
step will soon support a new <code>[owner/]repo[@ref][:path]</code>
format. All components except the repository name are optional. If
omitted, <code>owner</code> defaults to the same owner as the repository
the analysis is running for, <code>ref</code> to <code>main</code>, and
<code>path</code> to <code>.github/codeql-action.yaml</code>. Support
for this format ships in this version of the CodeQL Action, but will
only be enabled over the coming weeks. <a
href="https://redirect.github.com/github/codeql-action/pull/3973">#3973</a></li>
</ul>
<h2>4.36.3 - 01 Jul 2026</h2>
<p>No user facing changes.</p>
<h2>4.36.2 - 04 Jun 2026</h2>
<ul>
<li>Cache CodeQL CLI version information across Actions steps. <a
href="https://redirect.github.com/github/codeql-action/pull/3943">#3943</a></li>
<li>Reduce requests while waiting for analysis processing by using
exponential backoff when polling SARIF processing status. <a
href="https://redirect.github.com/github/codeql-action/pull/3937">#3937</a></li>
<li>Update default CodeQL bundle version to <a
href="https://github.com/github/codeql-action/releases/tag/codeql-bundle-v2.25.6">2.25.6</a>.
<a
href="https://redirect.github.com/github/codeql-action/pull/3948">#3948</a></li>
</ul>
<h2>4.36.1 - 02 Jun 2026</h2>
<p>No user facing changes.</p>
<h2>4.36.0 - 22 May 2026</h2>
<ul>
<li><em>Breaking change</em>: Bump the minimum required CodeQL bundle
version to 2.19.4. <a
href="https://redirect.github.com/github/codeql-action/pull/3894">#3894</a></li>
<li>Add support for SHA-256 Git object IDs. <a
href="https://redirect.github.com/github/codeql-action/pull/3893">#3893</a></li>
<li>Update default CodeQL bundle version to <a
href="https://github.com/github/codeql-action/releases/tag/codeql-bundle-v2.25.5">2.25.5</a>.
<a
href="https://redirect.github.com/github/codeql-action/pull/3926">#3926</a></li>
</ul>
<h2>4.35.5 - 15 May 2026</h2>
<ul>
<li>We have improved how the JavaScript bundles for the CodeQL Action
are generated to avoid duplication across bundles and reduce the size of
the repository by around 70%. This should have no effect on the runtime
behaviour of the CodeQL Action. <a
href="https://redirect.github.com/github/codeql-action/pull/3899">#3899</a></li>
</ul>
<!-- raw HTML omitted -->
</blockquote>
<p>... (truncated)</p>
</details>
<details>
<summary>Commits</summary>
<ul>
<li><a
href="https://github.com/github/codeql-action/commit/e4fba868fa4b1b91e1fdab776edc8cfbe6e9fb81"><code>e4fba86</code></a>
Merge pull request <a
href="https://redirect.github.com/github/codeql-action/issues/4031">#4031</a>
from github/update-v4.37.3-72f6a9da0</li>
<li><a
href="https://github.com/github/codeql-action/commit/fb50ab5d62a274adf3ef3e22cfe750ae87a0ede7"><code>fb50ab5</code></a>
Update changelog for v4.37.3</li>
<li><a
href="https://github.com/github/codeql-action/commit/72f6a9da0def52d9193d6a758f0378b65091f8d1"><code>72f6a9d</code></a>
Merge pull request <a
href="https://redirect.github.com/github/codeql-action/issues/4030">#4030</a>
from github/mbg/fix/no-proxy</li>
<li><a
href="https://github.com/github/codeql-action/commit/3b5ee58597653d9cc6785f3f1277f796d81f3646"><code>3b5ee58</code></a>
Use default <code>request</code> options instead of
<code>undefined</code></li>
<li><a
href="https://github.com/github/codeql-action/commit/bfb6be4b5ecd3650f02f530571453e8c64ef0778"><code>bfb6be4</code></a>
Merge pull request <a
href="https://redirect.github.com/github/codeql-action/issues/4028">#4028</a>
from github/mergeback/v4.37.2-to-main-e0647621</li>
<li><a
href="https://github.com/github/codeql-action/commit/526ab84f9858816d9cf5f7b9df4dd5e2235f0eba"><code>526ab84</code></a>
Rebuild</li>
<li><a
href="https://github.com/github/codeql-action/commit/d6217b9b8c14166e4851db94c11155d03bd13c07"><code>d6217b9</code></a>
Update changelog and version after v4.37.2</li>
<li><a
href="https://github.com/github/codeql-action/commit/e0647621c2984b5ed2f768cb892365bf2a616ad1"><code>e064762</code></a>
Merge pull request <a
href="https://redirect.github.com/github/codeql-action/issues/4027">#4027</a>
from github/update-v4.37.2-385bcdc5a</li>
<li><a
href="https://github.com/github/codeql-action/commit/e0faed839190caa67a5cd42f1cc16246028ca3df"><code>e0faed8</code></a>
Add a couple of change notes</li>
<li><a
href="https://github.com/github/codeql-action/commit/73aad0eaa9df172668665a150d17b8bc5a650c20"><code>73aad0e</code></a>
Update changelog for v4.37.2</li>
<li>Additional commits viewable in <a
href="https://github.com/github/codeql-action/compare/7188fc363630916deb702c7fdcf4e481b751f97a...e4fba868fa4b1b91e1fdab776edc8cfbe6e9fb81">compare
view</a></li>
</ul>
</details>
<br />


Dependabot will resolve any conflicts with this PR as long as you don't
alter it yourself. You can also trigger a rebase manually by commenting
`@dependabot rebase`.

[//]: # (dependabot-automerge-start)
[//]: # (dependabot-automerge-end)

---

<details>
<summary>Dependabot commands and options</summary>
<br />

You can trigger Dependabot actions by commenting on this PR:
- `@dependabot rebase` will rebase this PR
- `@dependabot recreate` will recreate this PR, overwriting any edits
that have been made to it
- `@dependabot show <dependency name> ignore conditions` will show all
of the ignore conditions of the specified dependency
- `@dependabot ignore <dependency name> major version` will close this
group update PR and stop Dependabot creating any more for the specific
dependency's major version (unless you unignore this specific
dependency's major version or upgrade to it yourself)
- `@dependabot ignore <dependency name> minor version` will close this
group update PR and stop Dependabot creating any more for the specific
dependency's minor version (unless you unignore this specific
dependency's minor version or upgrade to it yourself)
- `@dependabot ignore <dependency name>` will close this group update PR
and stop Dependabot creating any more for the specific dependency
(unless you unignore this specific dependency or upgrade to it yourself)
- `@dependabot unignore <dependency name>` will remove all of the ignore
conditions of the specified dependency
- `@dependabot unignore <dependency name> <ignore condition>` will
remove the ignore condition of the specified dependency and ignore
conditions


</details>

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-07-28 18:07:39 +02:00
Tobias GesellchenandClaude Sonnet 5 31ba2bd9cd docs: document and consolidate Management API credential defaults (#586)
## Summary
- Documents the default Management API credentials (`admin` /
`change_me!`) right where the Local Account tab is introduced
(`MUSIC-SERVICES.md`) — surfaced by #269, where the reporter was stuck
at an unexplained login prompt.
- Consolidates: the defaults now live in one place
(`SOUNDTOUCH-SERVICE.md`'s existing Configuration Options table), with
the other five mentions across the docs linking to it instead of
restating the value independently.
- Fixes two factual errors found while consolidating
(`SELF-HOSTING.md`): it claimed there's no login by default (wrong —
Basic Auth is always on with a published default) and that it protects
the Settings tab (wrong — `/api/setup/*` isn't behind Basic Auth at
all).

Refs #269.

## Test plan
- [x] Docs-only change; links verified against existing cross-file
anchor conventions used elsewhere in the repo

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-26 21:42:44 +02:00
Tobias GesellchenandClaude Opus 4.8 4a76805df4 feat(health): flag speakers whose runtime bmxRegistryUrl is still on the Bose cloud (#577)
## What

Adds a new `runtime_bmx_url_stale` health check to `soundtouch-service`.
For each reachable speaker it reads the **runtime** `bmxRegistryUrl`
(from the on-device `SoundTouchSdkPrivateCfg.xml` via SSH, or `getpdo
CurrentSystemConfiguration` over telnet) and warns when it still points
at the shut-down Bose cloud, offering a copy-paste re-migrate command.

## Why

Radio source types (TUNEIN / RADIO_BROWSER / LOCAL_INTERNET_RADIO) are
delivered to the speaker through the BMX registry. A speaker whose
runtime `bmxRegistryUrl` still names the Bose cloud can never mount
them, even though the service's own `/sources` listing is correct. The
existing `sources_xml_diff` check reports the *symptom* ("missing 3
source types"); this check reports the *per-device cause*, so an
operator sees exactly which speakers still need re-migrating.

This is the recurring "radio missing after migration" theme (relates to
#549, #547, #546, #493). In the #549 diagnostic, 6 of 9 speakers had
never actually been migrated (all four runtime URLs still on
`content.api.bose.io` / `streaming.bose.com`) while the service itself
looked healthy; this check would have surfaced that per device
immediately.

## False-positive guard

Under a DNS-based migration (AfterTouch acting as the speaker's DNS
server) a cloud URL is legitimate: the redirect happens at the DNS
layer, not by rewriting the on-device URL. So the check stays silent
while the service's own DNS interception is running (`GetDNSRunning`).
The router-DNS variant (the LAN's DNS points at AfterTouch without our
DNS server running) cannot be detected here, so it is called out as a
known exception in the finding text rather than suppressed.

## Changes

- `pkg/service/health/checks_runtime_bmx_url.go` (+ unit test): the
check, following the injected-closure pattern of `checks_marge_url.go`.
`assessRuntimeBmxURL` is the pure, testable core; `isBoseCloudHost` does
a domain-suffix match on the Bose cloud domains.
- `pkg/service/handlers/handlers_export.go`: a lightweight
`readSpeakerBmxRegistryURL(ip)` reader (SSH then telnet), reusing the
existing export imports. The diagnostic export path itself is unchanged.
- `pkg/service/handlers/server.go`: registers the check, wiring
`GetDNSRunning` as the guard.

The health package stays free of the SSH / `setup` imports (the reader
lives in the handlers layer), matching the existing dependency boundary.

## Testing

- `go test ./pkg/service/health/` green (new tests: cloud URL warns;
AfterTouch URL and empty URL do not; `isBoseCloudHost` matrix).
- `go vet` and `golangci-lint` clean on both packages; `go build
./cmd/soundtouch-service/` succeeds.

Not tied to a single issue to close; it complements the #549 / #547 /
#546 / #493 cluster as a diagnostic aid.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-26 21:11:10 +02:00
Tobias GesellchenandClaude Sonnet 5 bb9bce440e fix(admin): surface silent Management API auth failures, add link-state health checks (#585)
## Summary
- #269's stuck Spotify presets traced to the account link never
completing, invisible because the admin UI silently swallowed 401s from
`/api/mgmt/*` instead of prompting for a retry. The browser's own Basic
Auth caching already works correctly here (verified live); the bug was
purely missing feedback.
- Adds two Health-tab checks: Spotify configured but no account linked,
and Management API credentials still at the published default.

Refs #269, #419.

## Test plan
- [x] `make check` (fmt, vet, unit tests) clean
- [x] `make lint` clean
- [x] Live-tested end to end with a headless Chrome (chromedp) against a
local build: confirmed the new failure-path messages render correctly
for `fetchSpotifyStatus`, `fetchAccountList`, and `linkSpotify`

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-26 20:24:57 +02:00
dependabot[bot]andlnx01 a0f8b86922 ci(deps): bump the codeql-action group with 3 updates (#581)
Bumps the codeql-action group with 3 updates:
[github/codeql-action/init](https://github.com/github/codeql-action),
[github/codeql-action/analyze](https://github.com/github/codeql-action)
and
[github/codeql-action/upload-sarif](https://github.com/github/codeql-action).

Updates `github/codeql-action/init` from 4.37.0 to 4.37.1
<details>
<summary>Release notes</summary>
<p><em>Sourced from <a
href="https://github.com/github/codeql-action/releases">github/codeql-action/init's
releases</a>.</em></p>
<blockquote>
<h2>v4.37.1</h2>
<ul>
<li><em>Upcoming breaking change</em>: Add a deprecation warning for
customers using CodeQL version 2.20.6 and earlier. These versions of
CodeQL were discontinued on 1 July 2026 alongside GitHub Enterprise
Server 3.16, and will be unsupported by the next minor release of the
CodeQL Action. <a
href="https://redirect.github.com/github/codeql-action/pull/3956">#3956</a></li>
<li>Update default CodeQL bundle version to <a
href="https://github.com/github/codeql-action/releases/tag/codeql-bundle-v2.26.1">2.26.1</a>.
<a
href="https://redirect.github.com/github/codeql-action/pull/4019">#4019</a></li>
</ul>
</blockquote>
</details>
<details>
<summary>Changelog</summary>
<p><em>Sourced from <a
href="https://github.com/github/codeql-action/blob/main/CHANGELOG.md">github/codeql-action/init's
changelog</a>.</em></p>
<blockquote>
<h1>CodeQL Action Changelog</h1>
<p>See the <a
href="https://github.com/github/codeql-action/releases">releases
page</a> for the relevant changes to the CodeQL CLI and language
packs.</p>
<h2>[UNRELEASED]</h2>
<p>No user facing changes.</p>
<h2>4.37.1 - 16 Jul 2026</h2>
<ul>
<li><em>Upcoming breaking change</em>: Add a deprecation warning for
customers using CodeQL version 2.20.6 and earlier. These versions of
CodeQL were discontinued on 1 July 2026 alongside GitHub Enterprise
Server 3.16, and will be unsupported by the next minor release of the
CodeQL Action. <a
href="https://redirect.github.com/github/codeql-action/pull/3956">#3956</a></li>
<li>Update default CodeQL bundle version to <a
href="https://github.com/github/codeql-action/releases/tag/codeql-bundle-v2.26.1">2.26.1</a>.
<a
href="https://redirect.github.com/github/codeql-action/pull/4019">#4019</a></li>
</ul>
<h2>4.37.0 - 08 Jul 2026</h2>
<ul>
<li>Update default CodeQL bundle version to <a
href="https://github.com/github/codeql-action/releases/tag/codeql-bundle-v2.26.0">2.26.0</a>.
<a
href="https://redirect.github.com/github/codeql-action/pull/3995">#3995</a></li>
<li>In addition to the existing input format, the
<code>config-file</code> input for the <code>codeql-action/init</code>
step will soon support a new <code>[owner/]repo[@ref][:path]</code>
format. All components except the repository name are optional. If
omitted, <code>owner</code> defaults to the same owner as the repository
the analysis is running for, <code>ref</code> to <code>main</code>, and
<code>path</code> to <code>.github/codeql-action.yaml</code>. Support
for this format ships in this version of the CodeQL Action, but will
only be enabled over the coming weeks. <a
href="https://redirect.github.com/github/codeql-action/pull/3973">#3973</a></li>
</ul>
<h2>4.36.3 - 01 Jul 2026</h2>
<p>No user facing changes.</p>
<h2>4.36.2 - 04 Jun 2026</h2>
<ul>
<li>Cache CodeQL CLI version information across Actions steps. <a
href="https://redirect.github.com/github/codeql-action/pull/3943">#3943</a></li>
<li>Reduce requests while waiting for analysis processing by using
exponential backoff when polling SARIF processing status. <a
href="https://redirect.github.com/github/codeql-action/pull/3937">#3937</a></li>
<li>Update default CodeQL bundle version to <a
href="https://github.com/github/codeql-action/releases/tag/codeql-bundle-v2.25.6">2.25.6</a>.
<a
href="https://redirect.github.com/github/codeql-action/pull/3948">#3948</a></li>
</ul>
<h2>4.36.1 - 02 Jun 2026</h2>
<p>No user facing changes.</p>
<h2>4.36.0 - 22 May 2026</h2>
<ul>
<li><em>Breaking change</em>: Bump the minimum required CodeQL bundle
version to 2.19.4. <a
href="https://redirect.github.com/github/codeql-action/pull/3894">#3894</a></li>
<li>Add support for SHA-256 Git object IDs. <a
href="https://redirect.github.com/github/codeql-action/pull/3893">#3893</a></li>
<li>Update default CodeQL bundle version to <a
href="https://github.com/github/codeql-action/releases/tag/codeql-bundle-v2.25.5">2.25.5</a>.
<a
href="https://redirect.github.com/github/codeql-action/pull/3926">#3926</a></li>
</ul>
<h2>4.35.5 - 15 May 2026</h2>
<ul>
<li>We have improved how the JavaScript bundles for the CodeQL Action
are generated to avoid duplication across bundles and reduce the size of
the repository by around 70%. This should have no effect on the runtime
behaviour of the CodeQL Action. <a
href="https://redirect.github.com/github/codeql-action/pull/3899">#3899</a></li>
<li>For performance and accuracy reasons, <a
href="https://redirect.github.com/github/roadmap/issues/1158">improved
incremental analysis</a> will now only be enabled on a pull request when
diff-informed analysis is also enabled for that run. If diff-informed
analysis is unavailable (for example, because the PR diff ranges could
not be computed), the action will fall back to a full analysis. <a
href="https://redirect.github.com/github/codeql-action/pull/3791">#3791</a></li>
<li>If multiple inputs are provided for the GitHub-internal
<code>analysis-kinds</code> input, only <code>code-scanning</code> will
be enabled. The <code>analysis-kinds</code> input is experimental, for
GitHub-internal use only, and may change without notice at any time. <a
href="https://redirect.github.com/github/codeql-action/pull/3892">#3892</a></li>
<li>Added an experimental change which, when running a Code Scanning
analysis for a PR with <a
href="https://redirect.github.com/github/roadmap/issues/1158">improved
incremental analysis</a> enabled, prefers CodeQL CLI versions that have
a cached overlay-base database for the configured languages. This speeds
up analysis for a repository when there is not yet a cached overlay-base
database for the latest CLI version. We expect to roll this change out
to everyone in May. <a
href="https://redirect.github.com/github/codeql-action/pull/3880">#3880</a></li>
</ul>
<h2>4.35.4 - 07 May 2026</h2>
<ul>
<li>Update default CodeQL bundle version to <a
href="https://github.com/github/codeql-action/releases/tag/codeql-bundle-v2.25.4">2.25.4</a>.
<a
href="https://redirect.github.com/github/codeql-action/pull/3881">#3881</a></li>
</ul>
<h2>4.35.3 - 01 May 2026</h2>
<!-- raw HTML omitted -->
</blockquote>
<p>... (truncated)</p>
</details>
<details>
<summary>Commits</summary>
<ul>
<li><a
href="https://github.com/github/codeql-action/commit/7188fc363630916deb702c7fdcf4e481b751f97a"><code>7188fc3</code></a>
Merge pull request <a
href="https://redirect.github.com/github/codeql-action/issues/4020">#4020</a>
from github/update-v4.37.1-9e7c07009</li>
<li><a
href="https://github.com/github/codeql-action/commit/c8b5f69be686908c3dfd844428137d56fe80c936"><code>c8b5f69</code></a>
Update changelog for v4.37.1</li>
<li><a
href="https://github.com/github/codeql-action/commit/9e7c070092090e89e8b3d62f977d4456e0732cd7"><code>9e7c070</code></a>
Merge pull request <a
href="https://redirect.github.com/github/codeql-action/issues/4014">#4014</a>
from github/mbg/explicit-remote-prefix</li>
<li><a
href="https://github.com/github/codeql-action/commit/3492b7e9ab96e28b1d8b971345d30e929c6f8fee"><code>3492b7e</code></a>
Change <code>REMOTE_PATH_PREFIX</code> to <code>remote=</code></li>
<li><a
href="https://github.com/github/codeql-action/commit/3654baa924bc6456db54002581cb7c1c877548c4"><code>3654baa</code></a>
Merge remote-tracking branch 'origin/main' into
mbg/explicit-remote-prefix</li>
<li><a
href="https://github.com/github/codeql-action/commit/2d682ac05f1b3588aaff3814826bede39b9ba6bb"><code>2d682ac</code></a>
Merge pull request <a
href="https://redirect.github.com/github/codeql-action/issues/4017">#4017</a>
from github/dependabot/github_actions/dot-github/wor...</li>
<li><a
href="https://github.com/github/codeql-action/commit/23f6a50753a88efd9b7ae8687b29f6bdb65f6250"><code>23f6a50</code></a>
Merge pull request <a
href="https://redirect.github.com/github/codeql-action/issues/4009">#4009</a>
from github/mbg/action-state/additions</li>
<li><a
href="https://github.com/github/codeql-action/commit/1ee3c75d1988ab8621f01ebb165115c38d56df91"><code>1ee3c75</code></a>
Merge pull request <a
href="https://redirect.github.com/github/codeql-action/issues/4018">#4018</a>
from github/dependabot/github_actions/dot-github/wor...</li>
<li><a
href="https://github.com/github/codeql-action/commit/e053684dc500899b0b5520edc8549ac0f1ed730b"><code>e053684</code></a>
Merge pull request <a
href="https://redirect.github.com/github/codeql-action/issues/4015">#4015</a>
from github/dependabot/npm_and_yarn/npm-minor-fd2e83...</li>
<li><a
href="https://github.com/github/codeql-action/commit/6803c5671d2f87a83ed96e151c441b1cb3bdc66a"><code>6803c56</code></a>
Merge pull request <a
href="https://redirect.github.com/github/codeql-action/issues/4019">#4019</a>
from github/update-bundle/codeql-bundle-v2.26.1</li>
<li>Additional commits viewable in <a
href="https://github.com/github/codeql-action/compare/99df26d4f13ea111d4ec1a7dddef6063f76b97e9...7188fc363630916deb702c7fdcf4e481b751f97a">compare
view</a></li>
</ul>
</details>
<br />

Updates `github/codeql-action/analyze` from 4.37.0 to 4.37.1
<details>
<summary>Release notes</summary>
<p><em>Sourced from <a
href="https://github.com/github/codeql-action/releases">github/codeql-action/analyze's
releases</a>.</em></p>
<blockquote>
<h2>v4.37.1</h2>
<ul>
<li><em>Upcoming breaking change</em>: Add a deprecation warning for
customers using CodeQL version 2.20.6 and earlier. These versions of
CodeQL were discontinued on 1 July 2026 alongside GitHub Enterprise
Server 3.16, and will be unsupported by the next minor release of the
CodeQL Action. <a
href="https://redirect.github.com/github/codeql-action/pull/3956">#3956</a></li>
<li>Update default CodeQL bundle version to <a
href="https://github.com/github/codeql-action/releases/tag/codeql-bundle-v2.26.1">2.26.1</a>.
<a
href="https://redirect.github.com/github/codeql-action/pull/4019">#4019</a></li>
</ul>
</blockquote>
</details>
<details>
<summary>Changelog</summary>
<p><em>Sourced from <a
href="https://github.com/github/codeql-action/blob/main/CHANGELOG.md">github/codeql-action/analyze's
changelog</a>.</em></p>
<blockquote>
<h1>CodeQL Action Changelog</h1>
<p>See the <a
href="https://github.com/github/codeql-action/releases">releases
page</a> for the relevant changes to the CodeQL CLI and language
packs.</p>
<h2>[UNRELEASED]</h2>
<p>No user facing changes.</p>
<h2>4.37.1 - 16 Jul 2026</h2>
<ul>
<li><em>Upcoming breaking change</em>: Add a deprecation warning for
customers using CodeQL version 2.20.6 and earlier. These versions of
CodeQL were discontinued on 1 July 2026 alongside GitHub Enterprise
Server 3.16, and will be unsupported by the next minor release of the
CodeQL Action. <a
href="https://redirect.github.com/github/codeql-action/pull/3956">#3956</a></li>
<li>Update default CodeQL bundle version to <a
href="https://github.com/github/codeql-action/releases/tag/codeql-bundle-v2.26.1">2.26.1</a>.
<a
href="https://redirect.github.com/github/codeql-action/pull/4019">#4019</a></li>
</ul>
<h2>4.37.0 - 08 Jul 2026</h2>
<ul>
<li>Update default CodeQL bundle version to <a
href="https://github.com/github/codeql-action/releases/tag/codeql-bundle-v2.26.0">2.26.0</a>.
<a
href="https://redirect.github.com/github/codeql-action/pull/3995">#3995</a></li>
<li>In addition to the existing input format, the
<code>config-file</code> input for the <code>codeql-action/init</code>
step will soon support a new <code>[owner/]repo[@ref][:path]</code>
format. All components except the repository name are optional. If
omitted, <code>owner</code> defaults to the same owner as the repository
the analysis is running for, <code>ref</code> to <code>main</code>, and
<code>path</code> to <code>.github/codeql-action.yaml</code>. Support
for this format ships in this version of the CodeQL Action, but will
only be enabled over the coming weeks. <a
href="https://redirect.github.com/github/codeql-action/pull/3973">#3973</a></li>
</ul>
<h2>4.36.3 - 01 Jul 2026</h2>
<p>No user facing changes.</p>
<h2>4.36.2 - 04 Jun 2026</h2>
<ul>
<li>Cache CodeQL CLI version information across Actions steps. <a
href="https://redirect.github.com/github/codeql-action/pull/3943">#3943</a></li>
<li>Reduce requests while waiting for analysis processing by using
exponential backoff when polling SARIF processing status. <a
href="https://redirect.github.com/github/codeql-action/pull/3937">#3937</a></li>
<li>Update default CodeQL bundle version to <a
href="https://github.com/github/codeql-action/releases/tag/codeql-bundle-v2.25.6">2.25.6</a>.
<a
href="https://redirect.github.com/github/codeql-action/pull/3948">#3948</a></li>
</ul>
<h2>4.36.1 - 02 Jun 2026</h2>
<p>No user facing changes.</p>
<h2>4.36.0 - 22 May 2026</h2>
<ul>
<li><em>Breaking change</em>: Bump the minimum required CodeQL bundle
version to 2.19.4. <a
href="https://redirect.github.com/github/codeql-action/pull/3894">#3894</a></li>
<li>Add support for SHA-256 Git object IDs. <a
href="https://redirect.github.com/github/codeql-action/pull/3893">#3893</a></li>
<li>Update default CodeQL bundle version to <a
href="https://github.com/github/codeql-action/releases/tag/codeql-bundle-v2.25.5">2.25.5</a>.
<a
href="https://redirect.github.com/github/codeql-action/pull/3926">#3926</a></li>
</ul>
<h2>4.35.5 - 15 May 2026</h2>
<ul>
<li>We have improved how the JavaScript bundles for the CodeQL Action
are generated to avoid duplication across bundles and reduce the size of
the repository by around 70%. This should have no effect on the runtime
behaviour of the CodeQL Action. <a
href="https://redirect.github.com/github/codeql-action/pull/3899">#3899</a></li>
<li>For performance and accuracy reasons, <a
href="https://redirect.github.com/github/roadmap/issues/1158">improved
incremental analysis</a> will now only be enabled on a pull request when
diff-informed analysis is also enabled for that run. If diff-informed
analysis is unavailable (for example, because the PR diff ranges could
not be computed), the action will fall back to a full analysis. <a
href="https://redirect.github.com/github/codeql-action/pull/3791">#3791</a></li>
<li>If multiple inputs are provided for the GitHub-internal
<code>analysis-kinds</code> input, only <code>code-scanning</code> will
be enabled. The <code>analysis-kinds</code> input is experimental, for
GitHub-internal use only, and may change without notice at any time. <a
href="https://redirect.github.com/github/codeql-action/pull/3892">#3892</a></li>
<li>Added an experimental change which, when running a Code Scanning
analysis for a PR with <a
href="https://redirect.github.com/github/roadmap/issues/1158">improved
incremental analysis</a> enabled, prefers CodeQL CLI versions that have
a cached overlay-base database for the configured languages. This speeds
up analysis for a repository when there is not yet a cached overlay-base
database for the latest CLI version. We expect to roll this change out
to everyone in May. <a
href="https://redirect.github.com/github/codeql-action/pull/3880">#3880</a></li>
</ul>
<h2>4.35.4 - 07 May 2026</h2>
<ul>
<li>Update default CodeQL bundle version to <a
href="https://github.com/github/codeql-action/releases/tag/codeql-bundle-v2.25.4">2.25.4</a>.
<a
href="https://redirect.github.com/github/codeql-action/pull/3881">#3881</a></li>
</ul>
<h2>4.35.3 - 01 May 2026</h2>
<!-- raw HTML omitted -->
</blockquote>
<p>... (truncated)</p>
</details>
<details>
<summary>Commits</summary>
<ul>
<li><a
href="https://github.com/github/codeql-action/commit/7188fc363630916deb702c7fdcf4e481b751f97a"><code>7188fc3</code></a>
Merge pull request <a
href="https://redirect.github.com/github/codeql-action/issues/4020">#4020</a>
from github/update-v4.37.1-9e7c07009</li>
<li><a
href="https://github.com/github/codeql-action/commit/c8b5f69be686908c3dfd844428137d56fe80c936"><code>c8b5f69</code></a>
Update changelog for v4.37.1</li>
<li><a
href="https://github.com/github/codeql-action/commit/9e7c070092090e89e8b3d62f977d4456e0732cd7"><code>9e7c070</code></a>
Merge pull request <a
href="https://redirect.github.com/github/codeql-action/issues/4014">#4014</a>
from github/mbg/explicit-remote-prefix</li>
<li><a
href="https://github.com/github/codeql-action/commit/3492b7e9ab96e28b1d8b971345d30e929c6f8fee"><code>3492b7e</code></a>
Change <code>REMOTE_PATH_PREFIX</code> to <code>remote=</code></li>
<li><a
href="https://github.com/github/codeql-action/commit/3654baa924bc6456db54002581cb7c1c877548c4"><code>3654baa</code></a>
Merge remote-tracking branch 'origin/main' into
mbg/explicit-remote-prefix</li>
<li><a
href="https://github.com/github/codeql-action/commit/2d682ac05f1b3588aaff3814826bede39b9ba6bb"><code>2d682ac</code></a>
Merge pull request <a
href="https://redirect.github.com/github/codeql-action/issues/4017">#4017</a>
from github/dependabot/github_actions/dot-github/wor...</li>
<li><a
href="https://github.com/github/codeql-action/commit/23f6a50753a88efd9b7ae8687b29f6bdb65f6250"><code>23f6a50</code></a>
Merge pull request <a
href="https://redirect.github.com/github/codeql-action/issues/4009">#4009</a>
from github/mbg/action-state/additions</li>
<li><a
href="https://github.com/github/codeql-action/commit/1ee3c75d1988ab8621f01ebb165115c38d56df91"><code>1ee3c75</code></a>
Merge pull request <a
href="https://redirect.github.com/github/codeql-action/issues/4018">#4018</a>
from github/dependabot/github_actions/dot-github/wor...</li>
<li><a
href="https://github.com/github/codeql-action/commit/e053684dc500899b0b5520edc8549ac0f1ed730b"><code>e053684</code></a>
Merge pull request <a
href="https://redirect.github.com/github/codeql-action/issues/4015">#4015</a>
from github/dependabot/npm_and_yarn/npm-minor-fd2e83...</li>
<li><a
href="https://github.com/github/codeql-action/commit/6803c5671d2f87a83ed96e151c441b1cb3bdc66a"><code>6803c56</code></a>
Merge pull request <a
href="https://redirect.github.com/github/codeql-action/issues/4019">#4019</a>
from github/update-bundle/codeql-bundle-v2.26.1</li>
<li>Additional commits viewable in <a
href="https://github.com/github/codeql-action/compare/99df26d4f13ea111d4ec1a7dddef6063f76b97e9...7188fc363630916deb702c7fdcf4e481b751f97a">compare
view</a></li>
</ul>
</details>
<br />

Updates `github/codeql-action/upload-sarif` from 4.37.0 to 4.37.1
<details>
<summary>Release notes</summary>
<p><em>Sourced from <a
href="https://github.com/github/codeql-action/releases">github/codeql-action/upload-sarif's
releases</a>.</em></p>
<blockquote>
<h2>v4.37.1</h2>
<ul>
<li><em>Upcoming breaking change</em>: Add a deprecation warning for
customers using CodeQL version 2.20.6 and earlier. These versions of
CodeQL were discontinued on 1 July 2026 alongside GitHub Enterprise
Server 3.16, and will be unsupported by the next minor release of the
CodeQL Action. <a
href="https://redirect.github.com/github/codeql-action/pull/3956">#3956</a></li>
<li>Update default CodeQL bundle version to <a
href="https://github.com/github/codeql-action/releases/tag/codeql-bundle-v2.26.1">2.26.1</a>.
<a
href="https://redirect.github.com/github/codeql-action/pull/4019">#4019</a></li>
</ul>
</blockquote>
</details>
<details>
<summary>Changelog</summary>
<p><em>Sourced from <a
href="https://github.com/github/codeql-action/blob/main/CHANGELOG.md">github/codeql-action/upload-sarif's
changelog</a>.</em></p>
<blockquote>
<h1>CodeQL Action Changelog</h1>
<p>See the <a
href="https://github.com/github/codeql-action/releases">releases
page</a> for the relevant changes to the CodeQL CLI and language
packs.</p>
<h2>[UNRELEASED]</h2>
<p>No user facing changes.</p>
<h2>4.37.1 - 16 Jul 2026</h2>
<ul>
<li><em>Upcoming breaking change</em>: Add a deprecation warning for
customers using CodeQL version 2.20.6 and earlier. These versions of
CodeQL were discontinued on 1 July 2026 alongside GitHub Enterprise
Server 3.16, and will be unsupported by the next minor release of the
CodeQL Action. <a
href="https://redirect.github.com/github/codeql-action/pull/3956">#3956</a></li>
<li>Update default CodeQL bundle version to <a
href="https://github.com/github/codeql-action/releases/tag/codeql-bundle-v2.26.1">2.26.1</a>.
<a
href="https://redirect.github.com/github/codeql-action/pull/4019">#4019</a></li>
</ul>
<h2>4.37.0 - 08 Jul 2026</h2>
<ul>
<li>Update default CodeQL bundle version to <a
href="https://github.com/github/codeql-action/releases/tag/codeql-bundle-v2.26.0">2.26.0</a>.
<a
href="https://redirect.github.com/github/codeql-action/pull/3995">#3995</a></li>
<li>In addition to the existing input format, the
<code>config-file</code> input for the <code>codeql-action/init</code>
step will soon support a new <code>[owner/]repo[@ref][:path]</code>
format. All components except the repository name are optional. If
omitted, <code>owner</code> defaults to the same owner as the repository
the analysis is running for, <code>ref</code> to <code>main</code>, and
<code>path</code> to <code>.github/codeql-action.yaml</code>. Support
for this format ships in this version of the CodeQL Action, but will
only be enabled over the coming weeks. <a
href="https://redirect.github.com/github/codeql-action/pull/3973">#3973</a></li>
</ul>
<h2>4.36.3 - 01 Jul 2026</h2>
<p>No user facing changes.</p>
<h2>4.36.2 - 04 Jun 2026</h2>
<ul>
<li>Cache CodeQL CLI version information across Actions steps. <a
href="https://redirect.github.com/github/codeql-action/pull/3943">#3943</a></li>
<li>Reduce requests while waiting for analysis processing by using
exponential backoff when polling SARIF processing status. <a
href="https://redirect.github.com/github/codeql-action/pull/3937">#3937</a></li>
<li>Update default CodeQL bundle version to <a
href="https://github.com/github/codeql-action/releases/tag/codeql-bundle-v2.25.6">2.25.6</a>.
<a
href="https://redirect.github.com/github/codeql-action/pull/3948">#3948</a></li>
</ul>
<h2>4.36.1 - 02 Jun 2026</h2>
<p>No user facing changes.</p>
<h2>4.36.0 - 22 May 2026</h2>
<ul>
<li><em>Breaking change</em>: Bump the minimum required CodeQL bundle
version to 2.19.4. <a
href="https://redirect.github.com/github/codeql-action/pull/3894">#3894</a></li>
<li>Add support for SHA-256 Git object IDs. <a
href="https://redirect.github.com/github/codeql-action/pull/3893">#3893</a></li>
<li>Update default CodeQL bundle version to <a
href="https://github.com/github/codeql-action/releases/tag/codeql-bundle-v2.25.5">2.25.5</a>.
<a
href="https://redirect.github.com/github/codeql-action/pull/3926">#3926</a></li>
</ul>
<h2>4.35.5 - 15 May 2026</h2>
<ul>
<li>We have improved how the JavaScript bundles for the CodeQL Action
are generated to avoid duplication across bundles and reduce the size of
the repository by around 70%. This should have no effect on the runtime
behaviour of the CodeQL Action. <a
href="https://redirect.github.com/github/codeql-action/pull/3899">#3899</a></li>
<li>For performance and accuracy reasons, <a
href="https://redirect.github.com/github/roadmap/issues/1158">improved
incremental analysis</a> will now only be enabled on a pull request when
diff-informed analysis is also enabled for that run. If diff-informed
analysis is unavailable (for example, because the PR diff ranges could
not be computed), the action will fall back to a full analysis. <a
href="https://redirect.github.com/github/codeql-action/pull/3791">#3791</a></li>
<li>If multiple inputs are provided for the GitHub-internal
<code>analysis-kinds</code> input, only <code>code-scanning</code> will
be enabled. The <code>analysis-kinds</code> input is experimental, for
GitHub-internal use only, and may change without notice at any time. <a
href="https://redirect.github.com/github/codeql-action/pull/3892">#3892</a></li>
<li>Added an experimental change which, when running a Code Scanning
analysis for a PR with <a
href="https://redirect.github.com/github/roadmap/issues/1158">improved
incremental analysis</a> enabled, prefers CodeQL CLI versions that have
a cached overlay-base database for the configured languages. This speeds
up analysis for a repository when there is not yet a cached overlay-base
database for the latest CLI version. We expect to roll this change out
to everyone in May. <a
href="https://redirect.github.com/github/codeql-action/pull/3880">#3880</a></li>
</ul>
<h2>4.35.4 - 07 May 2026</h2>
<ul>
<li>Update default CodeQL bundle version to <a
href="https://github.com/github/codeql-action/releases/tag/codeql-bundle-v2.25.4">2.25.4</a>.
<a
href="https://redirect.github.com/github/codeql-action/pull/3881">#3881</a></li>
</ul>
<h2>4.35.3 - 01 May 2026</h2>
<!-- raw HTML omitted -->
</blockquote>
<p>... (truncated)</p>
</details>
<details>
<summary>Commits</summary>
<ul>
<li><a
href="https://github.com/github/codeql-action/commit/7188fc363630916deb702c7fdcf4e481b751f97a"><code>7188fc3</code></a>
Merge pull request <a
href="https://redirect.github.com/github/codeql-action/issues/4020">#4020</a>
from github/update-v4.37.1-9e7c07009</li>
<li><a
href="https://github.com/github/codeql-action/commit/c8b5f69be686908c3dfd844428137d56fe80c936"><code>c8b5f69</code></a>
Update changelog for v4.37.1</li>
<li><a
href="https://github.com/github/codeql-action/commit/9e7c070092090e89e8b3d62f977d4456e0732cd7"><code>9e7c070</code></a>
Merge pull request <a
href="https://redirect.github.com/github/codeql-action/issues/4014">#4014</a>
from github/mbg/explicit-remote-prefix</li>
<li><a
href="https://github.com/github/codeql-action/commit/3492b7e9ab96e28b1d8b971345d30e929c6f8fee"><code>3492b7e</code></a>
Change <code>REMOTE_PATH_PREFIX</code> to <code>remote=</code></li>
<li><a
href="https://github.com/github/codeql-action/commit/3654baa924bc6456db54002581cb7c1c877548c4"><code>3654baa</code></a>
Merge remote-tracking branch 'origin/main' into
mbg/explicit-remote-prefix</li>
<li><a
href="https://github.com/github/codeql-action/commit/2d682ac05f1b3588aaff3814826bede39b9ba6bb"><code>2d682ac</code></a>
Merge pull request <a
href="https://redirect.github.com/github/codeql-action/issues/4017">#4017</a>
from github/dependabot/github_actions/dot-github/wor...</li>
<li><a
href="https://github.com/github/codeql-action/commit/23f6a50753a88efd9b7ae8687b29f6bdb65f6250"><code>23f6a50</code></a>
Merge pull request <a
href="https://redirect.github.com/github/codeql-action/issues/4009">#4009</a>
from github/mbg/action-state/additions</li>
<li><a
href="https://github.com/github/codeql-action/commit/1ee3c75d1988ab8621f01ebb165115c38d56df91"><code>1ee3c75</code></a>
Merge pull request <a
href="https://redirect.github.com/github/codeql-action/issues/4018">#4018</a>
from github/dependabot/github_actions/dot-github/wor...</li>
<li><a
href="https://github.com/github/codeql-action/commit/e053684dc500899b0b5520edc8549ac0f1ed730b"><code>e053684</code></a>
Merge pull request <a
href="https://redirect.github.com/github/codeql-action/issues/4015">#4015</a>
from github/dependabot/npm_and_yarn/npm-minor-fd2e83...</li>
<li><a
href="https://github.com/github/codeql-action/commit/6803c5671d2f87a83ed96e151c441b1cb3bdc66a"><code>6803c56</code></a>
Merge pull request <a
href="https://redirect.github.com/github/codeql-action/issues/4019">#4019</a>
from github/update-bundle/codeql-bundle-v2.26.1</li>
<li>Additional commits viewable in <a
href="https://github.com/github/codeql-action/compare/99df26d4f13ea111d4ec1a7dddef6063f76b97e9...7188fc363630916deb702c7fdcf4e481b751f97a">compare
view</a></li>
</ul>
</details>
<br />


Dependabot will resolve any conflicts with this PR as long as you don't
alter it yourself. You can also trigger a rebase manually by commenting
`@dependabot rebase`.

[//]: # (dependabot-automerge-start)
[//]: # (dependabot-automerge-end)

---

<details>
<summary>Dependabot commands and options</summary>
<br />

You can trigger Dependabot actions by commenting on this PR:
- `@dependabot rebase` will rebase this PR
- `@dependabot recreate` will recreate this PR, overwriting any edits
that have been made to it
- `@dependabot show <dependency name> ignore conditions` will show all
of the ignore conditions of the specified dependency
- `@dependabot ignore <dependency name> major version` will close this
group update PR and stop Dependabot creating any more for the specific
dependency's major version (unless you unignore this specific
dependency's major version or upgrade to it yourself)
- `@dependabot ignore <dependency name> minor version` will close this
group update PR and stop Dependabot creating any more for the specific
dependency's minor version (unless you unignore this specific
dependency's minor version or upgrade to it yourself)
- `@dependabot ignore <dependency name>` will close this group update PR
and stop Dependabot creating any more for the specific dependency
(unless you unignore this specific dependency or upgrade to it yourself)
- `@dependabot unignore <dependency name>` will remove all of the ignore
conditions of the specified dependency
- `@dependabot unignore <dependency name> <ignore condition>` will
remove the ignore condition of the specified dependency and ignore
conditions


</details>

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-07-23 13:41:28 +02:00
dependabot[bot]andlnx01 e47805e6a9 ci(deps): bump the actions-core group with 2 updates (#582)
Bumps the actions-core group with 2 updates:
[actions/checkout](https://github.com/actions/checkout) and
[actions/setup-go](https://github.com/actions/setup-go).

Updates `actions/checkout` from 7.0.0 to 7.0.1
<details>
<summary>Release notes</summary>
<p><em>Sourced from <a
href="https://github.com/actions/checkout/releases">actions/checkout's
releases</a>.</em></p>
<blockquote>
<h2>v7.0.1</h2>
<h2>What's Changed</h2>
<ul>
<li>skip running unsafe pr check if input is default by <a
href="https://github.com/aiqiaoy"><code>@​aiqiaoy</code></a> in <a
href="https://redirect.github.com/actions/checkout/pull/2518">actions/checkout#2518</a></li>
<li>trim only ascii whitespace for branch by <a
href="https://github.com/aiqiaoy"><code>@​aiqiaoy</code></a> in <a
href="https://redirect.github.com/actions/checkout/pull/2521">actions/checkout#2521</a></li>
<li>escape values passed to --unset by <a
href="https://github.com/aiqiaoy"><code>@​aiqiaoy</code></a> in <a
href="https://redirect.github.com/actions/checkout/pull/2530">actions/checkout#2530</a></li>
<li>Various dependency updates</li>
</ul>
<p><strong>Full Changelog</strong>: <a
href="https://github.com/actions/checkout/compare/v7...v7.0.1">https://github.com/actions/checkout/compare/v7...v7.0.1</a></p>
</blockquote>
</details>
<details>
<summary>Changelog</summary>
<p><em>Sourced from <a
href="https://github.com/actions/checkout/blob/main/CHANGELOG.md">actions/checkout's
changelog</a>.</em></p>
<blockquote>
<h1>Changelog</h1>
<h2>v7.0.1</h2>
<ul>
<li>Skip running unsafe pr check if input is default by <a
href="https://github.com/aiqiaoy"><code>@​aiqiaoy</code></a> in <a
href="https://redirect.github.com/actions/checkout/pull/2518">actions/checkout#2518</a></li>
<li>Trim only ascii whitespace for branch by <a
href="https://github.com/aiqiaoy"><code>@​aiqiaoy</code></a> in <a
href="https://redirect.github.com/actions/checkout/pull/2521">actions/checkout#2521</a></li>
<li>Escape values passed to --unset by <a
href="https://github.com/aiqiaoy"><code>@​aiqiaoy</code></a> in <a
href="https://redirect.github.com/actions/checkout/pull/2530">actions/checkout#2530</a></li>
<li>Various dependency updates</li>
</ul>
<h2>v7.0.0</h2>
<ul>
<li>Block checking out fork PR for pull_request_target and workflow_run
by <a href="https://github.com/aiqiaoy"><code>@​aiqiaoy</code></a> in <a
href="https://redirect.github.com/actions/checkout/pull/2454">actions/checkout#2454</a></li>
<li>Various dependency updates</li>
</ul>
<h2>v6.0.3</h2>
<ul>
<li>Fix checkout init for SHA-256 repositories by <a
href="https://github.com/yaananth"><code>@​yaananth</code></a> in <a
href="https://redirect.github.com/actions/checkout/pull/2439">actions/checkout#2439</a></li>
<li>fix: expand merge commit SHA regex and add SHA-256 test cases by <a
href="https://github.com/yaananth"><code>@​yaananth</code></a> in <a
href="https://redirect.github.com/actions/checkout/pull/2414">actions/checkout#2414</a></li>
</ul>
<h2>v6.0.2</h2>
<ul>
<li>Fix tag handling: preserve annotations and explicit fetch-tags by <a
href="https://github.com/ericsciple"><code>@​ericsciple</code></a> in <a
href="https://redirect.github.com/actions/checkout/pull/2356">actions/checkout#2356</a></li>
</ul>
<h2>v6.0.1</h2>
<ul>
<li>Add worktree support for persist-credentials includeIf by <a
href="https://github.com/ericsciple"><code>@​ericsciple</code></a> in <a
href="https://redirect.github.com/actions/checkout/pull/2327">actions/checkout#2327</a></li>
</ul>
<h2>v6.0.0</h2>
<ul>
<li>Persist creds to a separate file by <a
href="https://github.com/ericsciple"><code>@​ericsciple</code></a> in <a
href="https://redirect.github.com/actions/checkout/pull/2286">actions/checkout#2286</a></li>
<li>Update README to include Node.js 24 support details and requirements
by <a href="https://github.com/salmanmkc"><code>@​salmanmkc</code></a>
in <a
href="https://redirect.github.com/actions/checkout/pull/2248">actions/checkout#2248</a></li>
</ul>
<h2>v5.0.1</h2>
<ul>
<li>Port v6 cleanup to v5 by <a
href="https://github.com/ericsciple"><code>@​ericsciple</code></a> in <a
href="https://redirect.github.com/actions/checkout/pull/2301">actions/checkout#2301</a></li>
</ul>
<h2>v5.0.0</h2>
<ul>
<li>Update actions checkout to use node 24 by <a
href="https://github.com/salmanmkc"><code>@​salmanmkc</code></a> in <a
href="https://redirect.github.com/actions/checkout/pull/2226">actions/checkout#2226</a></li>
</ul>
<h2>v4.3.1</h2>
<ul>
<li>Port v6 cleanup to v4 by <a
href="https://github.com/ericsciple"><code>@​ericsciple</code></a> in <a
href="https://redirect.github.com/actions/checkout/pull/2305">actions/checkout#2305</a></li>
</ul>
<h2>v4.3.0</h2>
<ul>
<li>docs: update README.md by <a
href="https://github.com/motss"><code>@​motss</code></a> in <a
href="https://redirect.github.com/actions/checkout/pull/1971">actions/checkout#1971</a></li>
<li>Add internal repos for checking out multiple repositories by <a
href="https://github.com/mouismail"><code>@​mouismail</code></a> in <a
href="https://redirect.github.com/actions/checkout/pull/1977">actions/checkout#1977</a></li>
<li>Documentation update - add recommended permissions to Readme by <a
href="https://github.com/benwells"><code>@​benwells</code></a> in <a
href="https://redirect.github.com/actions/checkout/pull/2043">actions/checkout#2043</a></li>
<li>Adjust positioning of user email note and permissions heading by <a
href="https://github.com/joshmgross"><code>@​joshmgross</code></a> in <a
href="https://redirect.github.com/actions/checkout/pull/2044">actions/checkout#2044</a></li>
<li>Update README.md by <a
href="https://github.com/nebuk89"><code>@​nebuk89</code></a> in <a
href="https://redirect.github.com/actions/checkout/pull/2194">actions/checkout#2194</a></li>
<li>Update CODEOWNERS for actions by <a
href="https://github.com/TingluoHuang"><code>@​TingluoHuang</code></a>
in <a
href="https://redirect.github.com/actions/checkout/pull/2224">actions/checkout#2224</a></li>
<li>Update package dependencies by <a
href="https://github.com/salmanmkc"><code>@​salmanmkc</code></a> in <a
href="https://redirect.github.com/actions/checkout/pull/2236">actions/checkout#2236</a></li>
</ul>
<h2>v4.2.2</h2>
<ul>
<li><code>url-helper.ts</code> now leverages well-known environment
variables by <a href="https://github.com/jww3"><code>@​jww3</code></a>
in <a
href="https://redirect.github.com/actions/checkout/pull/1941">actions/checkout#1941</a></li>
<li>Expand unit test coverage for <code>isGhes</code> by <a
href="https://github.com/jww3"><code>@​jww3</code></a> in <a
href="https://redirect.github.com/actions/checkout/pull/1946">actions/checkout#1946</a></li>
</ul>
<h2>v4.2.1</h2>
<ul>
<li>Check out other refs/* by commit if provided, fall back to ref by <a
href="https://github.com/orhantoy"><code>@​orhantoy</code></a> in <a
href="https://redirect.github.com/actions/checkout/pull/1924">actions/checkout#1924</a></li>
</ul>
<!-- raw HTML omitted -->
</blockquote>
<p>... (truncated)</p>
</details>
<details>
<summary>Commits</summary>
<ul>
<li><a
href="https://github.com/actions/checkout/commit/3d3c42e5aac5ba805825da76410c181273ba90b1"><code>3d3c42e</code></a>
prep v7.0.1 release (<a
href="https://redirect.github.com/actions/checkout/issues/2531">#2531</a>)</li>
<li><a
href="https://github.com/actions/checkout/commit/28802689a136bfcdb721715abd713740beecbe07"><code>2880268</code></a>
escape values passed to --unset (<a
href="https://redirect.github.com/actions/checkout/issues/2530">#2530</a>)</li>
<li><a
href="https://github.com/actions/checkout/commit/12cd2235efa0937479335606d7c3ac9f6c0973b1"><code>12cd223</code></a>
trim only ascii whitespace for branch (<a
href="https://redirect.github.com/actions/checkout/issues/2521">#2521</a>)</li>
<li><a
href="https://github.com/actions/checkout/commit/62661c4e71a304b2823ed026347b8d34c3eac541"><code>62661c4</code></a>
skip running unsafe pr check if input is default (<a
href="https://redirect.github.com/actions/checkout/issues/2518">#2518</a>)</li>
<li><a
href="https://github.com/actions/checkout/commit/e8d4307400f9427dba7cb98e488d6ab85f1cec5f"><code>e8d4307</code></a>
Bump the minor-actions-dependencies group with 2 updates (<a
href="https://redirect.github.com/actions/checkout/issues/2499">#2499</a>)</li>
<li><a
href="https://github.com/actions/checkout/commit/631c942040754b6e095e929c1677c07e10ed4f87"><code>631c942</code></a>
eslint 9 (<a
href="https://redirect.github.com/actions/checkout/issues/2474">#2474</a>)</li>
<li><a
href="https://github.com/actions/checkout/commit/4f1f4aec02e41874fa0262ea8ff5172d7978ad1e"><code>4f1f4ae</code></a>
Bump actions/upload-artifact from 4 to 7 (<a
href="https://redirect.github.com/actions/checkout/issues/2476">#2476</a>)</li>
<li><a
href="https://github.com/actions/checkout/commit/ba097532fb203f7e88c9c3c0b899b49469908a92"><code>ba09753</code></a>
Bump actions/checkout from 6 to 7 (<a
href="https://redirect.github.com/actions/checkout/issues/2488">#2488</a>)</li>
<li><a
href="https://github.com/actions/checkout/commit/b9e0990d219a03df7633c93f6f005a8fecbcab22"><code>b9e0990</code></a>
Bump docker/login-action from 3.3.0 to 4.2.0 (<a
href="https://redirect.github.com/actions/checkout/issues/2479">#2479</a>)</li>
<li><a
href="https://github.com/actions/checkout/commit/e8cb398be4a550817e382abf69e4c12c76fce1f2"><code>e8cb398</code></a>
Bump docker/build-push-action from 6.5.0 to 7.2.0 (<a
href="https://redirect.github.com/actions/checkout/issues/2478">#2478</a>)</li>
<li>Additional commits viewable in <a
href="https://github.com/actions/checkout/compare/9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0...3d3c42e5aac5ba805825da76410c181273ba90b1">compare
view</a></li>
</ul>
</details>
<br />

Updates `actions/setup-go` from 6.5.0 to 7.0.0
<details>
<summary>Release notes</summary>
<p><em>Sourced from <a
href="https://github.com/actions/setup-go/releases">actions/setup-go's
releases</a>.</em></p>
<blockquote>
<h2>v7.0.0</h2>
<h2>What's Changed</h2>
<ul>
<li>Migrate to ESM and upgrade dependencies by <a
href="https://github.com/priyagupta108"><code>@​priyagupta108</code></a>
in <a
href="https://redirect.github.com/actions/setup-go/pull/763">actions/setup-go#763</a></li>
<li>chore(deps): bump <code>@​actions/cache</code> to 6.2.0 by <a
href="https://github.com/philip-gai"><code>@​philip-gai</code></a> in <a
href="https://redirect.github.com/actions/setup-go/pull/771">actions/setup-go#771</a></li>
</ul>
<h2>New Contributors</h2>
<ul>
<li><a
href="https://github.com/philip-gai"><code>@​philip-gai</code></a> made
their first contribution in <a
href="https://redirect.github.com/actions/setup-go/pull/771">actions/setup-go#771</a></li>
</ul>
<p><strong>Full Changelog</strong>: <a
href="https://github.com/actions/setup-go/compare/v6...v7.0.0">https://github.com/actions/setup-go/compare/v6...v7.0.0</a></p>
</blockquote>
</details>
<details>
<summary>Commits</summary>
<ul>
<li><a
href="https://github.com/actions/setup-go/commit/b7ad1dad31e06c5925ef5d2fc7ad053ef454303e"><code>b7ad1da</code></a>
chore(deps): bump <code>@​actions/cache</code> to 6.2.0 (<a
href="https://redirect.github.com/actions/setup-go/issues/771">#771</a>)</li>
<li><a
href="https://github.com/actions/setup-go/commit/0778a10ce47b5d450cf60fb94fafad4330008a35"><code>0778a10</code></a>
Migrate to ESM and upgrade dependencies (<a
href="https://redirect.github.com/actions/setup-go/issues/763">#763</a>)</li>
<li>See full diff in <a
href="https://github.com/actions/setup-go/compare/924ae3a1cded613372ab5595356fb5720e22ba16...b7ad1dad31e06c5925ef5d2fc7ad053ef454303e">compare
view</a></li>
</ul>
</details>
<br />


Dependabot will resolve any conflicts with this PR as long as you don't
alter it yourself. You can also trigger a rebase manually by commenting
`@dependabot rebase`.

[//]: # (dependabot-automerge-start)
[//]: # (dependabot-automerge-end)

---

<details>
<summary>Dependabot commands and options</summary>
<br />

You can trigger Dependabot actions by commenting on this PR:
- `@dependabot rebase` will rebase this PR
- `@dependabot recreate` will recreate this PR, overwriting any edits
that have been made to it
- `@dependabot show <dependency name> ignore conditions` will show all
of the ignore conditions of the specified dependency
- `@dependabot ignore <dependency name> major version` will close this
group update PR and stop Dependabot creating any more for the specific
dependency's major version (unless you unignore this specific
dependency's major version or upgrade to it yourself)
- `@dependabot ignore <dependency name> minor version` will close this
group update PR and stop Dependabot creating any more for the specific
dependency's minor version (unless you unignore this specific
dependency's minor version or upgrade to it yourself)
- `@dependabot ignore <dependency name>` will close this group update PR
and stop Dependabot creating any more for the specific dependency
(unless you unignore this specific dependency or upgrade to it yourself)
- `@dependabot unignore <dependency name>` will remove all of the ignore
conditions of the specified dependency
- `@dependabot unignore <dependency name> <ignore condition>` will
remove the ignore condition of the specified dependency and ignore
conditions


</details>

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-07-23 13:40:54 +02:00
dependabot[bot]andlnx01 ee5f351828 deps(deps): bump github.com/chromedp/chromedp from 0.15.1 to 0.16.0 (#579)
Bumps
[github.com/chromedp/chromedp](https://github.com/chromedp/chromedp)
from 0.15.1 to 0.16.0.
<details>
<summary>Commits</summary>
<ul>
<li><a
href="https://github.com/chromedp/chromedp/commit/7963c203ed5458147d27dc39a5c06d2b12e81664"><code>7963c20</code></a>
Running modernize</li>
<li><a
href="https://github.com/chromedp/chromedp/commit/9178eba2a70c7691c6d710fd3383542503d4aa38"><code>9178eba</code></a>
Updating to latest cdproto, running go fix</li>
<li>See full diff in <a
href="https://github.com/chromedp/chromedp/compare/v0.15.1...v0.16.0">compare
view</a></li>
</ul>
</details>
<br />


[![Dependabot compatibility
score](https://dependabot-badges.githubapp.com/badges/compatibility_score?dependency-name=github.com/chromedp/chromedp&package-manager=go_modules&previous-version=0.15.1&new-version=0.16.0)](https://docs.github.com/en/github/managing-security-vulnerabilities/about-dependabot-security-updates#about-compatibility-scores)

Dependabot will resolve any conflicts with this PR as long as you don't
alter it yourself. You can also trigger a rebase manually by commenting
`@dependabot rebase`.

[//]: # (dependabot-automerge-start)
[//]: # (dependabot-automerge-end)

---

<details>
<summary>Dependabot commands and options</summary>
<br />

You can trigger Dependabot actions by commenting on this PR:
- `@dependabot rebase` will rebase this PR
- `@dependabot recreate` will recreate this PR, overwriting any edits
that have been made to it
- `@dependabot show <dependency name> ignore conditions` will show all
of the ignore conditions of the specified dependency
- `@dependabot ignore this major version` will close this PR and stop
Dependabot creating any more for this major version (unless you reopen
the PR or upgrade to it yourself)
- `@dependabot ignore this minor version` will close this PR and stop
Dependabot creating any more for this minor version (unless you reopen
the PR or upgrade to it yourself)
- `@dependabot ignore this dependency` will close this PR and stop
Dependabot creating any more for this dependency (unless you reopen the
PR or upgrade to it yourself)


</details>

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-07-20 12:40:04 +02:00
Tobias GesellchenandClaude Opus 4.8 1270eed554 feat(player): add name/IP device sort toggle (#571) (#576)
Relates to #571.

## What

Adds a **Name / IP** sort toggle to the Player device list. The choice
is persisted in `localStorage` (`aftertouch_device_sort`), following the
same preference pattern as the service-URL field in `PlayURL.js`.

## Why

The device list was previously ordered only by IP: the service datastore
keys devices by IP address and Go marshals map keys lexicographically,
so the frontend received an already-IP-ordered object and rendered it
as-is. BirdyBA (#571) asked to be able to sort by name instead.

## Changes

- `DeviceList.js`: a `sortEntries()` helper plus a `useState`-backed
toggle seeded from `localStorage`. Name mode sorts by `device.info.name`
(falling back to the IP key when a device has no name yet); IP mode
sorts the IP key **numerically** (`.2` before `.10`), which also tidies
the old lexicographic ordering.
- `css/app.css`: additive `.device-sort` / `.sort-btn` styling, reusing
the existing accent / `.active` look. No existing rules touched.

No backend change: the device name and IP are already in the payload.

## Testing

- `node --check` on `DeviceList.js` passes.
- `make build-player` succeeds (the static tree is `//go:embed`ed into
the binary).
- Manual: open the Player, toggle Name / IP, confirm the order changes
and the choice survives a page reload.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-17 17:46:53 +02:00
dependabot[bot] ca4adb3e11 ci(deps): bump actions/setup-node in the actions-core group
Bumps the actions-core group with 1 update: [actions/setup-node](https://github.com/actions/setup-node).


Updates `actions/setup-node` from 6.4.0 to 7.0.0
- [Release notes](https://github.com/actions/setup-node/releases)
- [Commits](https://github.com/actions/setup-node/compare/48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e...820762786026740c76f36085b0efc47a31fe5020)

---
updated-dependencies:
- dependency-name: actions/setup-node
  dependency-version: 7.0.0
  dependency-type: direct:production
  update-type: version-update:semver-major
  dependency-group: actions-core
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-07-14 22:35:43 +02:00
dependabot[bot] 66ab981695 ci(deps): bump softprops/action-gh-release from 3.0.1 to 3.0.2
Bumps [softprops/action-gh-release](https://github.com/softprops/action-gh-release) from 3.0.1 to 3.0.2.
- [Release notes](https://github.com/softprops/action-gh-release/releases)
- [Changelog](https://github.com/softprops/action-gh-release/blob/master/CHANGELOG.md)
- [Commits](https://github.com/softprops/action-gh-release/compare/718ea10b132b3b2eba29c1007bb80653f286566b...3d0d9888cb7fd7b750713d6e236d1fcb99157228)

---
updated-dependencies:
- dependency-name: softprops/action-gh-release
  dependency-version: 3.0.2
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-07-14 22:35:13 +02:00
dependabot[bot] d34e095c9f ci(deps): bump docker/metadata-action from 6.1.0 to 6.2.0
Bumps [docker/metadata-action](https://github.com/docker/metadata-action) from 6.1.0 to 6.2.0.
- [Release notes](https://github.com/docker/metadata-action/releases)
- [Commits](https://github.com/docker/metadata-action/compare/80c7e94dd9b9319bd5eb7a0e0fe9291e23a2a2e9...dc802804100637a589fabce1cb79ff13a1411302)

---
updated-dependencies:
- dependency-name: docker/metadata-action
  dependency-version: 6.2.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-07-09 21:48:22 +02:00
dependabot[bot] 745bef5acc ci(deps): bump docker/login-action from 4.2.0 to 4.4.0
Bumps [docker/login-action](https://github.com/docker/login-action) from 4.2.0 to 4.4.0.
- [Release notes](https://github.com/docker/login-action/releases)
- [Commits](https://github.com/docker/login-action/compare/650006c6eb7dba73a995cc03b0b2d7f5ca915bee...af1e73f918a031802d376d3c8bbc3fe56130a9b0)

---
updated-dependencies:
- dependency-name: docker/login-action
  dependency-version: 4.4.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-07-09 21:48:07 +02:00
Tobias GesellchenandClaude Opus 4.8 00db897c24 ci: group codeql-action sub-actions in Dependabot
github/codeql-action/init, /analyze and /upload-sarif are separate
Dependabot dependencies but must run on the same version. Without a
group they update in independent PRs that merge at different times,
producing a version mismatch that fails CodeQL. Group them so all
sub-actions bump together in one PR.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-09 21:22:06 +02:00
Tobias GesellchenandClaude Opus 4.8 c20e15df21 ci: align codeql-action init/upload-sarif with analyze v4.37.0
Dependabot bumped only github/codeql-action/analyze to v4.37.0 while
init and upload-sarif stayed on v4.36.2. CodeQL requires all of its
action steps on the same version; the mismatch failed every Analyze job
with 'Loaded a configuration file for version 4.36.2, but running
version 4.37.0'. Bump init and upload-sarif to the same v4.37.0 commit.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-09 21:22:06 +02:00
dependabot[bot] 7e555d8c86 ci(deps): bump github/codeql-action/analyze from 4.36.2 to 4.37.0
Bumps [github/codeql-action/analyze](https://github.com/github/codeql-action) from 4.36.2 to 4.37.0.
- [Release notes](https://github.com/github/codeql-action/releases)
- [Changelog](https://github.com/github/codeql-action/blob/main/CHANGELOG.md)
- [Commits](https://github.com/github/codeql-action/compare/8aad20d150bbac5944a9f9d289da16a4b0d87c1e...99df26d4f13ea111d4ec1a7dddef6063f76b97e9)

---
updated-dependencies:
- dependency-name: github/codeql-action/analyze
  dependency-version: 4.36.3
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-07-09 21:22:06 +02:00
Tobias GesellchenandClaude Opus 4.8 d523d96b6f test: refresh router snapshot for chi 5.3.1 QUERY method
chi 5.3.1 recognizes the HTTP QUERY method, so chi.Walk now expands the
all-methods HandleFunc registrations for the SiriusXM live-adapter routes
to include QUERY. The routes are functionally unchanged; only the walk
output grew two lines.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-09 21:16:42 +02:00
dependabot[bot] 27aa88b317 deps(deps): bump github.com/go-chi/chi/v5 from 5.3.0 to 5.3.1
Bumps [github.com/go-chi/chi/v5](https://github.com/go-chi/chi) from 5.3.0 to 5.3.1.
- [Release notes](https://github.com/go-chi/chi/releases)
- [Changelog](https://github.com/go-chi/chi/blob/master/CHANGELOG.md)
- [Commits](https://github.com/go-chi/chi/compare/v5.3.0...v5.3.1)

---
updated-dependencies:
- dependency-name: github.com/go-chi/chi/v5
  dependency-version: 5.3.1
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-07-09 21:16:42 +02:00
github-actions[bot] 9d17955430 chore: sync static dependencies with package.json 2026-07-09 21:12:59 +02:00
dependabot[bot] c5441639c5 deps(deps): bump preact from 10.29.3 to 10.29.7
Bumps [preact](https://github.com/preactjs/preact) from 10.29.3 to 10.29.7.
- [Release notes](https://github.com/preactjs/preact/releases)
- [Commits](https://github.com/preactjs/preact/compare/10.29.3...10.29.7)

---
updated-dependencies:
- dependency-name: preact
  dependency-version: 10.29.7
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-07-09 21:12:59 +02:00
Tobias GesellchenandClaude Opus 4.8 397faaaabd docker: use Go 1.26.5 release instead of 1.27rc2
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-09 21:05:09 +02:00
Tobias GesellchenandClaude Opus 4.8 486fb2faab deps: bump Go toolchain to 1.26.5
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-09 21:05:09 +02:00
dependabot[bot] a4cc6f5c96 deps(deps): bump the golang group with 9 updates
Bumps the golang group with 9 updates:

| Package | From | To |
| --- | --- | --- |
| [golang.org/x/crypto](https://github.com/golang/crypto) | `0.53.0` | `0.54.0` |
| [golang.org/x/net](https://github.com/golang/net) | `0.56.0` | `0.57.0` |
| [golang.org/x/term](https://github.com/golang/term) | `0.44.0` | `0.45.0` |
| [golang.org/x/image](https://github.com/golang/image) | `0.43.0` | `0.44.0` |
| [golang.org/x/mod](https://github.com/golang/mod) | `0.37.0` | `0.38.0` |
| [golang.org/x/sync](https://github.com/golang/sync) | `0.21.0` | `0.22.0` |
| [golang.org/x/sys](https://github.com/golang/sys) | `0.46.0` | `0.47.0` |
| [golang.org/x/text](https://github.com/golang/text) | `0.38.0` | `0.40.0` |
| [golang.org/x/tools](https://github.com/golang/tools) | `0.47.0` | `0.48.0` |


Updates `golang.org/x/crypto` from 0.53.0 to 0.54.0
- [Commits](https://github.com/golang/crypto/compare/v0.53.0...v0.54.0)

Updates `golang.org/x/net` from 0.56.0 to 0.57.0
- [Commits](https://github.com/golang/net/compare/v0.56.0...v0.57.0)

Updates `golang.org/x/term` from 0.44.0 to 0.45.0
- [Commits](https://github.com/golang/term/compare/v0.44.0...v0.45.0)

Updates `golang.org/x/image` from 0.43.0 to 0.44.0
- [Commits](https://github.com/golang/image/compare/v0.43.0...v0.44.0)

Updates `golang.org/x/mod` from 0.37.0 to 0.38.0
- [Commits](https://github.com/golang/mod/compare/v0.37.0...v0.38.0)

Updates `golang.org/x/sync` from 0.21.0 to 0.22.0
- [Commits](https://github.com/golang/sync/compare/v0.21.0...v0.22.0)

Updates `golang.org/x/sys` from 0.46.0 to 0.47.0
- [Commits](https://github.com/golang/sys/compare/v0.46.0...v0.47.0)

Updates `golang.org/x/text` from 0.38.0 to 0.40.0
- [Release notes](https://github.com/golang/text/releases)
- [Commits](https://github.com/golang/text/compare/v0.38.0...v0.40.0)

Updates `golang.org/x/tools` from 0.47.0 to 0.48.0
- [Release notes](https://github.com/golang/tools/releases)
- [Commits](https://github.com/golang/tools/compare/v0.47.0...v0.48.0)

---
updated-dependencies:
- dependency-name: golang.org/x/crypto
  dependency-version: 0.54.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: golang
- dependency-name: golang.org/x/net
  dependency-version: 0.57.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: golang
- dependency-name: golang.org/x/term
  dependency-version: 0.45.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: golang
- dependency-name: golang.org/x/image
  dependency-version: 0.44.0
  dependency-type: indirect
  update-type: version-update:semver-minor
  dependency-group: golang
- dependency-name: golang.org/x/mod
  dependency-version: 0.38.0
  dependency-type: indirect
  update-type: version-update:semver-minor
  dependency-group: golang
- dependency-name: golang.org/x/sync
  dependency-version: 0.22.0
  dependency-type: indirect
  update-type: version-update:semver-minor
  dependency-group: golang
- dependency-name: golang.org/x/sys
  dependency-version: 0.47.0
  dependency-type: indirect
  update-type: version-update:semver-minor
  dependency-group: golang
- dependency-name: golang.org/x/text
  dependency-version: 0.40.0
  dependency-type: indirect
  update-type: version-update:semver-minor
  dependency-group: golang
- dependency-name: golang.org/x/tools
  dependency-version: 0.48.0
  dependency-type: indirect
  update-type: version-update:semver-minor
  dependency-group: golang
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-07-09 20:56:40 +02:00
dependabot[bot] 0374b2a680 docker(deps): bump golang from 1.26.4-alpine to 1.27rc2-alpine
Bumps golang from 1.26.4-alpine to 1.27rc2-alpine.

---
updated-dependencies:
- dependency-name: golang
  dependency-version: 1.27rc2-alpine
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-07-08 19:00:01 +02:00
dependabot[bot] d944a5a794 ci(deps): bump docker/setup-buildx-action in the setup-actions group
Bumps the setup-actions group with 1 update: [docker/setup-buildx-action](https://github.com/docker/setup-buildx-action).


Updates `docker/setup-buildx-action` from 4.1.0 to 4.2.0
- [Release notes](https://github.com/docker/setup-buildx-action/releases)
- [Commits](https://github.com/docker/setup-buildx-action/compare/d7f5e7f509e45cec5c76c4d5afdd7de93d0b3df5...bb05f3f5519dd87d3ba754cc423b652a5edd6d2c)

---
updated-dependencies:
- dependency-name: docker/setup-buildx-action
  dependency-version: 4.2.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: setup-actions
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-07-07 19:35:48 +02:00
dependabot[bot] 7dfe2dc160 ci(deps): bump docker/build-push-action from 7.2.0 to 7.3.0
Bumps [docker/build-push-action](https://github.com/docker/build-push-action) from 7.2.0 to 7.3.0.
- [Release notes](https://github.com/docker/build-push-action/releases)
- [Commits](https://github.com/docker/build-push-action/compare/f9f3042f7e2789586610d6e8b85c8f03e5195baf...53b7df96c91f9c12dcc8a07bcb9ccacbed38856a)

---
updated-dependencies:
- dependency-name: docker/build-push-action
  dependency-version: 7.3.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-07-07 19:34:51 +02:00
Tobias GesellchenandClaude Opus 4.8 c153592a40 docs: document HTTPS URL derivation/override + cert-chain health warning (#355)
- HTTPS-SETUP.md: the HTTPS endpoint is only needed for certain features
  and its URL now derives from the Target Domain by default; note the
  https-Target-Domain shortcut and the Settings override.
- SOUNDTOUCH-SERVICE.md: HTTPS_SERVER_URL is an override that derives from
  SERVER_URL when empty, and is viewable/overridable in Settings.
- TROUBLESHOOTING.md: new entry for the "HTTPS endpoint TLS configuration"
  health warning (wrong port / not reachable), how to fix via the Settings
  HTTPS URL, and when it's an expected reverse-proxy case.

refs #355

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-04 17:45:41 +02:00
Tobias GesellchenandClaude Opus 4.8 edc6869faf ui(settings): group Target Domain + HTTPS URL under a "Service URLs" header
Follow-up polish to the HTTPS-URL settings work:

- Group Target Domain and the derived HTTPS URL under a single bold
  "Service URLs" section header, matching the existing section-header
  pattern (Landing page, TLS extra hosts, Device Discovery) rather than a
  one-off fieldset box — consistent across the whole Settings tab.
- Tighten the spacing so the HTTPS URL sits with Target Domain (drop the
  empty :443-status reserved line, reduce the intra-group gap) instead of
  floating toward the next section.
- Make "Landing page" a bold header for the same consistency.
- Expand the HTTPS URL override hint: HTTPS is only needed for certain
  features (DNS redirect, Spotify/Amazon login, cert trust); it derives
  from the Target Domain; and if you don't need plain HTTP you can set the
  Target Domain itself to an https:// URL, no override required.

refs #355

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-04 17:45:41 +02:00
Tobias GesellchenandClaude Opus 4.8 9957c9d64f fix(ui): correct HTTPS URL override toggle + normalize derived override (#355)
Two follow-ups to the derive/show/override settings work:

- The "advanced override" affordance reused the .info-toggle style with a
  text label, which is an 18px circular icon badge — the label rendered as
  a broken blue circle. Use the icon-toggle pattern like TLS extra hosts:
  a small ⓘ that reveals a details block containing the explanation and the
  override input.

- Existing installs persist their old effective HTTPS URL in the (now
  override) https_server_url field, so the UI showed "(override)" even when
  the value equals what we would derive. On load, treat an override that
  exactly matches the derived URL as "derive" (clear it), so default
  installs show "(derived from Target Domain)"; genuinely custom values are
  kept as overrides.

Verified live: an existing settings.json with https_server_url equal to the
derived value now reports an empty override, and the served admin HTML uses
the ⓘ toggle.

refs #355

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-04 17:45:41 +02:00
Tobias GesellchenandClaude Opus 4.8 381ab4639a fix(export): report effective HTTPS URL + override in diagnostics (#355)
The diagnostic export wrote the raw persisted https_server_url, which is
now the override (empty when the URL is derived). Report the effective
HTTPS URL actually in use plus the override as a separate field, so a
diagnostic makes an advertised-URL/listener mismatch legible instead of
showing an empty field.

refs #355

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-04 17:45:41 +02:00
Tobias GesellchenandClaude Opus 4.8 b1b3472297 feat(settings): derive the HTTPS URL from the Target Domain, show + override in UI (#355)
The HTTPS URL AfterTouch advertises (and points speakers at for the
DNS-redirect, OAuth, install-ca and cert-trust flows) was a separate,
internally-tracked value: sourced only from --https-server-url /
HTTPS_SERVER_URL / the settings file, defaulting to the machine hostname,
and never shown or editable in the web UI. So it could silently diverge
from the Target Domain (e.g. a different host, or a port-less value that
fell back to 443 while the listener was on 8443 — the root of #355), with
no way to see or fix it in the UI.

Make it derive + show + override:

- DeriveHTTPSURL resolves the effective HTTPS URL: an explicit override
  wins; otherwise it follows the Target Domain (same host, https, on the
  configured HTTPS port); an already-https Target Domain is honoured
  verbatim (its port is not second-guessed); empty falls back to the
  hostname default. So changing the Target Domain updates the HTTPS URL
  automatically for the common single-host case.
- The persisted https_server_url is now the *override* (empty = derive).
  Existing installs carry their old value here, so it is preserved as an
  override — no silent change on upgrade; clearing it opts into derive.
- The server keeps httpsServerURL as the effective value, so all
  consumers (cert SANs, migration, export, health) are unchanged; it is
  recomputed whenever the Target Domain or override changes.
- Settings API returns https_server_url (effective) plus
  https_server_url_override; the Settings page shows the effective URL
  with a derived/override note and an "advanced" override field.

Verified live on a clean data dir: derive from an http Target Domain,
auto-follow when the Target Domain changes, explicit override, an https
Target Domain kept verbatim, and override persistence across restart.
Unit tests cover DeriveHTTPSURL including the already-https cases.

refs #355

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-04 17:45:41 +02:00
Tobias GesellchenandClaude Opus 4.8 433a779998 fix(health): detect advertised-HTTPS-URL / listener port mismatch (#355)
Follow-up to the previous commit. The cert-chain check dialed the
advertised HTTPS URL, whose port defaults to 443 when the URL omits it
(splitHTTPSHostPort). The advertised URL comes from
--https-server-url / HTTPS_SERVER_URL / the settings file and is not
editable in the web UI, so when it lost its port it silently pointed the
check (and speakers) at 443 while the real listener was on 8443 — the
exact "port 443" complaint in issue #355.

Thread the actual HTTPS listener port into the check (new
Server.SetHTTPSListenAddr, wired from config.httpsAddr). When the dial
fails and the advertised port differs from the listener port, emit a
mismatch-specific warning that names both ports and offers the corrected
HTTPS_SERVER_URL, while still deferring to reverse-proxy setups. A
reachable endpoint never reaches this branch.

Reproduced locally on a clean data dir: seeding a port-less
https_server_url with the listener on 8443 previously errored on
:443; it now warns with both ports and the fix. Regression tests added.

refs #355

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-04 17:45:41 +02:00
Tobias GesellchenandClaude Opus 4.8 9d69b61779 fix(health): don't error when the advertised HTTPS URL is unreachable from the service
The `service_cert_chain` check ("HTTPS endpoint TLS configuration") dials
the service's own configured HTTPS URL. When that dial fails before any
certificate is presented (connection refused, timeout, handshake reset),
it reported a hard red error.

But from inside the service we can't distinguish "the endpoint is down"
from "the advertised HTTPS URL simply isn't reachable from here" — and
the latter is a normal, healthy deployment: TLS terminated by a reverse
proxy in front of AfterTouch, or a Docker-published port / LAN-only
hostname that the container itself can't dial. In those setups the red
error is a false alarm (issue #355: reporter runs HTTP 8080 / HTTPS 8443
and noted "in my configuration that is expected").

Downgrade that specific case (no cert presented) to a warning, reword it
to name the expected reverse-proxy / unreachable-advertised-URL case, and
add an `openssl s_client` command to verify the endpoint from a client
that actually reaches the advertised URL. Cert-classification outcomes
(own-CA info, foreign-chain warning) are unchanged.

Reproduced locally on a clean data dir before/after: custom ports and
localhost/127.0.0.1 already returned INFO; only the unreachable-URL case
produced the error, which now returns a warning.

refs #355

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-04 17:45:41 +02:00
Tobias GesellchenandClaude Opus 4.8 c3e3391db6 docs: add Downloads page + telnet re-migration workaround for #493
Address the two docs follow-ups from #493 (radio sources not mounting
after an in-place migration).

Troubleshooting: the "Radio sources never activate after an in-place
migration" entry now leads with the confirmed non-destructive fix,
re-running migration via the telnet method so all four service URLs
(incl. bmxRegistryUrl / statsServerUrl) land on the speaker's runtime:

    soundtouch-cli --host <ip> setup migrate --method telnet --service-url http://<host>:8000

Factory reset is kept as the fallback for models without a reachable
telnet port. The cause text is updated to the diagnosed BMX-registry
explanation, and notes why pointing the service at http://bose:8000 with
a server-side /etc/hosts entry does not help.

Downloads: new top-level docs section (docs/content/docs/downloads/)
structured by tool (service / player / cli / backup) x OS/arch, using
the real release asset naming (soundtouch-<tool>-v<ver>-<os>-<arch>),
plus install-script, Docker, and go-install routes. Sibling section
weights bumped so Downloads leads the sidebar. README, the release-notes
template, and the key install guides now point here. Also fixes the
stale, never-produced .tar.gz/.zip filenames in SELF-HOSTING.md.

refs #493

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-04 16:01:06 +02:00
github-actions[bot] 8695816d36 chore: sync static dependencies with package.json 2026-07-03 20:39:57 +02:00
dependabot[bot] bdbfc8dcf6 deps(deps): bump preact from 10.29.2 to 10.29.3
Bumps [preact](https://github.com/preactjs/preact) from 10.29.2 to 10.29.3.
- [Release notes](https://github.com/preactjs/preact/releases)
- [Commits](https://github.com/preactjs/preact/compare/10.29.2...10.29.3)

---
updated-dependencies:
- dependency-name: preact
  dependency-version: 10.29.3
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-07-03 20:39:57 +02:00
dependabot[bot] 2663357c9c ci(deps): bump golangci/golangci-lint-action from 9.2.1 to 9.3.0
Bumps [golangci/golangci-lint-action](https://github.com/golangci/golangci-lint-action) from 9.2.1 to 9.3.0.
- [Release notes](https://github.com/golangci/golangci-lint-action/releases)
- [Commits](https://github.com/golangci/golangci-lint-action/compare/82606bf257cbaff209d206a39f5134f0cfbfd2ee...ba0d7d2ec06a0ea1cb5fa41b2e4a3ab91d21278a)

---
updated-dependencies:
- dependency-name: golangci/golangci-lint-action
  dependency-version: 9.3.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-07-01 12:49:31 +02:00
dependabot[bot] 3f26b9ddf1 ci(deps): bump the actions-core group with 2 updates
Bumps the actions-core group with 2 updates: [actions/setup-go](https://github.com/actions/setup-go) and [actions/cache](https://github.com/actions/cache).


Updates `actions/setup-go` from 6.4.0 to 6.5.0
- [Release notes](https://github.com/actions/setup-go/releases)
- [Commits](https://github.com/actions/setup-go/compare/4a3601121dd01d1626a1e23e37211e3254c1c06c...924ae3a1cded613372ab5595356fb5720e22ba16)

Updates `actions/cache` from 5.0.5 to 6.1.0
- [Release notes](https://github.com/actions/cache/releases)
- [Changelog](https://github.com/actions/cache/blob/main/RELEASES.md)
- [Commits](https://github.com/actions/cache/compare/27d5ce7f107fe9357f9df03efb73ab90386fccae...55cc8345863c7cc4c66a329aec7e433d2d1c52a9)

---
updated-dependencies:
- dependency-name: actions/setup-go
  dependency-version: 6.5.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: actions-core
- dependency-name: actions/cache
  dependency-version: 6.1.0
  dependency-type: direct:production
  update-type: version-update:semver-major
  dependency-group: actions-core
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-07-01 12:49:11 +02:00
dependabot[bot] 7f2f30604c deps(deps): bump golang.org/x/tools in the golang group
Bumps the golang group with 1 update: [golang.org/x/tools](https://github.com/golang/tools).


Updates `golang.org/x/tools` from 0.46.0 to 0.47.0
- [Release notes](https://github.com/golang/tools/releases)
- [Commits](https://github.com/golang/tools/compare/v0.46.0...v0.47.0)

---
updated-dependencies:
- dependency-name: golang.org/x/tools
  dependency-version: 0.47.0
  dependency-type: indirect
  update-type: version-update:semver-minor
  dependency-group: golang
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-06-29 15:55:54 +02:00
Tobias GesellchenandClaude Opus 4.8 8a8b74386d docs(blog): add a thank-you shout-out to AfterTouch sponsors
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-28 17:44:20 +02:00
Tobias GesellchenandClaude Opus 4.8 4aad9fbcf0 docs(blog): fix /blog-update skill (footer convention, no-push, narrative)
- Close every post with the established footer convention (## Current
  release + dated release line + subscribe note), and never restyle
  already-published posts to fit a new convention.
- Prefer a narrative over a bare release-note aggregation.
- Forbid em dashes (with a grep check).
- Allow an explicit tag/date argument to override lookback detection.
- Stop the skill from pushing or opening the PR itself: commit to a
  branch and hand the maintainer the push + PR commands (maintainer
  always pushes over SSH).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-28 17:44:20 +02:00
Tobias GesellchenandClaude Opus 4.8 d154d459c6 docs(blog): add May – June 2026 narrative update post
Tells the story since v0.93.1 (rescue to platform): local music + TTS,
robustness/security hardening, health diagnostics. Emphasises that the
roadmap is community-driven, shouts out Sander ten Brinke's
soundtouch-maui companion app, and sets out what v1.0.0 signals
(stability, clean-slate re-migration, beyond-Bose value: #495, #508, #188).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-28 17:44:20 +02:00
Tobias GesellchenandClaude Opus 4.8 e61d2d9ac9 docs: clarify the proxy trust gate is on the immediate TCP peer
Spell out that AfterTouch reads X-Forwarded-For only when the connecting
socket's source IP is in trusted_proxy_cidrs (the socket address, which a
header can't forge), reword the table rows in those terms, and note that a
proxy in a separate Docker container is usually seen as the Docker bridge
subnet rather than its published address.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-28 16:28:52 +02:00
Tobias GesellchenandClaude Opus 4.8 022cafbe23 docs: move proxy client-IP guidance to the deployment walkthrough
Reverse-proxy client-IP resolution (trust_forwarded_headers /
trusted_proxy_cidrs) was documented under HTTPS-SETUP because proxies are
commonly used for TLS termination, but it's really a deployment concern.
Relocate it to CLOUD-DEPLOY-WALKTHROUGH as a "Client IP behind a proxy or
load balancer" section with a behavior table (no-proxy default, trusted-proxy
XFF resolution, and the untrusted-peer spoofing gate). HTTPS-SETUP keeps the
TLS-termination example and now cross-links to it; the deployment section
links back for the cert details.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-28 16:28:52 +02:00
Tobias GesellchenandClaude Opus 4.8 1cac9989be fix(service): detect first run by settings.json absence, not empty server_url
Startup treated an empty server_url as "first run" and wrote a fresh
default settings.json via createDefaultSettings, which builds the struct
from CLI flags and does not merge the existing file. A hand-authored
settings.json that sets, say, trust_forwarded_headers but leaves
server_url to the --server-url flag has no server_url, so it was
silently clobbered on first start (losing the operator's keys).

Gate the default-seed (and the lost-volume "first run" notice) on the
ABSENCE of settings.json instead. An existing file is now always
respected; a genuinely empty data dir still gets defaults and the
notice. This also fixes a latent loop where a never-set server_url made
every start look like a first run.

Adds regression tests: settingsFileExists, plus first-run seed both
preserving a hand-authored file and writing defaults when absent.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-28 16:06:13 +02:00
Tobias GesellchenandClaude Opus 4.8 d31710bd8e docs(clientip): document X-Forwarded-For-only proxy client-IP resolution
Update the HTTPS reverse-proxy guide and the trust_forwarded_headers /
trusted_proxy_cidrs settings comments to reflect that the client IP is now
resolved from X-Forwarded-For only (no longer X-Real-IP / True-Client-IP),
read via the request context rather than by rewriting r.RemoteAddr. The
nginx example now sets X-Forwarded-For.

(Release note staged locally at _/releases/v0_117_0.md, which is gitignored
like prior release notes.)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-28 13:15:49 +02:00
Tobias GesellchenandClaude Opus 4.8 67c30850cd fix(handlers): resolve client IP via chi ClientIP, drop deprecated RealIP
chi v5.3.0 deprecates middleware.RealIP (IP-spoofing advisories), which
failed the Lint and Static Security Analysis CI jobs (SA1019). Replace the
RealIP wrapper with chi's middleware.ClientIP: ClientIPFromRemoteAddr is
always applied so middleware.GetClientIP is populated, and when
trust_forwarded_headers is set and the immediate peer is a trusted-proxy
CIDR, ClientIPFromXFF resolves the real client from X-Forwarded-For
(rightmost entry outside the trusted CIDRs). The immediate-peer trust gate
is preserved, so a non-trusted peer's XFF is ignored. CIDR strings are
validated with netip.ParsePrefix first to avoid ClientIPFromXFF's panic.

Behavior change: only X-Forwarded-For is honored now (RealIP also read
X-Real-IP / True-Client-IP). Docs and a release note follow.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-28 13:15:49 +02:00
Tobias GesellchenandClaude Opus 4.8 28d7675fc4 refactor(handlers): resolve client IP via a clientHost helper
Route every HTTP read of the client IP through a single clientHost(r)
helper backed by chi's new middleware.GetClientIP, falling back to the
socket peer from r.RemoteAddr. AddDeviceToAccount now takes a bare client
host instead of a "host:port" RemoteAddr. Behavior is unchanged in this
commit (no ClientIP middleware is wired yet, so the fallback is always
taken); a follow-up wires middleware.ClientIP and removes the deprecated
middleware.RealIP.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-28 13:15:49 +02:00
dependabot[bot] f4c59c1e58 deps(deps): bump github.com/go-chi/chi/v5 from 5.2.5 to 5.3.0
Bumps [github.com/go-chi/chi/v5](https://github.com/go-chi/chi) from 5.2.5 to 5.3.0.
- [Release notes](https://github.com/go-chi/chi/releases)
- [Changelog](https://github.com/go-chi/chi/blob/master/CHANGELOG.md)
- [Commits](https://github.com/go-chi/chi/compare/v5.2.5...v5.3.0)

---
updated-dependencies:
- dependency-name: github.com/go-chi/chi/v5
  dependency-version: 5.3.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-06-28 13:15:49 +02:00
Tobias GesellchenandClaude Opus 4.8 a033ec077f fix(health): reword sources_xml_diff title to an expectation (refs #493)
The check title asserted "Speaker /sources matches service Sources.xml",
but the row renders as a warning when they differ, so "matches" plus a
warning read as a contradiction (reported in #493). Reword to "should
match" so the title states the expectation; the per-finding messages and
severities already convey whether it holds and what the differences are.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-28 12:10:25 +02:00
Tobias GesellchenandClaude Opus 4.8 1fff2c07a9 feat(health): show device name and IP on per-device findings
Per-device health findings previously labelled the device by account
and device IDs only (e.g. "account 3230304 · device 08DF1F0BA325"),
which is hard to place at a glance. Add display-only Name and IP fields
to health.Target and fill them centrally via EnrichTargets after the
checks run, so individual checks don't each have to look up the device
record. Both the live health endpoint and the diagnostic export go
through the new Server.runHealthChecks helper, and the Health tab renders
the friendly name first, then account/device IDs, then IP.

Fixes match on Account+Device only, so the new fields don't affect
quick-fix dispatch.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-28 12:10:18 +02:00
Tobias GesellchenandClaude Opus 4.8 84dc5fe999 docs(guides): add FRITZ!Box + AdGuard DNS-based bose hostname guide
Document the setup where AfterTouch sits behind a local resolver
(AdGuard Home / Pi-hole / FRITZ!Box) and is addressed by a short
hostname like `bose` instead of a raw IP. Captures the symptom cluster
(INVALID_SOURCE, missing source types, URL-mismatch pre-flight) and the
fix: short-hostname DNS rewrites, TLS_EXTRA_HOST coverage, switching the
service URLs to the hostname, and re-migration. Based on a real
user-contributed setup; IPs sanitised to RFC 5737.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-28 11:18:18 +02:00
Tobias GesellchenandClaude Opus 4.8 41b63216f0 docs(getting-help): note Discord exists as an on-request last-resort channel
Acknowledge a small Discord for direct, real-time conversation when an
email exchange or an issue/discussion thread isn't enough. No public
invite link: Issues and Discussions stay the first stop, and the invite
is shared in-thread only when a conversation genuinely needs it.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-28 10:59:23 +02:00
Tobias GesellchenandClaude Opus 4.8 258b9e7471 chore(cli): list speaker url-upnp in speaker help
The help text still listed only tts/url/beep/notify. Add the UPnP
AVTransport option (no app key, no DNS; http:// only, replaces source).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-27 21:33:45 +02:00
Tobias GesellchenandClaude Opus 4.8 a54204c1d0 feat(cli): play a URL via UPnP/AVTransport, no app-key or DNS (refs #517)
Adds a third way to push a clip to a speaker, surfaced by @dagrider in
#517: POST SetAVTransportURI + Play to the speaker's UPnP MediaRenderer
control endpoint (port 8091). Unlike /speaker play_info it needs no
app_key and no DNS interception, so it works on a plain LAN; the
trade-off is it switches the speaker to the UPNP source and replaces the
current playback (no duck-and-resume).

- pkg/client: SetAVTransportURI, AVTransportPlay, PlayURLViaUPnP (+ the
  :8091 control-URL derivation and SOAP plumbing), with tests.
- cmd/soundtouch-cli: `speaker url-upnp --url <url>`.
- docs: document the UPnP/AVTransport option under POST /speaker.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-27 21:27:53 +02:00
Tobias GesellchenandClaude Opus 4.8 906c53e5b6 docs(troubleshooting): on-device install "certificate is not yet valid" = stuck speaker clock (refs #403)
The on-device installer's curl fails with `curl: (60) ... certificate is
not yet valid` when the speaker's clock has fallen into the past (no NTP
since the cloud shutdown), since TLS then rejects the recently-issued
server cert. Document the symptom and the fix (set the date over SSH,
then re-run), and note the speaker_clock health check keeps it corrected
afterwards.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-27 20:58:49 +02:00
Tobias GesellchenandClaude Opus 4.8 b29dd1bd77 refs(models): remove the deprecated ZoneRequest member helpers (refs #511)
Delete RemoveMember, ClearMembers and HasMember (deprecated in the
previous commit) plus their tests. They had no production callers after
the zone remove paths moved to /removeZoneSlave.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-27 20:25:41 +02:00
Tobias GesellchenandClaude Opus 4.8 94496d5d4b refs(models): deprecate unused ZoneRequest member helpers (refs #511)
RemoveMember, ClearMembers and HasMember have no production callers: the
zone remove paths now use /removeZoneSlave instead of a /setZone rebuild,
and standalone is done by dissolving the zone. Mark them Deprecated ahead
of removal in the next commit (keeps history legible).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-27 20:25:41 +02:00
Tobias GesellchenandClaude Opus 4.8 720d2abc5c fix(zone): remove a member via /removeZoneSlave instead of a /setZone rebuild (refs #511)
Removing one member from a multi-member zone did nothing. The remove
paths rebuilt the zone with /setZone and the remaining members, but
/setZone is additive: it never drops a member that is simply absent from
the list. It only "removed" when the resulting set was empty (equivalent
to dissolve), which is why removing the last member worked but removing
one of several did not.

Switch all three remove paths to the dedicated /removeZoneSlave endpoint
(already implemented as client.RemoveZoneSlave):

- HandleZoneRemove  (web UI "remove member")
- HandleZoneLeave   (web UI slave "leave zone")
- RemoveFromZone    (client lib, used by CLI `zone remove`)

DissolveZone (setZone master-only) and HandleZoneAdd (additive setZone)
are correct and unchanged. Adds handler regression tests for remove/leave
and rewrites TestClient_RemoveFromZone to assert /removeZoneSlave (the old
test removed one of two members but only checked that setZone was called,
never that the member was dropped).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-27 20:25:41 +02:00
Tobias GesellchenandClaude Opus 4.8 462b4179f1 fix(docs,service): persist the Docker data dir at /app/data + warn when empty (refs #517)
The walkthrough mounted the volume at /data, but the image's DATA_DIR is
/app/data, so the documented docker run never actually persisted the
datastore, settings or CA; a recreated container silently lost all state.
Correct the mount path, document what lives under /app/data and the cost
of losing it, and add a Windows/macOS Docker Desktop note (host
networking is Linux-only; publish ports; DNS interception needs :53/:443).
The service also logs a clear notice on startup when the data dir looks
empty.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-27 19:58:19 +02:00
Tobias GesellchenandClaude Opus 4.8 18f3eabd67 docs(api,health): note /speaker play_info needs DNS interception (refs #517)
play_info notifications make the speaker validate the app_key via
GET /v1/auth against a hardcoded Bose host; without DNS interception that
call can't resolve and /speaker times out with ALLEGROWEBSERVER_TIMEOUT
(1046). Document the requirement on POST /speaker (plus the no-DNS
LOCAL_INTERNET_RADIO alternative), and have the "Test DNS path" health
check mention that TTS/play_info depends on the same DNS path.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-27 19:58:19 +02:00
Tobias GesellchenandClaude Opus 4.8 788a6ced93 fix(setup): re-install the CA when it was regenerated, not just label-matched (refs #517)
checkCACertTrusted matched only the static "# AfterTouch" label in the
device's trust bundle. After the service CA was regenerated (e.g. a
recreated container with a fresh/empty data dir), the stale label was
still present, so the migration wrongly reported the speaker as already
trusting the new CA and skipped re-installing it, leaving the speaker
unable to validate TLS to the service.

When the service CA is available, compare the actual cert payload and
re-install on mismatch; fall back to the label only when the CA can't be
read (CLI callers without Crypto). Adds regression tests for the
stale-label and no-Crypto cases.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-27 19:58:19 +02:00
Tobias GesellchenandClaude Opus 4.8 eab5241e6d docs: fix doc links flagged by markdown-link-check (refs #521)
- radio-browser.md: correct the relative path to the troubleshooting
  section (../guides/TROUBLESHOOTING.md#..., not ../../guides/.../).
- TROUBLESHOOTING.md: drop a same-page fragment link to the emoji
  "Getting More Help" heading (github-slugger anchor was unstable);
  reference the section in prose instead.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-27 19:34:09 +02:00
Tobias GesellchenandClaude Opus 4.8 96cdc050d1 docs(CLAUDE): document how to decrypt diagnostic reports
Use the repo's own scripts/decrypt-diagnostic.go (not the generic age
CLI), unpack per-file next to the .age, and note the archive layout.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-27 19:34:09 +02:00
Tobias GesellchenandClaude Opus 4.8 9d8e8f4858 docs(troubleshooting): radio sources not activating after in-place migration (refs #521)
After an in-place migration the firmware sometimes does not activate the
radio source types (LOCAL_INTERNET_RADIO, TUNEIN, RADIO_BROWSER) even
though the entries are present in the device's own Sources.xml; a reboot
and a sourcesUpdated notification do not help. The root cause is not yet
understood, so this documents the user-confirmed workaround (factory
reset + re-migrate) rather than changing migration behaviour:

- New troubleshooting section with a stable anchor, linked from the
  sources_xml_diff health check and the Radio Browser reference.
- Capture the speaker's on-device /mnt/nv/BoseApp-Persistence/1/Sources.xml
  in the diagnostic export (when SSH is available), so a future report
  taken before a factory reset carries the evidence to pin down the cause.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-27 19:34:09 +02:00
Tobias Gesellchen c318dbb99f docs(web): chore 2026-06-25 09:47:19 +02:00
Tobias GesellchenandClaude Opus 4.8 ea7f6f36ef feat(install): default installers to the latest release via releases/latest
The on-device and Raspberry Pi installers hardcoded the release version, which
had to be bumped on every release. Default VERSION to empty and resolve the
newest tag by following GitHub's documented stable redirect
(https://github.com/<repo>/releases/latest -> .../releases/tag/vX.Y.Z), reading
the effective URL. This avoids the GitHub API rate limit and needs no jq.

An explicit version (positional arg / VERSION= / --version) still pins a
release. If the lookup fails (offline, rate-limited, or a curl without -w
support), each script falls back to a pinned FALLBACK_VERSION so installs still
work. The Pi self_update path runs after resolution, so it fetches the resolved
tag's installer.

Docs updated to state the default installs the latest release; the pinned-version
examples remain as illustrations.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 09:47:19 +02:00
Tobias GesellchenandClaude Opus 4.8 01ebbb102d docs(install): bump example/default version v0.107.0 -> v0.111.3
The installer docs and the on-device + Raspberry Pi installer scripts all
defaulted to and showed v0.107.0. Update every install example and the
VERSION defaults to the current release v0.111.3 across the on-device and
Pi guides and scripts.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 09:47:19 +02:00
Tobias GesellchenandClaude Opus 4.8 2e6e88dd8c feat(scripts): add Raspberry Pi uninstallers + fix stale install-web.sh docs
soundtouch-web was renamed to soundtouch-player and the web UI merged into
soundtouch-service, so the player is now optional. The Raspberry Pi / host
installers had no matching uninstaller (removal was only documented as manual
commands), and users who installed the old soundtouch-web have a leftover
service with no scripted way to remove it.

Add three uninstallers under scripts/raspberry-pi/, each mirroring its
installer's conventions and tolerant of already-missing pieces:

- uninstall.sh        — soundtouch-service; preserves the data directory by
                        default, --purge / PURGE_DATA=true to delete it.
- uninstall-player.sh — soundtouch-player (stateless).
- uninstall-web.sh    — leftover soundtouch-web; points users at install-player.sh.

The shared soundtouch:soundtouch user/group is removed only once no other
soundtouch-{service,player,web} install remains on the host.

Docs: the README and guides still told users to fetch install-web.sh to install
the player. Switch those to install-player.sh, keep but improve the manual
removal commands (note the service datastore is preserved unless explicitly
deleted), document the new uninstallers, and add a "Migrating from soundtouch-web"
section.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 09:47:19 +02:00
Tobias GesellchenandClaude Opus 4.8 902b9d8402 fix(setup/ui): passive-observer no-inbound is a warning, not a failure (refs #471)
The migration pre-flight "Reachability check (passive observer)" reported a
timed-out no-inbound as a hard failure, so the panel showed "N of M checks
failed" and forced a Proceed Anyway. That outcome is usually just timing: the
speaker's swUpdate daemon dials out on its own slow schedule and a reboot
after Apply validates the fan-out. One reporter wrongly suspected custom
service ports were to blame (#471, Leeto001).

Introduce a proper non-blocking `warn` status (amber, no fail count) and
downgrade the no-inbound case to it, with a message that explains the timing
and states it is not a port or config problem and is safe to proceed. The
pre-flight summaries now surface a warning count alongside the passed/skipped
counts and keep auto-proceeding; genuine probe errors still fail.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 09:43:44 +02:00
dependabot[bot] dbe292a045 ci(deps): bump softprops/action-gh-release from 3.0.0 to 3.0.1
Bumps [softprops/action-gh-release](https://github.com/softprops/action-gh-release) from 3.0.0 to 3.0.1.
- [Release notes](https://github.com/softprops/action-gh-release/releases)
- [Changelog](https://github.com/softprops/action-gh-release/blob/master/CHANGELOG.md)
- [Commits](https://github.com/softprops/action-gh-release/compare/b4309332981a82ec1c5618f44dd2e27cc8bfbfda...718ea10b132b3b2eba29c1007bb80653f286566b)

---
updated-dependencies:
- dependency-name: softprops/action-gh-release
  dependency-version: 3.0.1
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-06-25 09:33:58 +02:00
Tobias GesellchenandClaude Opus 4.8 1c6f4c9eb8 fix(release): build the tagged commit and stamp the real version (#525)
v0.114.0 binaries reported version 0.0.0 in the web UI. Two root causes,
both fixed here.

1. The release build relied solely on Go's VCS stamping of
   info.Main.Version and never injected a version. When v0.114.0 was
   re-released via workflow_dispatch from `main` (one commit past the
   tag) with a shallow checkout, no tag was reachable, so Go stamped a
   v0.0.0-<ts>-<sha> pseudo-version. The asset filenames used the
   validated input version, so the files were named v0.114.0 but
   reported 0.0.0 at runtime.

2. The `release` and `workflow_dispatch` triggers followed two distinct
   patterns. On `release` every job's checkout landed on the tagged
   commit (GITHUB_SHA == tag); on `workflow_dispatch` they all built
   whatever branch the run started from. So a manual dispatch built the
   wrong source entirely (binaries and Docker images alike).

Changes:

- Unify both triggers on the git tag. `validate` resolves the tag once
  (inputs.tag on dispatch, release.tag_name on a release event), verifies
  it exists in git, and exposes it as an output. Every other job checks
  out `ref: needs.validate.outputs.tag`, so the build is always the
  tagged commit regardless of trigger. The dispatch path now re-releases
  an existing tag (push the tag first) instead of creating one from a
  branch; it fails fast if the tag is missing.
- Inject -X main.version/commit/date into the release binaries, mirroring
  the Dockerfile (which has done this since #422). version/commit no
  longer depend on git stamping; commit is read from the checked-out HEAD
  (not github.sha, which on dispatch is the branch HEAD). Both binaries
  and Docker images take the v-prefixed tag (needs.validate.outputs.tag)
  so the displayed version stays "v0.114.0", matching prior releases.
- Guard updateBuildInfo() in all four cmd/*/main.go so an injected
  version (version != "dev") is never clobbered by a VCS pseudo-version.
  `go install …@vX.Y.Z` still resolves the tag via build info as before.
- Collapse the duplicated `if event_name == workflow_dispatch` tag
  derivations and route tag/version through needs.validate.outputs.*.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 09:33:48 +02:00
dependabot[bot] b04d7f2fe1 ci(deps): bump actions/checkout in the actions-core group
Bumps the actions-core group with 1 update: [actions/checkout](https://github.com/actions/checkout).


Updates `actions/checkout` from 6.0.3 to 7.0.0
- [Release notes](https://github.com/actions/checkout/releases)
- [Changelog](https://github.com/actions/checkout/blob/main/CHANGELOG.md)
- [Commits](https://github.com/actions/checkout/compare/df4cb1c069e1874edd31b4311f1884172cec0e10...9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0)

---
updated-dependencies:
- dependency-name: actions/checkout
  dependency-version: 7.0.0
  dependency-type: direct:production
  update-type: version-update:semver-major
  dependency-group: actions-core
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-06-25 09:26:52 +02:00
Tobias GesellchenandClaude Opus 4.8 2744708a9d chore(release): drop the transitional soundtouch-web alias
soundtouch-web was a transitional alias of soundtouch-player. Stop
building and publishing it everywhere, and refresh the release notes
while at it:

- release.yml: remove the soundtouch-web binary, its individual and
  combined checksums, and its release assets (EXPECTED_COUNT 35 -> 28);
  drop the ghcr.io/...-web Docker image steps. Also slim the
  workflow_dispatch release notes to an accurate AfterTouch header plus
  GitHub's auto-generated changelog, with the bare tag as the title.
- Dockerfile: drop the soundtouch-web image stage.
- Makefile: remove WEB_NAME and the build-web target (and its use in
  build/install).
- Delete scripts/raspberry-pi/install-web.sh (it fetched a release asset
  that is no longer published) and point the docs at install-player.sh.
- Correct README, CLAUDE.md, and main.go wording that claimed the alias
  was still published.

The runtime notice for a binary still run under the soundtouch-web name
is kept, so anyone who renamed the binary is nudged to soundtouch-player.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-22 22:29:23 +02:00
Tobias GesellchenandClaude Opus 4.8 9331e63b2d feat(setup): add enable-ssh --full-config for devices where sshd never starts (#515)
The default `setup enable-ssh` injects the remote_services/sshd payload
only via `envswitch boseurls set` and relies on the speaker re-reading its
boseurls (~60s) without a reboot. On the SoundTouch Portable (Series I,
FW 27.0.6.46330.5043500) and some CineMate 520 units the device accepts and
persists that injection (getpdo confirms) but sshd never comes up, so :22
stays "Connection refused".

@Henri-be got root on the ST Portable by typing a different sequence by hand
over telnet :17000: the injection rides `sys configuration margeServerUrl`
(the runtime layer) as well as `envswitch`, all four URL keys are written,
and the device is rebooted so it re-parses the config at boot.

Add an opt-in `--full-config` flag that replicates that exact sequence
(EnableSSHViaTelnetFullConfig + telnet reboot via the existing
RebootMethodTelnet). The default single-envswitch path is unchanged, so the
field-confirmed flow on the Wireless Link Adapter and CineMate 520 `lisa`
variant does not regress. Docs (TELNET-COMMAND-REFERENCE, DEVICE-LOGGING)
document both paths and which device models/firmware need `--full-config`.

The flag automation is candidate behaviour awaiting reporter confirmation:
the manual sequence is confirmed on the ST Portable, the flag is not yet.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-22 21:23:15 +02:00
dependabot[bot] 4e25dacb0d deps(deps): bump golang.org/x/image in the golang group
Bumps the golang group with 1 update: [golang.org/x/image](https://github.com/golang/image).


Updates `golang.org/x/image` from 0.42.0 to 0.43.0
- [Commits](https://github.com/golang/image/compare/v0.42.0...v0.43.0)

---
updated-dependencies:
- dependency-name: golang.org/x/image
  dependency-version: 0.43.0
  dependency-type: indirect
  update-type: version-update:semver-minor
  dependency-group: golang
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-06-22 18:09:39 +02:00
dependabot[bot] f92aad0612 deps(deps): bump github.com/hashicorp/mdns from 1.0.6 to 1.0.7
Bumps [github.com/hashicorp/mdns](https://github.com/hashicorp/mdns) from 1.0.6 to 1.0.7.
- [Release notes](https://github.com/hashicorp/mdns/releases)
- [Commits](https://github.com/hashicorp/mdns/compare/v1.0.6...v1.0.7)

---
updated-dependencies:
- dependency-name: github.com/hashicorp/mdns
  dependency-version: 1.0.7
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-06-15 18:37:43 +02:00
dependabot[bot] 7a5a587f2a deps(deps): bump the golang group with 9 updates
Bumps the golang group with 9 updates:

| Package | From | To |
| --- | --- | --- |
| [golang.org/x/crypto](https://github.com/golang/crypto) | `0.52.0` | `0.53.0` |
| [golang.org/x/net](https://github.com/golang/net) | `0.55.0` | `0.56.0` |
| [golang.org/x/term](https://github.com/golang/term) | `0.43.0` | `0.44.0` |
| [golang.org/x/image](https://github.com/golang/image) | `0.41.0` | `0.42.0` |
| [golang.org/x/mod](https://github.com/golang/mod) | `0.36.0` | `0.37.0` |
| [golang.org/x/sync](https://github.com/golang/sync) | `0.20.0` | `0.21.0` |
| [golang.org/x/sys](https://github.com/golang/sys) | `0.45.0` | `0.46.0` |
| [golang.org/x/text](https://github.com/golang/text) | `0.37.0` | `0.38.0` |
| [golang.org/x/tools](https://github.com/golang/tools) | `0.45.0` | `0.46.0` |


Updates `golang.org/x/crypto` from 0.52.0 to 0.53.0
- [Commits](https://github.com/golang/crypto/compare/v0.52.0...v0.53.0)

Updates `golang.org/x/net` from 0.55.0 to 0.56.0
- [Commits](https://github.com/golang/net/compare/v0.55.0...v0.56.0)

Updates `golang.org/x/term` from 0.43.0 to 0.44.0
- [Commits](https://github.com/golang/term/compare/v0.43.0...v0.44.0)

Updates `golang.org/x/image` from 0.41.0 to 0.42.0
- [Commits](https://github.com/golang/image/compare/v0.41.0...v0.42.0)

Updates `golang.org/x/mod` from 0.36.0 to 0.37.0
- [Commits](https://github.com/golang/mod/compare/v0.36.0...v0.37.0)

Updates `golang.org/x/sync` from 0.20.0 to 0.21.0
- [Commits](https://github.com/golang/sync/compare/v0.20.0...v0.21.0)

Updates `golang.org/x/sys` from 0.45.0 to 0.46.0
- [Commits](https://github.com/golang/sys/compare/v0.45.0...v0.46.0)

Updates `golang.org/x/text` from 0.37.0 to 0.38.0
- [Release notes](https://github.com/golang/text/releases)
- [Commits](https://github.com/golang/text/compare/v0.37.0...v0.38.0)

Updates `golang.org/x/tools` from 0.45.0 to 0.46.0
- [Release notes](https://github.com/golang/tools/releases)
- [Commits](https://github.com/golang/tools/compare/v0.45.0...v0.46.0)

---
updated-dependencies:
- dependency-name: golang.org/x/crypto
  dependency-version: 0.53.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: golang
- dependency-name: golang.org/x/net
  dependency-version: 0.56.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: golang
- dependency-name: golang.org/x/term
  dependency-version: 0.44.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: golang
- dependency-name: golang.org/x/image
  dependency-version: 0.42.0
  dependency-type: indirect
  update-type: version-update:semver-minor
  dependency-group: golang
- dependency-name: golang.org/x/mod
  dependency-version: 0.37.0
  dependency-type: indirect
  update-type: version-update:semver-minor
  dependency-group: golang
- dependency-name: golang.org/x/sync
  dependency-version: 0.21.0
  dependency-type: indirect
  update-type: version-update:semver-minor
  dependency-group: golang
- dependency-name: golang.org/x/sys
  dependency-version: 0.46.0
  dependency-type: indirect
  update-type: version-update:semver-minor
  dependency-group: golang
- dependency-name: golang.org/x/text
  dependency-version: 0.38.0
  dependency-type: indirect
  update-type: version-update:semver-minor
  dependency-group: golang
- dependency-name: golang.org/x/tools
  dependency-version: 0.46.0
  dependency-type: indirect
  update-type: version-update:semver-minor
  dependency-group: golang
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-06-15 18:19:59 +02:00
Tobias GesellchenandClaude Opus 4.8 23949189f7 fix(player): stop navbar title/icons overlapping on small screens; show device names in grouping
#500: the absolutely-centered page title and the right-aligned icon bar
shared the same space in the fixed-height navbar and overlapped on phones
(portrait). On <=600px the navbar now wraps into two rows: row 1 keeps the
logo with the title beside it (the title fills the remaining width and
ellipsizes), and the icon bar drops onto its own centered, full-width row
below. CSS-only.

#498: the zone/grouping UI showed raw IP addresses instead of device names.
Root cause was a field-name casing bug: Zone.js read info.Name (uppercase),
but the device info field is info.name (lowercase) everywhere else in the UI
(app.js, DeviceList, Library, TTS, ...). So the lookup always missed and fell
back to the IP. Fixed the casing in the deviceName() helper, and made the
"Add to zone" picker show the device name with the IP as a smaller secondary
line (reusing the .picker-device-info/name/ip pattern the other pickers
already use). Member/master rows resolve names via deviceName().

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-14 21:48:02 +02:00
Tobias GesellchenandClaude Opus 4.8 d6257e6108 fix(player): set STORED_MUSIC type when replaying a recent (fix INVALID_SOURCE)
Replaying a STORED_MUSIC item from Recents sent the speaker a ContentItem with
an empty type (recents carry no contentItemType for STORED_MUSIC), and the
speaker rejects an empty-type STORED_MUSIC select with INVALID_SOURCE. The
library play paths work because they pass type "track"/"dir".

HandleDevicePlay now derives the type from the speaker-native location, which
ends with the item kind (e.g. "1$4$2 TRACK" -> "track"), when the caller didn't
supply one. (The recents account itself is already correct via the #503 fix.)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-14 21:48:02 +02:00
Tobias GesellchenandClaude Opus 4.8 b8de0f90f0 feat(player): allow playing a Library folder (queue it) so next/prev work (refs #501)
DLNA STORED_MUSIC playback stopped after one track and next/previous did nothing:
the Library UI only offered a play button on individual tracks and always
selected with type "track", so the speaker had no queue to advance through
(next/prev send the NEXT_TRACK/PREV_TRACK key, which needs a queue).

- playEntry now passes the entry's own type, so selecting a folder uses the
  container type ("dir") instead of "track" — letting the speaker queue the
  folder for next/previous + auto-advance.
- show the play button on folders too (title "Play folder"), in addition to
  navigating into them.

Server-side needs no change: HandlePlayLibrary already forwards the type to the
speaker's /select. Whether a given firmware queues a container select is to be
confirmed on hardware (testable with cmd/example-dlna-server).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-14 21:48:02 +02:00
Tobias GesellchenandClaude Opus 4.8 0cdf8deb3b fixup: refine CodeQL XSS autofix (fakespeaker round-trip + lint)
Follow-up to the Copilot Autofix commit for the reflected-XSS finding.

- fakespeaker buildAddGroupResponse: the autofix modelled only
  name/master/slave, dropping the posted masterDeviceId, roles, id, and
  senderIPAddress that the client (pkg/models.Group) actually sends and
  TestFakeSpeakerAddGroupEchoesWithGroupOK expects to survive the echo.
  Parse into the canonical models.Group and re-marshal it, so values stay
  XML-escaped (CodeQL-clean) and the fake can't drift from the real
  request schema. Updates the now-stale doc comment.
- marge ProviderSettingsToXML / fakespeaker: satisfy golangci-lint
  (wsl_v5 cuddled type decls, gofmt trailing blank lines) the autofix
  left behind.

make lint clean; marge, handlers, and fakespeaker suites pass.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-14 21:36:01 +02:00
Tobias Gesellchenandlnx01 557e92682f Potential fix for pull request finding 'CodeQL / Reflected cross-site scripting'
Co-authored-by: Copilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com>
2026-06-14 21:36:01 +02:00
Tobias GesellchenandClaude Opus 4.8 841a9047e2 style(example-dlna-server): satisfy golangci-lint (nilerr, revive)
Two lint fixes on the DLNA test server, no behaviour change:

- nilerr: the "skip unreadable file, keep walking" branch in the
  --media-dir WalkDir callback returns nil after a non-nil read error
  by design; annotate it with //nolint:nilerr, matching the existing
  skip-entry branch above it.
- revive (redefines-builtin-id): rename between()'s `close` parameter
  (and `open` for symmetry) to closeTag/openTag so it no longer shadows
  the builtin `close`.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-14 21:36:01 +02:00
Tobias GesellchenandClaude Opus 4.8 5103f459bf test(example-dlna-server): one container per album dir, real artist from path
--media-dir flattened all tracks into a single container named after --name, so
browsing showed one "<--name>" dir (and a doubled breadcrumb) instead of the
real album folder, and every track's artist was the hardcoded "Test Artist".

- Group tracks by their containing directory; each becomes its own browsable +
  playable container titled after that directory (e.g. "Sunday at Devil Dirt").
- Derive the artist from the directory above the album
  (<root>/<artist>/<album>/track), falling back to "Unknown Artist"; album stays
  the track's own folder name.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-14 21:36:01 +02:00
Tobias GesellchenandClaude Opus 4.8 dfa6706703 feat(example-dlna-server): album art, real media via --media-dir, fix discovery, BrowseMetadata + access log
Tooling to reproduce "album cover broken in the player" (disc #499) and to debug
STORED_MUSIC playback against a controllable DLNA source.

- dlnatest.Item gains ArtPayload/ArtMime; the Browse DIDL emits
  <upnp:albumArtURI> and the art bytes are served at /AlbumArt/<id>.<ext>. The
  built-in tracks carry a tiny PNG cover so the repro works with zero setup.
- audio + art are served via http.ServeContent (adds the byte-range support real
  speakers use when streaming).
- example-dlna-server gains --media-dir: serve real .mp3/.wav/.flac/.m4a/.ogg
  files, searched recursively so an artist/album tree works. Art per track: a
  sibling <name>.jpg/.png, else cover.jpg/cover.png/folder.jpg in the album
  folder. Files are read into memory (point it at an album, not a whole library).
- BrowseMetadata: serveContentDir now honours BrowseFlag and returns single-object
  metadata (with the track's <res>). Speakers issue Browse(BrowseMetadata) to
  resolve a track before playing; returning empty caused INVALID_SOURCE.
- fix SSDP discoverability on multi-interface hosts: join the multicast group on
  the interface that owns the LAN IP (macOS lists lo0 first, so the old "first
  multicast interface" join landed on loopback and never heard the LAN M-SEARCH).
- add an HTTP access log (method/path/status/bytes/peer, plus ObjectID+BrowseFlag
  for Browse) so the speaker's request sequence is visible while debugging.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-14 21:36:01 +02:00
Tobias GesellchenandClaude Opus 4.8 6ad75657e8 fix(datastore): dedup recents by ID in SaveRecents (stop same-recent pile-up)
A speaker<->marge recents sync could re-store the same recent (same ID) multiple
times — observed live as one STORED_MUSIC track appearing 4x in the speaker's
/recents, the service's stored Recents.xml, and /full. The duplicates crowd the
capped (10) recents list and evict other sources (e.g. a freshly played Spotify
track never appears). SaveConfiguredSources already dedups by ID; SaveRecents did
not, so dupes introduced by any path (AddRecent move-to-front, syncRecents from
the speaker's /full, setup/health) persisted and fed back through the sync loop.

SaveRecents now dedups by ID (first occurrence wins) at the single chokepoint all
callers share, so the list self-heals on the next write. Regression test added.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-14 21:02:24 +02:00
Tobias GesellchenandClaude Opus 4.8 393b31ad93 fix(marge): stop recents move-to-front from dropping/duplicating entries
Re-playing an existing recent could make it (and its list neighbour)
vanish from the speaker's recents, even with the list well under the
10-item cap. Root cause is a slice-aliasing bug in updateOrCreateRecent's
move-to-front branch:

  recentObj = &recents[i]
  recents = append([]ServiceRecent{*recentObj}, append(recents[:i], recents[i+1:]...)...)
  return recentObj, recents

The inner append(recents[:i], recents[i+1:]...) shifts elements left in
place in the shared backing array, overwriting slot i. The returned
recentObj still points at &recents[i], so it leaks the neighbouring
recent back to the speaker. Worse, Go does not specify evaluation order
between the *recentObj dereference and the inner append call, so the
front element written into the saved list can also read the overwritten
slot, dropping the matched recent and duplicating its neighbour. The
SaveRecents dedup-by-ID guard then collapses that duplicate into a clean
loss.

Verified against recorded interactions (a "White Water" replay returned
the "Sand Castle" recent; both Spotify albums vanished from a 9-item
list) and a live diagnostic export (6 persisted recents, no duplicates,
both albums gone).

Fix: copy the matched recent out first, rebuild into a fresh backing
array, and return a pointer into the new slice. Adds a regression test
that fails on the old code and passes now.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-14 20:59:35 +02:00
Tobias GesellchenandClaude Opus 4.8 fc3e6ed795 fix(marge): preserve STORED_MUSIC account in recents (fix replay INVALID_SOURCE)
Replaying a STORED_MUSIC media-server item from Recents failed with
INVALID_SOURCE: the served recent's <source> had an empty <username>, so the
speaker fell back to the provider id ("7") as the account and could not resolve
which media server to use.

Root cause: a media server's account ("<UDN>/0") is persisted in
SourceKey.Account, but Username is NOT persisted (SaveConfiguredSources writes
sourceKey.account, not username). prepareRecentItemParitySource and
formatRecentResponse emitted <username> straight from the now-empty Username
field. The /full path (mapToFullResponseSource) already falls back to
SourceKeyAccount; the recents builders did not.

Fix: add recentSourceUsername(src) that falls back to SourceKeyAccount when
Username is empty (TuneIn / Internet Radio / Local Internet Radio keep an empty
username for parity), used by both recent <source> builders. Regression test
drives the captured Bose_Lisa flow (sourceid-only recent POST) and asserts the
served <source><username> is the real UDN, never empty or the bare provider id.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-14 19:01:07 +02:00
Tobias GesellchenandClaude Opus 4.8 d862666fb7 fix(marge): keep all DLNA media servers registered (don't evict on second add)
A speaker registers each DLNA media server as a STORED_MUSIC source whose
account is "<UDN>/0", and reconciles its source list against marge (/full +
/sources). AddSource deduped STORED_MUSIC by provider ID alone, so registering
a second media server overwrote the first in the datastore; the first then
disappeared from /full + /sources and the speaker dropped it. Only one media
server could ever stay registered.

- STORED_MUSIC now replaces only when the account (SourceKey.Account) matches,
  so distinct servers coexist and re-adding the same server updates in place.
  Other (singleton) providers keep replace-by-provider.
- Generate source IDs from crypto/rand instead of a per-second timestamp.
  SaveConfiguredSources dedups by ID, so two sources created in the same instant
  would otherwise collide and one would be silently dropped; a timestamp (even
  nanosecond) is fragile on coarse clocks, so use 64 bits of randomness with a
  timestamp fallback only if the RNG fails.
- Add a regression test for two coexisting media servers + same-account update.

Diagnosed from speaker + service logs: setMusicServiceAccount succeeds locally,
the speaker pushes AddSource to marge (streaming.bose.com, DNS-intercepted to
AfterTouch), then re-fetches /full + /sources; that list returned only the
latest STORED_MUSIC source, so the speaker pruned the previously-added one.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-14 18:23:29 +02:00
Tobias GesellchenandClaude Opus 4.8 836e985c58 feat(player,cli): nudge sources refresh after adding a media server
Adding a DLNA media server (setMusicServiceAccount) can leave the new
STORED_MUSIC source not fully registered on the speaker, so playing a track
fails with INVALID_SOURCE until a power-cycle. AfterTouch's health
diagnostic already recommends the no-reboot fix: a sourcesUpdated
notification makes the speaker re-fetch its account /full and re-register
its source list.

Fire that nudge automatically right after a successful registration, in
both the player (HandleAddLibraryServer) and the CLI (account add-nas), via
the existing client.NotifySourcesUpdated. It is best-effort: registration
already succeeded, so a failed nudge never fails the request (the handler
returns {account, refreshed}, the CLI prints a warning that a power-cycle
may still be needed). The handler resolves the Bose device ID from the
cached DeviceConnection.DeviceInfo, falling back to GetDeviceInfo.

Note: per the diagnostic, a power-cycle is still occasionally required, so
the nudge is an improvement, not a guarantee.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-11 20:56:04 +02:00
dependabot[bot] f010e96699 ci(deps): bump codecov/codecov-action in the security-actions group
Bumps the security-actions group with 1 update: [codecov/codecov-action](https://github.com/codecov/codecov-action).


Updates `codecov/codecov-action` from 6.0.1 to 7.0.0
- [Release notes](https://github.com/codecov/codecov-action/releases)
- [Changelog](https://github.com/codecov/codecov-action/blob/main/CHANGELOG.md)
- [Commits](https://github.com/codecov/codecov-action/compare/e79a6962e0d4c0c17b229090214935d2e33f8354...fb8b3582c8e4def4969c97caa2f19720cb33a72f)

---
updated-dependencies:
- dependency-name: codecov/codecov-action
  dependency-version: 7.0.0
  dependency-type: direct:production
  update-type: version-update:semver-major
  dependency-group: security-actions
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-06-11 20:49:36 +02:00
dependabot[bot] aa35290bef docker(deps): bump alpine from 3.23 to 3.24
Bumps alpine from 3.23 to 3.24.

---
updated-dependencies:
- dependency-name: alpine
  dependency-version: '3.24'
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-06-11 20:48:37 +02:00
Tobias GesellchenandClaude Opus 4.8 da723a24e5 test(service): update router snapshot for /library routes
TestPrintRoutes is a golden snapshot of the full chi route tree. The new
DLNA Music Library routes (device-scoped /library/{servers,browse,play} and
the global /providers/library/servers, plus the /app/library SPA deep link)
legitimately extend the tree, so refresh the snapshot. Diff is exactly the
seven new library routes mapping to the new handlers; no other routes change.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-09 22:50:49 +02:00
Tobias GesellchenandClaude Opus 4.8 0435f990eb docs(dlna): add Music Library guide; mark /listMediaServers implemented
New guide docs/content/docs/guides/dlna-music-library.md: how to browse a
DLNA/UPnP media server and play it on a SoundTouch via native STORED_MUSIC
(discover -> register -> browse -> play), with CLI examples (RFC-5737 IPs +
placeholder UDNs) and the player Library tab (BETA), plus gotchas and format
limits. Flips GET /listMediaServers from unimplemented to implemented in
UNIMPLEMENTED-ENDPOINTS.md (client.ListMediaServers + models.ListMediaServersResponse).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-09 22:50:49 +02:00
Tobias GesellchenandClaude Opus 4.8 e399b5ab00 fix(player,discovery): normalize uuid: prefix for STORED_MUSIC accounts; fill MediaServer.Address
The DLNA UDN from discovery carries a "uuid:" prefix (e.g.
uuid:fa095ecc-...), but a SoundTouch STORED_MUSIC account is the bare UUID
plus /0 (the speaker's /sources reports the bare form). The mismatch made
the player Library tab show an "Add" button for an already-registered
server, and an Add via the UI would have registered a wrong "uuid:.../0"
account. Normalize (strip "uuid:") when mapping discovery results to the DTO
and when building the account in HandleAddLibraryServer, so the LAN list and
the registered list agree and Add builds the correct account. Verified live:
discover now returns the bare UDN, matching /sources.

Also populate the previously-unset MediaServer.Address from the
ContentDirectory control URL host.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-09 22:50:49 +02:00
Tobias GesellchenandClaude Opus 4.8 ab7c857630 feat(player): Library tab UI for DLNA browsing and playback
Adds the soundtouch-player "Library" tab (Preact + htm), implementing the
discover -> add server -> browse -> play flow over the device-scoped
library API. Device is picked up front (browsing is speaker-native), then:
find LAN servers (SSDP) and add one to the speaker, open a registered
server, navigate folders via a breadcrumb, and play a track via native
STORED_MUSIC. Mirrors the TuneIn/RadioBrowser components and reuses their
CSS classes; marked BETA. api.js gains libraryDiscover/Servers/AddServer/
RemoveServer/Browse/Play; app.js gets the nav entry, title, and route.

Validated end to end through the running player against real hardware
(FRITZ!Box media server -> ST10): now_playing source=STORED_MUSIC
status=PLAY_STATE.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-09 22:50:49 +02:00
Tobias GesellchenandClaude Opus 4.8 426036c911 feat(player): DLNA music library backend (native STORED_MUSIC, device-scoped)
Wires the soundtouch-player control API for browsing and playing DLNA
media-server content on a speaker, using the validated native STORED_MUSIC
path (the speaker is the DLNA control point; no AfterTouch proxy).

Routes (under /api/control):
- GET  /providers/library/servers            LAN-wide SSDP sweep (discovery.DiscoverMediaServers)
- GET  /devices/{id}/library/servers         STORED_MUSIC sources registered on this speaker (+ ready)
- POST /devices/{id}/library/servers         register a server (setMusicServiceAccount; 1024 = already present)
- DELETE /devices/{id}/library/servers/{account}  unregister
- GET  /devices/{id}/library/browse          speaker /navigate (root or container) -> location tokens
- POST /devices/{id}/library/play            select a STORED_MUSIC ContentItem (type=track)
- GET  /app/library                          SPA deep link

Browsing goes through the speaker's own /navigate so the returned location
tokens are the ones /select accepts; the raw DLNA ContentDirectory IDs are
not playable, so pkg/dlna is intentionally not on this path. All handlers
reuse existing client methods (Navigate, NavigateContainer, SelectContentItem,
GetSources, AddStoredMusicAccount, RemoveStoredMusicAccount) and the existing
APIResponse envelope. Unit tests cover play XML shape, browse mapping,
source filtering, idempotent register, and validation/404s.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-09 22:50:49 +02:00
Tobias GesellchenandClaude Opus 4.8 2e5e7a5763 fix(cli): library play uses native STORED_MUSIC (drop raw-URL modes)
Hardware testing (ST10 FW27.0.6 + FRITZ!Box 6490) showed the previous
raw-URL play modes do not play DLNA content: a raw stream URL sent as a
LOCAL_INTERNET_RADIO location is rejected by the speaker (APServer
"REJECT: TransportControl: Wrong Client", nothing plays). The native
mechanism is STORED_MUSIC: register the server, then select a ContentItem
carrying the media server's object ID as location.

`library play` now takes --source-account (<UDN>/0) and --location
(object ID from a browse), plus optional --name/--type/--art, and selects
a STORED_MUSIC ContentItem with type="track" via SelectContentItem (so the
type is set, which SelectStoredMusic does not do). It first checks /sources
for a READY STORED_MUSIC entry with that account and, if absent, prints a
ready-to-copy `account add-nas` hint and stops instead of failing opaquely.
The old --url/--mode raw-URL flags are removed.

Validated end to end: browse -> play -> now_playing source=STORED_MUSIC
status=PLAY_STATE. The speaker streams from the media server directly; no
AfterTouch proxy involved.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-09 22:50:49 +02:00
Tobias GesellchenandClaude Opus 4.8 c4acc794b8 feat(cli,client): soundtouch-cli library command + ListMediaServers
Adds the CLI-first surface for the DLNA feature
(https://github.com/gesellix/Bose-SoundTouch/discussions/213), so the
discovery/browse/play plumbing can be exercised against a real media server
and speaker without the web build loop.

- soundtouch-cli library servers: app-side SSDP sweep
  (discovery.DiscoverMediaServers); --via-speaker queries the speaker's own
  /listMediaServers instead, for an A/B of the two views.
- soundtouch-cli library browse --udn <id> [--object --start --count]:
  dlna.Browse of a discovered server's ContentDirectory.
- soundtouch-cli library play --url <streamURL> --mode <...>: plays a track
  URL on a speaker; --mode selects the playback path (local-internet-radio,
  local-music, stored-music, content-item) so the best one can be found
  empirically on hardware.
- pkg/client.ListMediaServers() + models.ListMediaServersResponse for the
  speaker-native (Option 2) path; an empty <ListMediaServersResponse />
  parses to an empty slice.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-09 22:50:49 +02:00
Tobias GesellchenandClaude Opus 4.8 ad0f1fbd8f feat(discovery,dlna): generic SSDP core + media-server discovery + browse client
Foundation for browsing DLNA media servers and playing tracks on a
SoundTouch speaker (https://github.com/gesellix/Bose-SoundTouch/discussions/213).

- pkg/discovery/ssdp.go: a target-agnostic UPnP SSDP core. SearchSSDP sweeps
  multiple targets (a typed device URN plus ssdp:all, since some servers only
  answer one), fans out across all routable IPv4 interfaces, and sends each
  batch in two rounds spaced 80ms apart so slower NAS/router boxes that drop
  back-to-back bursts still answer. FetchDescription parses a UPnP device
  description into a generic device tree with FindService/FirstIcon that
  recurse through sub-devices. The XML parse is a pure function for offline
  unit testing.
- pkg/discovery/mediaserver.go: DiscoverMediaServers rides the core, keeps
  only devices exposing a ContentDirectory service, and dedupes by UDN. The
  description->MediaServer mapping is a pure, tested function.
- pkg/dlna: a ContentDirectory browse client (Browse + DIDL-Lite parse +
  IsAudioItem), consuming discovery.MediaServer. Kept separate from discovery,
  mirroring how pkg/client is separate from pkg/discovery. Track metadata maps
  upnp:artist / upnp:album; the audio filter accepts audio/* MIME or an
  audioItem/musicTrack class.

Existing SoundTouch speaker discovery (pkg/discovery/upnp.go) is untouched;
migrating it onto the shared core is a later, de-risked step. Tests cover the
description/DIDL parsers and run the browse client against an in-process
ContentDirectory server; the parse was checked against real minidlna and
FRITZ!Box output.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-09 22:50:49 +02:00
Tobias GesellchenandClaude Opus 4.8 11b59d3911 test(dlna): add DLNA MediaServer test server (fixture + LAN example)
Adds a dependency-free Go DLNA/UPnP MediaServer used to develop and test
the upcoming "browse a DLNA server and play on a SoundTouch" feature
(https://github.com/gesellix/Bose-SoundTouch/discussions/213).

Two faces over one content core:
- pkg/dlna/dlnatest: an in-process server (httptest) serving rootDesc.xml
  and ContentDirectory Browse for an injectable content tree, for fast
  cross-platform unit tests with no Docker and no multicast.
- cmd/example-dlna-server: the same handlers behind a real http.Server
  plus an SSDP responder (answers M-SEARCH, periodic NOTIFY), so a real
  speaker on the LAN can discover it and fetch real (silent WAV) audio.

A Docker minidlna was unusable here: on macOS the container IP in the
DIDL <res> URL is unreachable from the LAN, and its SSDP never reaches the
speakers. A native Go server embeds the host LAN IP and is discoverable.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-09 22:50:49 +02:00
dependabot[bot] a7050fd503 ci(deps): bump github/codeql-action from 4.36.1 to 4.36.2
Bumps [github/codeql-action](https://github.com/github/codeql-action) from 4.36.1 to 4.36.2.
- [Release notes](https://github.com/github/codeql-action/releases)
- [Changelog](https://github.com/github/codeql-action/blob/main/CHANGELOG.md)
- [Commits](https://github.com/github/codeql-action/compare/87557b9c84dde89fdd9b10e88954ac2f4248e463...8aad20d150bbac5944a9f9d289da16a4b0d87c1e)

---
updated-dependencies:
- dependency-name: github/codeql-action
  dependency-version: 4.36.2
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-06-09 18:41:26 +02:00
Tobias GesellchenandClaude Opus 4.8 fa158bc498 feat(cli/setup): friendlier enable-ssh timeout + re-sync boseurls after XML migration (refs #471)
Two follow-ups from the #471 field reports on the BETA `setup enable-ssh`:

1. enable-ssh: when sshd (:22) does not come up within the wait window, this is
   no longer treated as a hard error. On some devices (e.g. the Wireless Link
   Adapter) the envswitch injection is accepted but sshd only starts after the
   speaker restarts. The command now prints a warning with power-cycle + retry
   guidance (and the exact ssh command), deliberately leaves the injected
   boseurls in place so a restart re-triggers the unlock, and exits cleanly
   instead of failing.

2. XML migration: re-apply the boseurls over telnet at the end of migrateViaXML
   so the runtime layer reported by `getpdo CurrentSystemConfiguration` matches
   the persisted SoundTouchSdkPrivateCfg.xml. After enable-ssh bootstraps SSH,
   that runtime layer still points at the placeholder (https://aftertouch.invalid),
   so the preflight cross-check keeps warning that margeServerUrl/swUpdateUrl
   differ between transports until a reboot. The re-apply reconciles it now.
   Best-effort: if telnet is unavailable (e.g. port 17000 was closed via
   --close-17000), a reboot still reconciles the layers, so it only logs a note
   and never fails the migration.

Tests cover the re-apply command and its best-effort (non-fatal) behavior.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-08 21:36:21 +02:00
284 changed files with 42254 additions and 3903 deletions
+45 -10
View File
@@ -1,8 +1,13 @@
Draft a "News & Updates" blog post for AfterTouch covering recent git activity, then open a draft PR for review.
Draft a "News & Updates" blog post for AfterTouch covering recent git activity, commit it to a branch, and hand the maintainer the commands to push and open a draft PR (the maintainer pushes, not you).
## Step 1 — Determine lookback window
Run:
If the invocation arguments name an explicit starting point (a tag like `v0.93.1` or a
date), use that as SINCE. For a tag, resolve its date:
`git log -1 --format=%ad --date=short <tag>`. An explicit argument always overrides the
auto-detection below.
Otherwise, auto-detect from the last published post:
```
git log --format="%ad" --date=short -- docs/content/blog/ | grep -v '_index' | head -1
```
@@ -63,25 +68,55 @@ sidebar:
Body structure:
1. Opening paragraph (35 sentences) explaining what happened and why it matters to someone running AfterTouch.
2. One `##` section per non-empty category. Use bullet points written for an operator audience no raw git subjects, no internal Go package paths.
3. End with: `**Current release:** vX.Y.Z`
2. The body. Prefer a narrative that ties the changes into a story (what shifted, why it matters), not a bare aggregation of the release notes. Group related work under `##` sections (the commit categories are raw material, not the final headings). Write for an operator audience: no raw git subjects, no internal Go package paths. A short bullet list inside a section is fine, but the post should read like prose, not a changelog dump.
3. Close with the standard footer convention used by the existing posts, so every post ends the same way:
Target length: 300600 words. Never include real IPs, MAC addresses, account IDs, or device names.
```markdown
## Current release
## Step 6 — Create a branch and open a draft PR
**vX.Y.Z**, released MONTH D, YYYY
This blog will be updated monthly, or whenever something significant ships.
Subscribe to the [GitHub releases](https://github.com/gesellix/Bose-SoundTouch/releases)
for individual version notes.
```
Get the release date with `git log -1 --format=%ad --date=format:'%B %-d, %Y' vX.Y.Z`.
When in doubt about any recurring element (footer, release line, tags), match the most
recent existing post under `docs/content/blog/` rather than inventing a new convention.
Never retrofit or restyle already-published posts to fit a new convention — they are
dated records; a new convention applies going forward only.
Target length: 300600 words (longer is fine when the story warrants it). Never include
real IPs, MAC addresses, account IDs, or device names.
**No em dashes.** Do not use the em dash character (``) anywhere in the post; use commas,
parentheses, colons, or separate sentences. (En dashes in a period label like
`April May 2026` are fine.) Verify with `grep -c '—' <file>` before committing.
## Step 6 — Create a branch and commit (do NOT push)
```bash
git checkout -b blog/YYYY-MM-update
git add docs/content/blog/YYYY-MM-slug.md
git commit -m "docs(blog): add PERIOD update post"
```
**Do not push and do not open the PR yourself.** The maintainer always pushes over SSH
(see the global and project instructions). Pushing on their behalf, including over HTTPS
with a token or by switching the remote, is not allowed.
## Step 7 — Done
Hand the maintainer the ready-to-run commands to push and open the draft PR, then stop:
```bash
git push -u origin blog/YYYY-MM-update
gh pr create --draft \
--title "Blog: PERIOD update post" \
--body "Automated draft from /blog-update skill. Review content before merging — deployment is automatic on merge to main."
--body "Update post covering recent changes. Review content before merging — deployment is automatic on merge to main."
```
If the `documentation` label exists on the repo, add `--label documentation`.
## Step 7 — Done
Report the PR URL. Do not merge, approve, or request review.
Do not merge, approve, or request review.
+1 -1
View File
@@ -86,7 +86,7 @@ body:
attributes:
label: AfterTouch version
description: Shown in the admin UI footer, or via the binary's `--version`.
placeholder: "v0.111.2"
placeholder: "v0.123.0"
validations:
required: false
+18
View File
@@ -0,0 +1,18 @@
# CodeQL configuration
# https://docs.github.com/en/code-security/code-scanning/creating-an-advanced-setup-for-code-scanning/customizing-your-advanced-setup-for-code-scanning
name: "JavaScript/TypeScript Security Analysis"
disable-default-queries: false
queries:
- uses: security-extended
- uses: security-and-quality
# Paths to exclude from analysis
paths-ignore:
- "**/node_modules/**"
# The minified es-module-shims distribution currently triggers findings in
# third-party code. Keep this exception file-specific so Preact, HTM, and
# future files under static/lib remain covered.
- "pkg/service/soundtouchweb/static/lib/es-module-shims.js"
+7
View File
@@ -125,6 +125,13 @@ updates:
allow:
- dependency-type: "all"
groups:
# Group all codeql-action sub-actions (init/analyze/upload-sarif)
# so they bump together. They are separate dependencies to
# Dependabot but must stay on the same version, or CodeQL fails
# with "Loaded a configuration file for version X, but running Y".
codeql-action:
patterns:
- "github/codeql-action*"
# Group actions from the same organization
actions-core:
patterns:
+57
View File
@@ -0,0 +1,57 @@
name: Browser Compatibility Tests
permissions:
contents: read
on:
push:
branches: [main]
pull_request:
branches: [main]
workflow_dispatch:
jobs:
test-browser:
name: Player browser tests
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- name: Set up Go
uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0
with:
go-version-file: "go.mod"
- name: Cache Go modules
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
with:
path: |
~/.cache/go-build
~/go/pkg/mod
key: ${{ runner.os }}-go-${{ hashFiles('**/go.mod') }}-${{ hashFiles('**/go.sum') }}
restore-keys: |
${{ runner.os }}-go-
- name: Download dependencies
run: go mod download
- name: Verify a Chrome/Chromium binary is available
# chromedp (used by the browsertest-tagged tests) discovers Chrome on
# PATH or in a standard install location; GitHub's ubuntu-latest
# runner image ships Google Chrome preinstalled. Fail fast here with a
# clear message instead of a cryptic chromedp allocator error if that
# image ever stops including it.
run: google-chrome --version
- name: Run browser-level player compatibility tests
run: make test-browser
- name: Set up Node
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
with:
node-version: '24'
- name: Run frontend unit tests
run: make test-frontend
+23 -23
View File
@@ -17,15 +17,15 @@ jobs:
steps:
- name: Checkout code
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- name: Set up Go
uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0
uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0
with:
go-version-file: "go.mod"
- name: Cache Go modules
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
with:
path: |
~/.cache/go-build
@@ -53,7 +53,7 @@ jobs:
run: make test-http-client
- name: Upload coverage to Codecov
uses: codecov/codecov-action@e79a6962e0d4c0c17b229090214935d2e33f8354 # v6.0.1
uses: codecov/codecov-action@fb8b3582c8e4def4969c97caa2f19720cb33a72f # v7.0.0
with:
file: ./coverage.out
flags: unittests
@@ -66,10 +66,10 @@ jobs:
steps:
- name: Checkout code
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- name: Set up Go
uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0
uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0
with:
go-version-file: "go.mod"
@@ -77,7 +77,7 @@ jobs:
run: sudo apt-get install -y libpcap-dev
- name: Run golangci-lint
uses: golangci/golangci-lint-action@82606bf257cbaff209d206a39f5134f0cfbfd2ee # v9.2.1
uses: golangci/golangci-lint-action@ba0d7d2ec06a0ea1cb5fa41b2e4a3ab91d21278a # v9.3.0
with:
version: latest
args: --timeout=5m
@@ -107,15 +107,15 @@ jobs:
steps:
- name: Checkout code
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- name: Set up Go
uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0
uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0
with:
go-version-file: "go.mod"
- name: Cache Go modules
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
with:
path: |
~/.cache/go-build
@@ -165,10 +165,10 @@ jobs:
steps:
- name: Checkout code
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- name: Set up Go
uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0
uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0
with:
go-version-file: "go.mod"
@@ -192,12 +192,12 @@ jobs:
steps:
- name: Checkout code
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- name: Check documentation links
run: |
npm install -g markdown-link-check
find . -name "*.md" -not -path "./tests/*" -not -path "./node_modules/*" -print0 | xargs -0 -n1 markdown-link-check -q -v -c .github/markdown-link-check.json
./scripts/check-doc-links.sh
- name: Warn on pending images
run: |
@@ -250,10 +250,10 @@ jobs:
steps:
- name: Checkout code
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- name: Set up Go
uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0
uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0
with:
go-version-file: "go.mod"
@@ -305,10 +305,10 @@ jobs:
steps:
- name: Checkout code
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@d7f5e7f509e45cec5c76c4d5afdd7de93d0b3df5 # v4.1.0
uses: docker/setup-buildx-action@37fe631027851001ddb9b187196cc803df7f5f0e # v4.3.0
- name: Set build date
id: build_date
@@ -330,7 +330,7 @@ jobs:
- name: Log in to GitHub Container Registry
if: steps.push-check.outputs.should-push == 'true'
uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4.2.0
uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0
with:
registry: ghcr.io
username: ${{ github.actor }}
@@ -338,7 +338,7 @@ jobs:
- name: Extract metadata (tags, labels) for soundtouch-service
id: meta-service
uses: docker/metadata-action@80c7e94dd9b9319bd5eb7a0e0fe9291e23a2a2e9 # v6.1.0
uses: docker/metadata-action@dc802804100637a589fabce1cb79ff13a1411302 # v6.2.0
with:
images: ghcr.io/${{ github.repository }}
tags: |
@@ -348,7 +348,7 @@ jobs:
type=ref,event=branch,prefix=preview-branch-,enable=${{ github.event_name == 'push' && github.ref != 'refs/heads/main' }}
- name: Build and push soundtouch-service Docker image
uses: docker/build-push-action@f9f3042f7e2789586610d6e8b85c8f03e5195baf # v7.2.0
uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7.3.0
with:
context: .
target: soundtouch-service
@@ -364,7 +364,7 @@ jobs:
- name: Extract metadata (tags, labels) for soundtouch-player
id: meta-player
uses: docker/metadata-action@80c7e94dd9b9319bd5eb7a0e0fe9291e23a2a2e9 # v6.1.0
uses: docker/metadata-action@dc802804100637a589fabce1cb79ff13a1411302 # v6.2.0
with:
images: ghcr.io/${{ github.repository }}-player
tags: |
@@ -374,7 +374,7 @@ jobs:
type=ref,event=branch,prefix=preview-branch-,enable=${{ github.event_name == 'push' && github.ref != 'refs/heads/main' }}
- name: Build and push soundtouch-player Docker image
uses: docker/build-push-action@f9f3042f7e2789586610d6e8b85c8f03e5195baf # v7.2.0
uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7.3.0
with:
context: .
target: soundtouch-player
+4 -4
View File
@@ -33,24 +33,24 @@ jobs:
steps:
- name: Checkout repository
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- name: Install libpcap (required for Go build)
if: matrix.language == 'go'
run: sudo apt-get install -y libpcap-dev
- name: Initialize CodeQL
uses: github/codeql-action/init@87557b9c84dde89fdd9b10e88954ac2f4248e463 # v4.36.1
uses: github/codeql-action/init@cdf488f595d80d6e07e03d4674febd5ab45fa938 # v4.37.9
with:
languages: ${{ matrix.language }}
build-mode: ${{ matrix.build-mode }}
config-file: ${{ matrix.language == 'go' && './.github/codeql-config.yml' || '' }}
config-file: ${{ matrix.language == 'go' && './.github/codeql-config.yml' || matrix.language == 'javascript-typescript' && './.github/codeql-config-js.yml' || '' }}
- name: Build Go (required for manual build-mode)
if: matrix.language == 'go'
run: go build ./...
- name: Perform CodeQL Analysis
uses: github/codeql-action/analyze@87557b9c84dde89fdd9b10e88954ac2f4248e463 # v4.36.1
uses: github/codeql-action/analyze@cdf488f595d80d6e07e03d4674febd5ab45fa938 # v4.37.9
with:
category: "/language:${{ matrix.language }}"
+1 -1
View File
@@ -20,7 +20,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- name: Setup Pages
id: pages
uses: actions/configure-pages@45bfe0192ca1faeb007ade9deae92b16b8254a0d # v6.0.0
+150 -204
View File
@@ -23,23 +23,27 @@ jobs:
name: Validate Release
runs-on: ubuntu-latest
outputs:
tag: ${{ steps.version.outputs.tag }}
version: ${{ steps.version.outputs.version }}
is_prerelease: ${{ steps.version.outputs.is_prerelease }}
steps:
- name: Checkout code
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
# Both triggers resolve to the same thing: the release tag. On a
# `release` event inputs.tag is empty, so this falls back to the
# published release's tag. Every other job checks out this same
# tag (via needs.validate.outputs.tag) so the build is always the
# tagged commit, never whatever branch the dispatch ran on (#525).
ref: ${{ github.event.inputs.tag || github.event.release.tag_name }}
fetch-depth: 0
- name: Validate tag format
id: version
run: |
if [[ "${{ github.event_name }}" == "workflow_dispatch" ]]; then
TAG_NAME="${{ github.event.inputs.tag }}"
else
TAG_NAME="${GITHUB_REF#refs/tags/}"
fi
# Single source of truth for the tag, regardless of trigger.
TAG_NAME="${{ github.event.inputs.tag || github.event.release.tag_name }}"
echo "Tag name: $TAG_NAME"
@@ -50,6 +54,15 @@ jobs:
exit 1
fi
# Confirm the tag actually exists in git. The dispatch path
# re-releases an existing tag; it never creates one from a branch.
if ! git rev-parse -q --verify "refs/tags/$TAG_NAME" >/dev/null; then
echo "❌ Tag $TAG_NAME does not exist in git. Push the tag first, then re-run."
exit 1
fi
echo "tag=$TAG_NAME" >> $GITHUB_OUTPUT
# Extract version without 'v' prefix
VERSION=${TAG_NAME#v}
echo "version=$VERSION" >> $GITHUB_OUTPUT
@@ -64,7 +77,7 @@ jobs:
fi
- name: Set up Go
uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0
uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0
with:
go-version-file: ${{ env.GO_VERSION_FILE }}
@@ -102,15 +115,17 @@ jobs:
steps:
- name: Checkout code
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
ref: ${{ needs.validate.outputs.tag }}
- name: Set up Go
uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0
uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0
with:
go-version-file: ${{ env.GO_VERSION_FILE }}
- name: Cache Go modules
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
with:
path: |
~/.cache/go-build
@@ -125,6 +140,11 @@ jobs:
CGO_ENABLED: 0
run: |
# Common variables
# Single build timestamp shared across every binary in this job.
BUILD_DATE="$(date -u +%Y-%m-%dT%H:%M:%SZ)"
# Commit of the checked-out tag (not GITHUB_SHA, which on a manual
# dispatch is the branch HEAD the run started from, not the tag).
COMMIT_SHA="$(git rev-parse HEAD)"
ARCH_SUFFIX="${{ matrix.goos }}-${{ matrix.goarch }}"
if [[ "${{ matrix.goarm }}" != "" ]]; then
ARCH_SUFFIX="${ARCH_SUFFIX}v${{ matrix.goarm }}"
@@ -150,9 +170,13 @@ jobs:
# Ensure clean build environment for this binary
rm -f "$OUTPUT_NAME" "$OUTPUT_NAME.sha256" "$OUTPUT_NAME.sha512"
# Inject the validated version (plus commit/date) so the binary
# reports the right version regardless of git checkout state.
# Relying on Go's VCS stamping alone yields v0.0.0-… when built
# from a shallow checkout or a non-tagged commit (see #525).
if ! go build \
-trimpath \
-ldflags="-s -w" \
-ldflags="-s -w -X main.version=${{ needs.validate.outputs.tag }} -X main.commit=${COMMIT_SHA} -X main.date=${BUILD_DATE}" \
-o "$OUTPUT_NAME" \
"$CMD_PATH"; then
echo "❌ Build failed for $BINARY_NAME"
@@ -173,10 +197,6 @@ jobs:
# Build Player (formerly soundtouch-web)
build_binary "soundtouch-player" "./cmd/soundtouch-player"
# Build Web: transitional alias of the player, built from the same
# source. Dropped in a future release; keep in sync with player.
build_binary "soundtouch-web" "./cmd/soundtouch-player"
# Build Backup
build_binary "soundtouch-backup" "./cmd/soundtouch-backup"
id: build
@@ -186,7 +206,6 @@ jobs:
CLI_NAME="${{ steps.build.outputs.soundtouch-cli }}"
SVC_NAME="${{ steps.build.outputs.soundtouch-service }}"
PLAYER_NAME="${{ steps.build.outputs.soundtouch-player }}"
WEB_NAME="${{ steps.build.outputs.soundtouch-web }}"
BCK_NAME="${{ steps.build.outputs.soundtouch-backup }}"
# Use atomic operations to avoid conflicts
@@ -204,7 +223,6 @@ jobs:
generate_checksums "$CLI_NAME"
generate_checksums "$SVC_NAME"
generate_checksums "$PLAYER_NAME"
generate_checksums "$WEB_NAME"
generate_checksums "$BCK_NAME"
# Cleanup
@@ -219,7 +237,6 @@ jobs:
build/soundtouch-cli-v*
build/soundtouch-service-v*
build/soundtouch-player-v*
build/soundtouch-web-v*
build/soundtouch-backup-v*
retention-days: 1
@@ -247,7 +264,7 @@ jobs:
mkdir -p release-files
# Move all files from subdirectories to the collection directory
find . -mindepth 2 -type f \( -name "soundtouch-cli-*" -o -name "soundtouch-service-*" -o -name "soundtouch-player-*" -o -name "soundtouch-web-*" -o -name "soundtouch-backup-*" \) -exec mv {} release-files/ \;
find . -mindepth 2 -type f \( -name "soundtouch-cli-*" -o -name "soundtouch-service-*" -o -name "soundtouch-player-*" -o -name "soundtouch-backup-*" \) -exec mv {} release-files/ \;
# Remove empty directories
find . -type d -empty -delete
@@ -262,14 +279,14 @@ jobs:
# Generate combined checksums (exclude individual .sha256/.sha512 files)
if ls soundtouch-* 1> /dev/null 2>&1; then
# Only checksum the actual binaries, not the .sha256/.sha512 files
ls soundtouch-cli-* soundtouch-service-* soundtouch-player-* soundtouch-web-* soundtouch-backup-* | grep -v '\.sha256$' | grep -v '\.sha512$' | xargs sha256sum > checksums.sha256
ls soundtouch-cli-* soundtouch-service-* soundtouch-player-* soundtouch-web-* soundtouch-backup-* | grep -v '\.sha256$' | grep -v '\.sha512$' | xargs sha512sum > checksums.sha512
ls soundtouch-cli-* soundtouch-service-* soundtouch-player-* soundtouch-backup-* | grep -v '\.sha256$' | grep -v '\.sha512$' | xargs sha256sum > checksums.sha256
ls soundtouch-cli-* soundtouch-service-* soundtouch-player-* soundtouch-backup-* | grep -v '\.sha256$' | grep -v '\.sha512$' | xargs sha512sum > checksums.sha512
echo "📋 Generated combined checksums:"
cat checksums.sha256
# Verify all expected files are present (binaries only, not checksum files)
EXPECTED_COUNT=35 # 7 platforms * 5 binaries (player + its web alias)
EXPECTED_COUNT=28 # 7 platforms * 4 binaries
ACTUAL_COUNT=$(ls soundtouch-* | grep -v '\.sha256$' | grep -v '\.sha512$' | wc -l)
if [[ $ACTUAL_COUNT -ne $EXPECTED_COUNT ]]; then
@@ -312,8 +329,9 @@ jobs:
steps:
- name: Checkout code
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
ref: ${{ needs.validate.outputs.tag }}
fetch-depth: 0
- name: Download release assets
@@ -325,172 +343,73 @@ jobs:
- name: Generate release notes
id: release_notes
run: |
if [[ "${{ github.event_name }}" == "workflow_dispatch" ]]; then
TAG_NAME="${{ github.event.inputs.tag }}"
else
TAG_NAME="${{ github.event.release.tag_name }}"
fi
TAG_NAME="${{ needs.validate.outputs.tag }}"
VERSION="${TAG_NAME#v}"
# Generate comprehensive release notes
# Real per-platform links for the two most-used tools, generated
# from the deterministic `<binary>-<tag>-<os>-<arch>[.exe]` asset
# naming convention (see scripts/release/quick-downloads.sh),
# instead of requiring a scroll through the flat, alphabetical
# Assets list. Inline checksum link per row (à la Helm's release
# notes) instead of sending people to the combined checksums file.
QUICK_DOWNLOADS="$(scripts/release/quick-downloads.sh "$TAG_NAME" "${{ github.repository }}")"
# Short, accurate header. GitHub's auto-generated "What's Changed"
# + "Full Changelog" are appended after this (generate_release_notes).
cat > release_notes.md << EOF
# Bose SoundTouch Go Library $TAG_NAME
# AfterTouch $TAG_NAME
A comprehensive Go library for controlling Bose SoundTouch speakers with 100% API coverage, real-time WebSocket events, and production-ready features.
**Bose SoundTouch Toolkit.** Keep your Bose SoundTouch speakers alive after the Bose cloud shutdown. No Bose infrastructure required.
## 🎯 Key Features
$QUICK_DOWNLOADS
- **100% API Coverage**: All 19 official endpoints + 6 useful extensions (25 total)
- **Real-time Events**: WebSocket support with auto-reconnect and comprehensive event handling
- **Multiroom Control**: Complete zone management and coordination
- **Production Ready**: Connection pooling, error handling, circuit breakers, monitoring
- **Excellent Documentation**: 4000+ lines including Getting Started, Cookbook, Troubleshooting, and Deployment guides
- **CLI Tool**: Full-featured command-line interface with all endpoints
## What's included
## 🚀 Quick Start
Pre-built binaries for Linux (amd64, arm64, armv7), macOS (Intel & Apple Silicon), Windows (amd64), and FreeBSD (amd64):
- **soundtouch-service** (see above)
- **soundtouch-cli** (see above)
- **soundtouch-player**: standalone LAN web UI for device control: play/pause, volume, presets, live status. (Formerly \`soundtouch-web\`.)
- **soundtouch-backup**: back up your Bose cloud account and each speaker's local state. \`soundtouch-backup all\` captures everything in one step.
Not sure which file to grab? The [Downloads page](https://gesellix.github.io/Bose-SoundTouch/docs/downloads/) explains which tool you need and which \`<os>-<arch>\` build matches your computer.
## Documentation
Full guides, setup walkthroughs, and troubleshooting: https://gesellix.github.io/Bose-SoundTouch/
## Use as a Go library
The core client is also importable:
\`\`\`bash
go get github.com/gesellix/bose-soundtouch@$TAG_NAME
\`\`\`
\`\`\`go
package main
## Verifying downloads
import (
"fmt"
"log"
"github.com/gesellix/bose-soundtouch/pkg/client"
)
func main() {
// Create client
c := client.New("192.0.2.100", 8090)
// Get device info
info, err := c.GetInfo()
if err != nil {
log.Fatal(err)
}
fmt.Printf("Device: %s\\n", info.Name)
}
\`\`\`
## 📚 Documentation
- [Getting Started Guide](docs/GETTING-STARTED.md) - 10-minute tutorial from discovery to WebSocket monitoring
- [API Cookbook](docs/API-COOKBOOK.md) - 1000+ lines of real-world patterns and examples
- [Troubleshooting Guide](docs/TROUBLESHOOTING.md) - Systematic issue resolution
- [Deployment Guide](docs/DEPLOYMENT.md) - Production deployment examples (Docker, K8s, systemd)
## 🔧 CLI & Service Tools
Download the tools for your platform from the assets below:
### CLI Tool
\`\`\`bash
# Quick device discovery
./soundtouch-cli -discover
\`\`\`
### SoundTouch Service
\`\`\`bash
# Start the service
./soundtouch-service
\`\`\`
### SoundTouch Player (formerly soundtouch-web)
\`\`\`bash
# Start the LAN web player
./soundtouch-player
\`\`\`
> Note: \`soundtouch-web\` has been renamed to \`soundtouch-player\`.
> The \`soundtouch-web\` assets are still published as a transitional
> alias and will be removed in a future release. Please switch your
> downloads and scripts to \`soundtouch-player\`.
### SoundTouch Backup
\`\`\`bash
# Back up cloud account and all paired speakers in one go
./soundtouch-backup all
\`\`\`
## 🧪 Tested Hardware
- Bose SoundTouch 10
- Bose SoundTouch 20
- All core functionality validated on real devices
## 📈 What's New in $TAG_NAME
$(git log --pretty=format:"- %s" $(git describe --tags --abbrev=0 HEAD^)..HEAD 2>/dev/null || echo "- Initial release with complete feature set")
## 🏗️ Supported Platforms
This release includes pre-built binaries for:
- Linux (amd64, arm64, armv7)
- macOS (Intel & Apple Silicon)
- Windows (amd64)
- FreeBSD (amd64)
`soundtouch-cli`, `soundtouch-service`, `soundtouch-player` (with `soundtouch-web` as a transitional alias), and `soundtouch-backup` are included.
## 🔐 Checksums
Multiple checksum options are provided for download verification:
### Combined Checksums (Recommended)
- \`checksums.sha256\` - SHA256 checksums for all binaries
- \`checksums.sha512\` - SHA512 checksums for all binaries
Each binary has its own \`.sha256\`/\`.sha512\`, and combined \`checksums.sha256\` / \`checksums.sha512\` cover all of them:
\`\`\`bash
# Download any binary + combined checksums
curl -L -O https://github.com/.../soundtouch-cli-v$TAG_NAME-linux-amd64
curl -L -O https://github.com/.../checksums.sha256
# Verify your specific download
sha256sum -c checksums.sha256 --ignore-missing
\`\`\`
### Individual Checksums (Per Binary)
Each binary also has its own dedicated checksum files:
- \`soundtouch-cli-v$TAG_NAME-platform.sha256\`
- \`soundtouch-cli-v$TAG_NAME-platform.sha512\`
\`\`\`bash
# Download binary + its individual checksum
curl -L -O https://github.com/.../soundtouch-cli-v$TAG_NAME-linux-amd64
curl -L -O https://github.com/.../soundtouch-cli-v$TAG_NAME-linux-amd64.sha256
# Verify with individual checksum
sha256sum -c soundtouch-cli-v$TAG_NAME-linux-amd64.sha256
\`\`\`
## 🤝 Contributing
Contributions welcome! See our documentation for examples and patterns.
## 📄 License
MIT License - see [LICENSE](LICENSE) file.
EOF
echo "release_notes_file=release_notes.md" >> $GITHUB_OUTPUT
- name: Create GitHub Release
uses: softprops/action-gh-release@b4309332981a82ec1c5618f44dd2e27cc8bfbfda # v3.0.0
uses: softprops/action-gh-release@efb35369e0ad2afab669f228072c1b0d510eae64 # v3.0.3
with:
tag_name: ${{ github.event.inputs.tag }}
name: "Bose SoundTouch Go Library ${{ github.event.inputs.tag }}"
tag_name: ${{ needs.validate.outputs.tag }}
name: ${{ needs.validate.outputs.tag }}
body_path: ${{ steps.release_notes.outputs.release_notes_file }}
generate_release_notes: true
draft: false
prerelease: ${{ needs.validate.outputs.is_prerelease == 'true' }}
files: |
release-assets/soundtouch-cli-v*
release-assets/soundtouch-service-v*
release-assets/soundtouch-player-v*
release-assets/soundtouch-web-v*
release-assets/soundtouch-backup-v*
release-assets/checksums.sha256
release-assets/checksums.sha512
@@ -505,21 +424,70 @@ jobs:
if: github.event_name == 'release' && github.event.action == 'published'
steps:
- name: Checkout code
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
ref: ${{ needs.validate.outputs.tag }}
- name: Download release assets
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
with:
name: release-assets
path: ./release-assets
- name: Upgrade the Downloads footer with direct per-platform links
# This is the path real releases take: a maintainer hand-writes
# "Noteworthy" notes and publishes via the GitHub web UI, which
# fires this job, not create_release (workflow_dispatch only).
# _/releases/_TEMPLATE.md's convention is a trailing footer line:
# ---
# 📦 **Downloads / installation:** <downloads page URL>
# Drop that line (if present) and append the quick-downloads
# block in its place. Always goes through the same append path
# (strip block + strip footer + append), whether or not a
# footer line is still there, so re-runs stay byte-for-byte
# idempotent instead of drifting on the 2nd run.
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
TAG_NAME="${{ needs.validate.outputs.tag }}"
scripts/release/quick-downloads.sh "$TAG_NAME" "${{ github.repository }}" > quick_downloads.md
gh release view "$TAG_NAME" --json body -q .body > existing_body.md
python3 - << 'PYEOF'
import re
with open("existing_body.md") as f:
body = f.read()
with open("quick_downloads.md") as f:
block = f.read().rstrip("\n")
# Drop a block this automation inserted on a previous run.
body = re.sub(r"\n*<!-- quick-downloads:start -->.*?<!-- quick-downloads:end -->\n*", "\n", body, flags=re.DOTALL)
# Drop the hand-authored footer line (first run only) so both
# cases converge on the same append below and re-runs stay
# byte-for-byte idempotent.
footer = re.compile(r"^📦 \*\*Downloads / installation:\*\*.*\n?", re.MULTILINE)
body = footer.sub("", body, count=1)
body = body.rstrip("\n") + "\n\n" + block + "\n"
with open("combined_notes.md", "w") as f:
f.write(body)
PYEOF
gh release edit "$TAG_NAME" --notes-file combined_notes.md
- name: Upload additional assets to existing release
uses: softprops/action-gh-release@b4309332981a82ec1c5618f44dd2e27cc8bfbfda # v3.0.0
uses: softprops/action-gh-release@efb35369e0ad2afab669f228072c1b0d510eae64 # v3.0.3
with:
tag_name: ${{ github.event.release.tag_name }}
tag_name: ${{ needs.validate.outputs.tag }}
files: |
release-assets/soundtouch-cli-v*
release-assets/soundtouch-service-v*
release-assets/soundtouch-player-v*
release-assets/soundtouch-web-v*
release-assets/soundtouch-backup-v*
release-assets/checksums.sha256
release-assets/checksums.sha512
@@ -534,17 +502,22 @@ jobs:
steps:
- name: Checkout code
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
ref: ${{ needs.validate.outputs.tag }}
- name: Set build date
- name: Set build metadata
id: build_date
run: echo "date=$(date -u +%Y-%m-%d)" >> $GITHUB_OUTPUT
run: |
echo "date=$(date -u +%Y-%m-%d)" >> $GITHUB_OUTPUT
# Commit of the checked-out tag, not github.sha (the dispatch HEAD).
echo "commit=$(git rev-parse HEAD)" >> $GITHUB_OUTPUT
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@d7f5e7f509e45cec5c76c4d5afdd7de93d0b3df5 # v4.1.0
uses: docker/setup-buildx-action@37fe631027851001ddb9b187196cc803df7f5f0e # v4.3.0
- name: Log in to GitHub Container Registry
uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4.2.0
uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0
with:
registry: ghcr.io
username: ${{ github.actor }}
@@ -552,7 +525,7 @@ jobs:
- name: Extract metadata (tags, labels) for soundtouch-service
id: meta-service
uses: docker/metadata-action@80c7e94dd9b9319bd5eb7a0e0fe9291e23a2a2e9 # v6.1.0
uses: docker/metadata-action@dc802804100637a589fabce1cb79ff13a1411302 # v6.2.0
with:
images: ghcr.io/${{ github.repository }}
tags: |
@@ -561,7 +534,7 @@ jobs:
type=raw,value=latest,enable=${{ needs.validate.outputs.is_prerelease == 'false' }}
- name: Build and push soundtouch-service Docker image
uses: docker/build-push-action@f9f3042f7e2789586610d6e8b85c8f03e5195baf # v7.2.0
uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7.3.0
with:
context: .
target: soundtouch-service
@@ -570,15 +543,15 @@ jobs:
tags: ${{ steps.meta-service.outputs.tags }}
labels: ${{ steps.meta-service.outputs.labels }}
build-args: |
VERSION=v${{ needs.validate.outputs.version }}
COMMIT=${{ github.sha }}
VERSION=${{ needs.validate.outputs.tag }}
COMMIT=${{ steps.build_date.outputs.commit }}
DATE=${{ steps.build_date.outputs.date }}
cache-from: type=gha
cache-to: type=gha,mode=max
- name: Extract metadata (tags, labels) for soundtouch-player
id: meta-player
uses: docker/metadata-action@80c7e94dd9b9319bd5eb7a0e0fe9291e23a2a2e9 # v6.1.0
uses: docker/metadata-action@dc802804100637a589fabce1cb79ff13a1411302 # v6.2.0
with:
images: ghcr.io/${{ github.repository }}-player
tags: |
@@ -587,7 +560,7 @@ jobs:
type=raw,value=latest,enable=${{ needs.validate.outputs.is_prerelease == 'false' }}
- name: Build and push soundtouch-player Docker image
uses: docker/build-push-action@f9f3042f7e2789586610d6e8b85c8f03e5195baf # v7.2.0
uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7.3.0
with:
context: .
target: soundtouch-player
@@ -596,35 +569,8 @@ jobs:
tags: ${{ steps.meta-player.outputs.tags }}
labels: ${{ steps.meta-player.outputs.labels }}
build-args: |
VERSION=v${{ needs.validate.outputs.version }}
COMMIT=${{ github.sha }}
DATE=${{ steps.build_date.outputs.date }}
cache-from: type=gha
cache-to: type=gha,mode=max
# Transitional alias image (formerly the only web image). Dropped later.
- name: Extract metadata (tags, labels) for soundtouch-web
id: meta-web
uses: docker/metadata-action@80c7e94dd9b9319bd5eb7a0e0fe9291e23a2a2e9 # v6.1.0
with:
images: ghcr.io/${{ github.repository }}-web
tags: |
type=semver,pattern={{version}},value=v${{ needs.validate.outputs.version }}
type=semver,pattern={{major}}.{{minor}},value=v${{ needs.validate.outputs.version }}
type=raw,value=latest,enable=${{ needs.validate.outputs.is_prerelease == 'false' }}
- name: Build and push soundtouch-web Docker image
uses: docker/build-push-action@f9f3042f7e2789586610d6e8b85c8f03e5195baf # v7.2.0
with:
context: .
target: soundtouch-web
platforms: linux/amd64,linux/arm64,linux/arm64/v8,linux/arm/v7
push: true
tags: ${{ steps.meta-web.outputs.tags }}
labels: ${{ steps.meta-web.outputs.labels }}
build-args: |
VERSION=v${{ needs.validate.outputs.version }}
COMMIT=${{ github.sha }}
VERSION=${{ needs.validate.outputs.tag }}
COMMIT=${{ steps.build_date.outputs.commit }}
DATE=${{ steps.build_date.outputs.date }}
cache-from: type=gha
cache-to: type=gha,mode=max
@@ -639,12 +585,12 @@ jobs:
- name: Notify success
run: |
echo "🎉 Release ${{ needs.validate.outputs.version }} completed successfully!"
echo "📦 Binaries built for 7 platforms (CLI, Service, Web, and Backup)"
echo "📦 Binaries built for 7 platforms (CLI, Service, Player, and Backup)"
echo "🐳 Docker image published to ghcr.io"
echo "🔐 Checksums generated and verified"
echo "📋 Release notes automatically generated"
echo ""
TAG_NAME="${{ github.event.inputs.tag || github.event.release.tag_name }}"
TAG_NAME="${{ needs.validate.outputs.tag }}"
echo "🔗 Release URL: https://github.com/${{ github.repository }}/releases/tag/${TAG_NAME}"
echo ""
echo "Next steps:"
+6 -6
View File
@@ -19,10 +19,10 @@ jobs:
steps:
- name: Checkout code
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- name: Set up Go
uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0
uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0
with:
go-version-file: "go.mod"
@@ -46,10 +46,10 @@ jobs:
steps:
- name: Checkout code
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- name: Set up Go
uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0
uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0
with:
go-version-file: "go.mod"
@@ -78,7 +78,7 @@ jobs:
- name: Upload Semgrep SARIF results
if: always()
uses: github/codeql-action/upload-sarif@87557b9c84dde89fdd9b10e88954ac2f4248e463 # v4.36.1
uses: github/codeql-action/upload-sarif@cdf488f595d80d6e07e03d4674febd5ab45fa938 # v4.37.9
with:
sarif_file: semgrep.sarif
continue-on-error: true
@@ -92,7 +92,7 @@ jobs:
steps:
- name: Checkout code
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- name: Dependency Review
uses: actions/dependency-review-action@a1d282b36b6f3519aa1f3fc636f609c47dddb294 # v5.0.0
+2 -2
View File
@@ -16,13 +16,13 @@ jobs:
if: github.actor == 'dependabot[bot]' || github.event_name == 'workflow_dispatch'
steps:
- name: Checkout code
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
ref: ${{ github.head_ref }}
token: ${{ secrets.GITHUB_TOKEN }}
- name: Setup Node.js
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
with:
node-version: '24'
cache: 'npm'
+1
View File
@@ -21,6 +21,7 @@ dist/
/example-mdns
/example-upnp
/example-unified
/example-dlna-server
/mdns-scanner
/websocket-demo
/main
+1 -1
View File
@@ -1,5 +1,5 @@
# golangci-lint configuration for Bose SoundTouch Go Library
# Compatible with golangci-lint v2.8.0
# Compatible with golangci-lint v2.13.1
# See: https://golangci-lint.run/usage/configuration/
version: "2"
+29 -1
View File
@@ -43,7 +43,7 @@ Per-session pickup notes live in two local files at the repo root (they are `.gi
make build # All binaries
make build-cli # Just CLI
make build-service # Just service
make build-web # Just web UI
make build-player # Just web player
make build-all # Cross-platform builds (Linux, macOS, Windows)
make install # Install to $GOPATH/bin
@@ -99,6 +99,34 @@ The rotate target is non-destructive (it moves, never deletes) and
opt-in (no other target invokes it). Old archives stay around for
retrospective diffing whenever something goes sideways.
## Decrypting diagnostic reports
Reporters attach an encrypted diagnostic archive
(`aftertouch-diagnostic-*.age`), usually saved under `_/i_<reporter>/`.
Decrypt it with **this repo's own tool**, not the generic `age` CLI:
```bash
go run scripts/decrypt-diagnostic.go <file.age> | tar xz -C <dir containing the .age>
```
- The tool is `scripts/decrypt-diagnostic.go`; the private key lives at
`keys/private/diagnostic` (provisioned by `scripts/setup-diagnostic-key.sh`,
and never committed). It writes the decrypted `.tar.gz` to stdout.
- Always decrypt/unpack next to the `.age` (not a scratch/tmp dir), into a
**per-file subfolder** so nothing collides: e.g.
`mkdir -p <dir>/extracted-<timestamp> && go run scripts/decrypt-diagnostic.go <dir>/<file>.age | tar xz -C <dir>/extracted-<timestamp>`.
This matters when a reporter folder holds multiple `.age` snapshots or
already has other files: every archive uses the same inner names
(`diagnostic.json`, `datastore/`, `http/`, ...), so extracting two into the
same dir overwrites and mixes them.
- The archive contains `diagnostic.json` (health/device summary), `datastore/`
(raw speaker XML: DeviceInfo/Presets/Recents/Sources), `http/` (service
`full.xml`, `sourceproviders.xml`, captured speaker responses), `logs/`,
`settings.json`, `env.txt`, `system/`, `ssh/`. See
`docs/content/docs/appendix/DIAGNOSTIC-EXPORT.md`.
- Reporter data stays under `_/` and is never committed (see "What never goes
into this repo").
## Project structure
```
+3 -22
View File
@@ -1,5 +1,5 @@
# Build stage
FROM --platform=$BUILDPLATFORM golang:1.26.4-alpine AS builder
FROM --platform=$BUILDPLATFORM golang:1.27.1-alpine AS builder
# Declare automatic platform ARGs to make them available in build stage
# See https://docs.docker.com/reference/dockerfile#automatic-platform-args-in-the-global-scope
@@ -46,7 +46,7 @@ RUN if [ "${TARGETARCH}" = "arm" ] && [ -n "${TARGETVARIANT}" ]; then \
fi
# soundtouch-service image
FROM alpine:3.23 AS soundtouch-service
FROM alpine:3.24 AS soundtouch-service
RUN apk add --no-cache ca-certificates tzdata
@@ -95,7 +95,7 @@ EXPOSE 8000
ENTRYPOINT ["/app/soundtouch-service"]
# soundtouch-player image
FROM alpine:3.23 AS soundtouch-player
FROM alpine:3.24 AS soundtouch-player
RUN apk add --no-cache ca-certificates tzdata
@@ -112,22 +112,3 @@ EXPOSE 8080
USER nobody
ENTRYPOINT ["/app/soundtouch-player"]
# soundtouch-web image: transitional alias of soundtouch-player. Built from the
# same binary; the entrypoint name makes the binary print a rename notice on
# start. Will be dropped in a future release.
FROM alpine:3.23 AS soundtouch-web
RUN apk add --no-cache ca-certificates tzdata
WORKDIR /app
COPY --from=builder /soundtouch-player /app/soundtouch-web
ENV PORT=8080
EXPOSE 8080
USER nobody
ENTRYPOINT ["/app/soundtouch-web"]
+34 -21
View File
@@ -1,4 +1,4 @@
.PHONY: all build build-cli test test-coverage test-http-client test-http-client-rotate check fmt vet lint clean dev help screenshots build-stockholm-image prepare-stockholm update-static-deps dev-docs dev-docs-tidy hugo
.PHONY: all build build-cli test test-coverage test-browser test-frontend test-http-client test-http-client-rotate check fmt vet lint clean dev help screenshots build-stockholm-image prepare-stockholm update-static-deps dev-docs dev-docs-tidy hugo
# Load .env if present (simple KEY=VALUE format, no shell quoting)
-include .env
@@ -19,9 +19,6 @@ SERVICE_NAME=soundtouch-service
SERVICE_PATH=./cmd/$(SERVICE_NAME)
PLAYER_NAME=soundtouch-player
PLAYER_PATH=./cmd/$(PLAYER_NAME)
# WEB_NAME is the previous name for the player, kept as a transitional alias
# built from the same PLAYER_PATH source. It will be dropped in a future release.
WEB_NAME=soundtouch-web
EXAMPLE_MDNS_NAME=example-mdns
EXAMPLE_MDNS_PATH=./cmd/$(EXAMPLE_MDNS_NAME)
EXAMPLE_UPNP_NAME=example-upnp
@@ -55,7 +52,7 @@ AUTH_SERVICE_URL ?= $(BACKEND_URL)
all: check build
build: build-cli build-service build-player build-web build-examples build-favicon-gen build-backup
build: build-cli build-service build-player build-examples build-favicon-gen build-backup
build-cli:
@echo "Building $(BINARY_NAME)..."
@@ -72,13 +69,6 @@ build-player:
@mkdir -p $(BUILD_DIR)
$(GOBUILD) $(BUILDFLAGS) -o $(BUILD_DIR)/$(PLAYER_NAME) $(PLAYER_PATH)
# Transitional alias: builds the same source as build-player under the old
# soundtouch-web name. Drop this target once the alias is retired.
build-web:
@echo "Building $(WEB_NAME) (transitional alias of $(PLAYER_NAME))..."
@mkdir -p $(BUILD_DIR)
$(GOBUILD) $(BUILDFLAGS) -o $(BUILD_DIR)/$(WEB_NAME) $(PLAYER_PATH)
build-examples:
@echo "Building $(EXAMPLE_MDNS_NAME)..."
@mkdir -p $(BUILD_DIR)
@@ -157,6 +147,23 @@ test-coverage:
$(GOCMD) tool cover -html=coverage.out -o coverage.html
@echo "Coverage report generated: coverage.html"
# Browser-level regression tests for the embedded player's static assets
# (see pkg/service/soundtouchweb/browser_compatibility_test.go). Opt-in via
# the "browsertest" build tag, not part of `test`/`check`, since they need a
# Chrome/Chromium binary that chromedp can find on PATH or in a standard
# install location.
test-browser:
@echo "Running browser-level compatibility tests..."
$(GOTEST) -tags browsertest -v ./pkg/service/soundtouchweb/...
# Unit tests for the embedded player's static JS modules (see
# pkg/service/soundtouchweb/frontend_test/), run via Node's built-in test
# runner. Not part of `test`/`check`, since they need a Node binary matching
# package.json's engines field, same reasoning as test-browser needing Chrome.
test-frontend:
@echo "Running frontend unit tests..."
node --test pkg/service/soundtouchweb/frontend_test/*.test.mjs
check: fmt vet test test-http-client
# Archive any existing tests/integration/testdata/ to a timestamped sibling
@@ -175,9 +182,16 @@ test-http-client-rotate:
test-http-client:
@echo "Starting services with docker compose (waiting for healthchecks)..."
@docker compose -f docker-compose.yml -f docker-compose.ci.yml up -d --build --wait
@echo "Running .http tests..."
@docker run --rm --network soundtouch-test-net \
@docker compose -f docker-compose.yml -f docker-compose.ci.yml up -d --build --wait; \
UP_EXIT_CODE=$$?; \
if [ $$UP_EXIT_CODE -ne 0 ]; then \
echo "docker compose up failed (exit $$UP_EXIT_CODE); dumping container logs:"; \
docker compose -f docker-compose.yml -f docker-compose.ci.yml logs; \
docker compose -f docker-compose.yml -f docker-compose.ci.yml down; \
exit $$UP_EXIT_CODE; \
fi; \
echo "Running .http tests..."; \
docker run --rm --network soundtouch-test-net \
-v "$(PWD)/tests/integration/http-client:/workdir" \
jetbrains/intellij-http-client:2026.1 \
--env-file /workdir/http-client.env.json \
@@ -237,9 +251,7 @@ test-http-client:
/workdir/unregister_device.http \
--report; \
EXIT_CODE=$$?; \
docker compose -f docker-compose.yml -f docker-compose.ci.yml logs soundtouch-service; \
docker compose -f docker-compose.yml -f docker-compose.ci.yml logs spotify-mock; \
docker compose -f docker-compose.yml -f docker-compose.ci.yml logs amazon-mock; \
docker compose -f docker-compose.yml -f docker-compose.ci.yml logs; \
docker compose -f docker-compose.yml -f docker-compose.ci.yml down; \
exit $$EXIT_CODE
@@ -253,7 +265,7 @@ vet:
lint:
@echo "Running golangci-lint..."
@which golangci-lint > /dev/null || (echo "golangci-lint not found. Install with: go install github.com/golangci/golangci-lint/cmd/golangci-lint@latest" && exit 1)
@which golangci-lint > /dev/null || (echo "golangci-lint not found. Install with: go install github.com/golangci/golangci-lint/v2/cmd/golangci-lint@latest" && exit 1)
golangci-lint run
tidy:
@@ -376,12 +388,11 @@ dev-player-host: build-player
fi
cd cmd/soundtouch-player && ../../$(BUILD_DIR)/$(PLAYER_NAME) -host $(HOST)
install: build-cli build-service build-player build-web build-backup
install: build-cli build-service build-player build-backup
@echo "Installing binaries to $(GOPATH)/bin..."
cp $(BUILD_DIR)/$(BINARY_NAME) $(GOPATH)/bin/
cp $(BUILD_DIR)/$(SERVICE_NAME) $(GOPATH)/bin/
cp $(BUILD_DIR)/$(PLAYER_NAME) $(GOPATH)/bin/
cp $(BUILD_DIR)/$(WEB_NAME) $(GOPATH)/bin/
cp $(BUILD_DIR)/$(BACKUP_NAME) $(GOPATH)/bin/
update-static-deps:
@@ -512,6 +523,8 @@ help:
@echo " build-linux-armv7 - Build for Linux ARMv7 (kernel 3.14+ compatible, CGO_ENABLED=0)"
@echo " test - Run tests"
@echo " test-coverage - Run tests with coverage report"
@echo " test-browser - Run browser-level (chromedp) player compatibility tests"
@echo " test-frontend - Run player static JS unit tests (Node's test runner)"
@echo " test-http-client - Run .http integration tests via Docker Compose"
@echo " test-http-client-rotate - Archive tests/integration/testdata/ before a fresh run (non-destructive)"
@echo " check - Run fmt, vet, and tests"
+4 -4
View File
@@ -2,7 +2,6 @@
<p style="margin-top: -10px; font-style: italic; color: #666;">Bose SoundTouch Toolkit</p>
[![Go Reference](https://pkg.go.dev/badge/github.com/gesellix/bose-soundtouch.svg)](https://pkg.go.dev/github.com/gesellix/bose-soundtouch)
[![Go Report Card](https://goreportcard.com/badge/github.com/gesellix/bose-soundtouch)](https://goreportcard.com/report/github.com/gesellix/bose-soundtouch)
[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT)
> Independent project. **Not affiliated with, endorsed by, sponsored
@@ -13,7 +12,7 @@
Bose shut down SoundTouch cloud services on **May 6, 2026**. Presets, music service browsing, and stereo pairing no longer work through Bose's infrastructure. AfterTouch restores all of these — no Bose infrastructure required.
See the [Survival Guide](https://gesellix.github.io/Bose-SoundTouch/docs/guides/SURVIVAL-GUIDE/) for the full picture.
See the [Survival Guide](https://gesellix.github.io/Bose-SoundTouch/docs/guides/SURVIVAL-GUIDE/) for the full picture, or jump straight to [Downloads](https://gesellix.github.io/Bose-SoundTouch/docs/downloads/) to get the tools.
[![AfterTouch docs homepage](media/docs-homepage.png)](https://gesellix.github.io/Bose-SoundTouch/)
@@ -66,13 +65,13 @@ See the [soundtouch-backup README](cmd/soundtouch-backup/README.md) for usage.
Command-line control of any SoundTouch device: play/pause/volume, presets, source selection, multiroom zones, device discovery, and more. Works entirely over the local network — no cloud dependency. Well-suited for scripting and home automation.
See the [CLI Reference](https://gesellix.github.io/Bose-SoundTouch/docs/guides/CLI-REFERENCE/) for full usage.
See the [CLI Reference](https://gesellix.github.io/Bose-SoundTouch/docs/guides/CLI-REFERENCE/) for full usage, and the [Downloads page](https://gesellix.github.io/Bose-SoundTouch/docs/downloads/) to get the `soundtouch-cli` build for your OS.
---
### soundtouch-player
> Formerly `soundtouch-web`. The `soundtouch-web` binary, Docker image, and install script are still published as a transitional alias and will be removed in a future release; please switch to `soundtouch-player`.
> Formerly `soundtouch-web`. The `soundtouch-web` binary, Docker image, and install script are no longer published; please use `soundtouch-player`. (If you still run the binary under its old name, it prints a rename notice and works as before.)
A standalone, LAN-resident web UI for device control — play, pause, volume, preset selection, real-time status — served from a local Go binary. Because it reaches speakers directly on your network and can delegate cloud-only features (e.g. TTS) to a remote AfterTouch service via `--service-url`, it stays useful when `soundtouch-service` runs off-LAN (for example in the cloud), where the embedded `/app` player cannot reach your speakers.
@@ -113,6 +112,7 @@ See the [API Reference](https://gesellix.github.io/Bose-SoundTouch/docs/referenc
- **[SoundTouch Plus](https://github.com/thlucas1/homeassistantcomponent_soundtouchplus)** (Todd Lucas) — Home Assistant integration; extensive undocumented API documentation
- **[ÜberBöse API](https://github.com/julius-d/ueberboese-api)** (Julius) — API research and advanced endpoint discovery
- **[Bose SoundTouch Hook](https://github.com/CodeFinder2/bose-soundtouch-hook)** (Adrian Böckenkamp) — `LD_PRELOAD` hooking for reverse engineering device internals
- **[STR, SoundTouch Reborn](https://github.com/JRpersonal/streborn)** ([st-reborn.de](https://st-reborn.de)) — on-device agent plus desktop app; its published `iptables` REDIRECT technique is what makes AfterTouch's on-device install reachable over the LAN on co-processor chassis (see [Model Support Matrix](https://gesellix.github.io/Bose-SoundTouch/docs/reference/MODEL-SUPPORT-MATRIX/))
---
+13
View File
@@ -0,0 +1,13 @@
package main
import "strings"
// sanitizeLog strips newline characters from s to prevent log-injection
// (CodeQL go/log-injection). Values from HTTP requests may contain
// attacker-controlled newlines.
func sanitizeLog(s string) string {
s = strings.ReplaceAll(s, "\n", `\n`)
s = strings.ReplaceAll(s, "\r", `\r`)
return s
}
+741
View File
@@ -0,0 +1,741 @@
// Package main runs a LAN-visible DLNA / UPnP MediaServer backed by the
// dlnatest in-memory content tree.
//
// Usage:
//
// example-dlna-server [--port 8200] [--name "My Library"]
//
// The server:
// - Binds an HTTP server on 0.0.0.0:<port> (default 8200).
// - Detects the host's primary LAN IPv4 to build the SSDP LOCATION header
// and the absolute <res> URLs inside DIDL-Lite Browse responses.
// - Joins the SSDP multicast group 239.255.255.250:1900 and answers
// M-SEARCH requests whose ST matches upnp:rootdevice, ssdp:all, or
// urn:schemas-upnp-org:device:MediaServer:1.
// - Periodically sends ssdp:alive NOTIFY announcements.
// - Sends ssdp:byebye on graceful shutdown (SIGINT / SIGTERM).
package main
import (
"bytes"
"context"
"flag"
"fmt"
"io"
"io/fs"
"log/slog"
"net"
"net/http"
"os"
"os/signal"
"path/filepath"
"strconv"
"strings"
"syscall"
"time"
"github.com/gesellix/bose-soundtouch/pkg/dlna/dlnatest"
)
const (
ssdpMulticastAddr = "239.255.255.250:1900"
ssdpMulticastIP = "239.255.255.250"
ssdpPort = 1900
mediaServerURN = "urn:schemas-upnp-org:device:MediaServer:1"
contentDirURN = "urn:schemas-upnp-org:service:ContentDirectory:1"
notifyInterval = 30 * time.Second
ssdpMaxAge = 1800
serverVersion = "AfterTouch/1.0 UPnP/1.0 AfterTouchDLNA/1.0"
)
func main() {
port := flag.Int("port", 8200, "HTTP port to bind")
name := flag.String("name", "AfterTouch Test Library", "UPnP friendlyName advertised over SSDP")
mediaDir := flag.String("media-dir", "", "serve real audio files + artwork from this directory "+
"(searched recursively, so an artist/album tree works) instead of the built-in silent test "+
"tracks. Audio: .mp3/.wav/.flac/.m4a/.ogg. Art per track: a sibling <name>.jpg/.png, else a "+
"cover.jpg/cover.png/folder.jpg in the same album folder. Files are loaded into memory, so "+
"point it at an album or a modest folder, not your whole library")
flag.Parse()
logger := slog.New(slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{Level: slog.LevelDebug}))
lanIP, err := primaryLANIP()
if err != nil {
logger.Warn("could not detect LAN IP, falling back to loopback", "err", err)
lanIP = "127.0.0.1"
}
addr := fmt.Sprintf("0.0.0.0:%d", *port)
location := fmt.Sprintf("http://%s:%d/rootDesc.xml", lanIP, *port)
// Join the SSDP multicast group on the interface that owns the LAN IP. On
// macOS net.Interfaces() lists lo0 (UP+MULTICAST) first, so picking the
// "first" multicast interface would join on loopback and never receive the
// LAN M-SEARCH from clients like AfterTouch.
lanIface := interfaceForIP(lanIP)
if lanIface != nil {
logger.Info("SSDP: will join multicast on LAN interface", "iface", lanIface.Name, "ip", lanIP)
}
opts := []dlnatest.Option{dlnatest.WithFriendlyName(*name)}
if *mediaDir != "" {
tree, n, err := loadTreeFromDir(*mediaDir, *name)
if err != nil {
logger.Error("failed to load --media-dir", "dir", *mediaDir, "err", err)
os.Exit(1)
}
opts = append(opts, dlnatest.WithTree(tree))
logger.Info("serving real media from directory", "dir", *mediaDir, "tracks", n)
}
srv := dlnatest.NewServer(opts...)
httpSrv := &http.Server{
Addr: addr,
Handler: withAccessLog(logger, srv.HTTPHandler()),
}
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
defer stop()
// Start HTTP server.
go func() {
logger.Info("HTTP server starting", "addr", addr, "lanIP", lanIP, "location", location)
if err := httpSrv.ListenAndServe(); err != nil && err != http.ErrServerClosed {
logger.Error("HTTP server error", "err", err)
}
}()
// Give the HTTP listener a moment to bind before we advertise it.
time.Sleep(50 * time.Millisecond)
udn := srv.UDN
// Start SSDP listener + responder.
go runSSDPListener(ctx, logger, udn, location, lanIface)
// Start periodic ssdp:alive announcements.
go runSSDPAlive(ctx, logger, udn, location)
logger.Info("DLNA MediaServer ready", "location", location, "name", *name)
// Wait for shutdown signal.
<-ctx.Done()
logger.Info("shutting down...")
// Send byebye before exiting.
sendByebye(logger, udn)
shutCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
if err := httpSrv.Shutdown(shutCtx); err != nil {
logger.Error("HTTP shutdown error", "err", err)
}
logger.Info("stopped")
}
// ----------------------------------------------------------------------------
// SSDP listener: answers M-SEARCH requests
// ----------------------------------------------------------------------------
func runSSDPListener(ctx context.Context, logger *slog.Logger, udn, location string, ifi *net.Interface) {
group := &net.UDPAddr{IP: net.ParseIP(ssdpMulticastIP), Port: ssdpPort}
// Join the group on the LAN interface. If we could not resolve it, fall back
// to the first non-loopback multicast interface (never loopback, which would
// only ever receive same-host loopback traffic).
if ifi == nil {
if cands, err := multicastInterfaces(); err == nil {
for _, c := range cands {
if c != nil && c.Flags&net.FlagLoopback == 0 {
ifi = c
break
}
}
}
}
conn, err := net.ListenMulticastUDP("udp4", ifi, group)
if err != nil {
logger.Warn("SSDP: ListenMulticastUDP failed (try running as root or check firewall)", "err", err)
return
}
defer conn.Close()
logger.Info("SSDP: listening for M-SEARCH on multicast", "group", group.String())
buf := make([]byte, 2048)
for {
select {
case <-ctx.Done():
return
default:
}
_ = conn.SetReadDeadline(time.Now().Add(500 * time.Millisecond))
n, src, err := conn.ReadFromUDP(buf)
if err != nil {
// Deadline timeout is expected; just continue.
continue
}
msg := string(buf[:n])
if !strings.HasPrefix(msg, "M-SEARCH") {
continue
}
st := extractHeader(msg, "ST")
logger.Debug("SSDP: M-SEARCH received", "from", src, "ST", st)
if !stMatches(st) {
continue
}
logger.Info("SSDP: answering M-SEARCH", "from", src, "ST", st)
reply := buildMSearchReply(udn, location, st)
_, _ = conn.WriteToUDP([]byte(reply), src)
}
}
// multicastInterfaces returns all UP interfaces that support multicast.
func multicastInterfaces() ([]*net.Interface, error) {
all, err := net.Interfaces()
if err != nil {
return nil, err
}
var result []*net.Interface
for i := range all {
iface := &all[i]
if iface.Flags&net.FlagUp == 0 || iface.Flags&net.FlagMulticast == 0 {
continue
}
result = append(result, iface)
}
return result, nil
}
// stMatches returns true when the ST header should receive an M-SEARCH reply.
func stMatches(st string) bool {
switch st {
case "ssdp:all", "upnp:rootdevice", mediaServerURN:
return true
}
return false
}
// buildMSearchReply builds an HTTP/1.1 200 OK SSDP response.
func buildMSearchReply(udn, location, st string) string {
usn := usnForST(udn, st)
return fmt.Sprintf(
"HTTP/1.1 200 OK\r\n"+
"CACHE-CONTROL: max-age=%d\r\n"+
"DATE: %s\r\n"+
"EXT:\r\n"+
"LOCATION: %s\r\n"+
"SERVER: %s\r\n"+
"ST: %s\r\n"+
"USN: %s\r\n"+
"\r\n",
ssdpMaxAge,
time.Now().UTC().Format(http.TimeFormat),
location,
serverVersion,
st,
usn,
)
}
// usnForST builds the USN header value for a given ST.
func usnForST(udn, st string) string {
if st == "upnp:rootdevice" || st == "ssdp:all" {
return udn + "::upnp:rootdevice"
}
return udn + "::" + st
}
// ----------------------------------------------------------------------------
// SSDP alive announcements
// ----------------------------------------------------------------------------
func runSSDPAlive(ctx context.Context, logger *slog.Logger, udn, location string) {
// Send an initial batch immediately, then repeat on the interval.
sendAlive(logger, udn, location)
ticker := time.NewTicker(notifyInterval)
defer ticker.Stop()
for {
select {
case <-ctx.Done():
return
case <-ticker.C:
sendAlive(logger, udn, location)
}
}
}
func sendAlive(logger *slog.Logger, udn, location string) {
nts := []struct{ nt, usn string }{
{"upnp:rootdevice", udn + "::upnp:rootdevice"},
{udn, udn},
{mediaServerURN, udn + "::" + mediaServerURN},
{contentDirURN, udn + "::" + contentDirURN},
}
conn, err := net.Dial("udp4", ssdpMulticastAddr)
if err != nil {
logger.Warn("SSDP: cannot send alive notification", "err", err)
return
}
defer conn.Close()
for _, n := range nts {
msg := fmt.Sprintf(
"NOTIFY * HTTP/1.1\r\n"+
"HOST: %s\r\n"+
"CACHE-CONTROL: max-age=%d\r\n"+
"LOCATION: %s\r\n"+
"NT: %s\r\n"+
"NTS: ssdp:alive\r\n"+
"SERVER: %s\r\n"+
"USN: %s\r\n"+
"\r\n",
ssdpMulticastAddr, ssdpMaxAge, location,
n.nt, serverVersion, n.usn,
)
_, _ = conn.Write([]byte(msg))
}
logger.Debug("SSDP: alive announcements sent")
}
// ----------------------------------------------------------------------------
// SSDP byebye on shutdown
// ----------------------------------------------------------------------------
func sendByebye(logger *slog.Logger, udn string) {
conn, err := net.Dial("udp4", ssdpMulticastAddr)
if err != nil {
logger.Warn("SSDP: cannot send byebye", "err", err)
return
}
defer conn.Close()
nts := []struct{ nt, usn string }{
{"upnp:rootdevice", udn + "::upnp:rootdevice"},
{udn, udn},
{mediaServerURN, udn + "::" + mediaServerURN},
{contentDirURN, udn + "::" + contentDirURN},
}
for _, n := range nts {
msg := fmt.Sprintf(
"NOTIFY * HTTP/1.1\r\n"+
"HOST: %s\r\n"+
"NT: %s\r\n"+
"NTS: ssdp:byebye\r\n"+
"USN: %s\r\n"+
"\r\n",
ssdpMulticastAddr, n.nt, n.usn,
)
_, _ = conn.Write([]byte(msg))
}
logger.Info("SSDP: byebye announcements sent")
}
// ----------------------------------------------------------------------------
// Network helpers
// ----------------------------------------------------------------------------
// primaryLANIP returns the first non-loopback, non-link-local IPv4 address
// found on any UP interface.
func primaryLANIP() (string, error) {
ifaces, err := net.Interfaces()
if err != nil {
return "", err
}
for _, iface := range ifaces {
if iface.Flags&net.FlagUp == 0 || iface.Flags&net.FlagLoopback != 0 {
continue
}
addrs, err := iface.Addrs()
if err != nil {
continue
}
for _, addr := range addrs {
ipNet, ok := addr.(*net.IPNet)
if !ok {
continue
}
v4 := ipNet.IP.To4()
if v4 == nil {
continue
}
if v4.IsLoopback() || v4.IsLinkLocalUnicast() {
continue
}
return v4.String(), nil
}
}
return "", fmt.Errorf("no usable LAN IPv4 address found")
}
// ----------------------------------------------------------------------------
// --media-dir loader
// ----------------------------------------------------------------------------
// loadTreeFromDir walks dir recursively and builds a single flat content folder
// from every audio file found, so an artist/album tree works. Album art for a
// track is, in order of preference: a sibling <basename>.<img>, then a
// cover.jpg/cover.png/folder.jpg in the track's own directory. Returns the tree
// and track count.
func loadTreeFromDir(dir, fallbackName string) (*dlnatest.Tree, int, error) {
rootClean := filepath.Clean(dir)
// Cache the resolved cover per directory so we read each album's folder.jpg
// once rather than for every track in it.
type cover struct {
data []byte
mime string
}
coverCache := map[string]cover{}
dirCover := func(d string) ([]byte, string) {
if c, ok := coverCache[d]; ok {
return c.data, c.mime
}
var c cover
for _, n := range []string{"cover.jpg", "cover.jpeg", "cover.png", "folder.jpg", "folder.png", "albumart.jpg", "albumart.png"} {
if b, err := os.ReadFile(filepath.Join(d, n)); err == nil {
c = cover{data: b, mime: imageMimeForExt(filepath.Ext(n))}
break
}
}
coverCache[d] = c
return c.data, c.mime
}
// Group tracks by their containing directory (preserving first-seen order),
// so each real album folder becomes its own browsable + playable container
// named after the directory, rather than one flat list named after --name.
type group struct {
dir string
items []*dlnatest.Item
}
groups := map[string]*group{}
var order []string
total := 0
walkErr := filepath.WalkDir(dir, func(path string, d fs.DirEntry, err error) error {
if err != nil || d.IsDir() {
return nil //nolint:nilerr // skip unreadable entries and directories
}
mime := audioMimeForExt(filepath.Ext(path))
if mime == "" {
return nil // not an audio file we recognise
}
payload, rerr := os.ReadFile(path)
if rerr != nil {
return nil //nolint:nilerr // skip unreadable file, keep walking
}
trackDir := filepath.Dir(path)
base := strings.TrimSuffix(d.Name(), filepath.Ext(d.Name()))
// Prefer a per-track image sibling; fall back to the album-folder cover.
art, artMime := dirCover(trackDir)
for _, ae := range []string{".jpg", ".jpeg", ".png", ".webp"} {
if b, aerr := os.ReadFile(filepath.Join(trackDir, base+ae)); aerr == nil {
art = b
artMime = imageMimeForExt(ae)
break
}
}
g := groups[trackDir]
if g == nil {
g = &group{dir: trackDir}
groups[trackDir] = g
order = append(order, trackDir)
}
g.items = append(g.items, &dlnatest.Item{
Title: base,
Class: "object.item.audioItem.musicTrack",
Artist: artistForDir(trackDir, rootClean),
Album: albumTitle(trackDir, rootClean, fallbackName),
MimeType: mime,
Payload: payload,
ArtPayload: art,
ArtMime: artMime,
})
total++
return nil
})
if walkErr != nil {
return nil, 0, walkErr
}
if total == 0 {
return nil, 0, fmt.Errorf("no audio files (.mp3/.wav/.flac/.m4a/.ogg) found under %s", dir)
}
containers := make([]*dlnatest.Container, 0, len(order))
for ci, d := range order {
cid := strconv.Itoa(ci + 1)
g := groups[d]
for ti, it := range g.items {
it.ID = fmt.Sprintf("%s$%d", cid, ti)
it.ParentID = cid
}
containers = append(containers, &dlnatest.Container{
ID: cid,
ParentID: "0",
Title: albumTitle(d, rootClean, fallbackName),
Class: "object.container.storageFolder",
Children: g.items,
})
}
return &dlnatest.Tree{Containers: containers}, total, nil
}
// albumTitle returns the display name for a track directory: the directory's own
// name, or the fallback (the --name) when the tracks sit directly in the root.
func albumTitle(trackDir, root, fallback string) string {
if filepath.Clean(trackDir) == root {
return fallback
}
return filepath.Base(trackDir)
}
// artistForDir derives the artist from the directory above the album folder
// (e.g. <root>/<artist>/<album>/track.mp3 → "<artist>"). Falls back to
// "Unknown Artist" when there is no artist level (album directly under root, or
// tracks directly in root).
func artistForDir(trackDir, root string) string {
clean := filepath.Clean(trackDir)
if clean == root {
return "Unknown Artist"
}
parent := filepath.Dir(clean)
if parent == root {
return "Unknown Artist"
}
return filepath.Base(parent)
}
// audioMimeForExt maps an audio file extension to a MIME type, or "" if the
// extension is not a recognised audio format.
func audioMimeForExt(ext string) string {
switch strings.ToLower(ext) {
case ".mp3":
return "audio/mpeg"
case ".wav":
return "audio/x-wav"
case ".flac":
return "audio/flac"
case ".m4a", ".mp4":
return "audio/mp4"
case ".ogg":
return "audio/ogg"
}
return ""
}
// imageMimeForExt maps an image file extension to a MIME type.
func imageMimeForExt(ext string) string {
switch strings.ToLower(ext) {
case ".jpg", ".jpeg":
return "image/jpeg"
case ".png":
return "image/png"
case ".webp":
return "image/webp"
case ".gif":
return "image/gif"
}
return "application/octet-stream"
}
// ----------------------------------------------------------------------------
// HTTP access logging (debugging aid)
// ----------------------------------------------------------------------------
// statusRecorder captures the status code and byte count of a response.
type statusRecorder struct {
http.ResponseWriter
status int
bytes int
}
func (r *statusRecorder) WriteHeader(code int) {
r.status = code
r.ResponseWriter.WriteHeader(code)
}
func (r *statusRecorder) Write(b []byte) (int, error) {
n, err := r.ResponseWriter.Write(b)
r.bytes += n
return n, err
}
// withAccessLog logs every HTTP request the server handles. For ContentDirectory
// Browse POSTs it also surfaces the ObjectID and BrowseFlag so the speaker's
// browse sequence (and whether it ever resolves a track's metadata) is visible.
func withAccessLog(logger *slog.Logger, next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
start := time.Now()
var browseAttrs []any
if r.Method == http.MethodPost && strings.Contains(r.URL.Path, "ContentDir") {
body, _ := io.ReadAll(io.LimitReader(r.Body, 1<<16))
_ = r.Body.Close()
r.Body = io.NopCloser(bytes.NewReader(body))
browseAttrs = []any{
"objectID", sanitizeLog(between(string(body), "<ObjectID>", "</ObjectID>")),
"browseFlag", sanitizeLog(between(string(body), "<BrowseFlag>", "</BrowseFlag>")),
}
}
rec := &statusRecorder{ResponseWriter: w, status: http.StatusOK}
next.ServeHTTP(rec, r)
attrs := []any{
"method", r.Method,
"path", sanitizeLog(r.URL.Path),
"status", rec.status,
"bytes", rec.bytes,
"from", r.RemoteAddr,
"dur", time.Since(start).String(),
}
attrs = append(attrs, browseAttrs...)
logger.Info("HTTP", attrs...)
})
}
// between returns the text between the first occurrence of openTag and the next
// closeTag, or "" if not found. Used for lightweight SOAP field extraction in logs.
func between(s, openTag, closeTag string) string {
i := strings.Index(s, openTag)
if i < 0 {
return ""
}
i += len(openTag)
j := strings.Index(s[i:], closeTag)
if j < 0 {
return ""
}
return s[i : i+j]
}
// interfaceForIP returns the UP, multicast-capable interface that owns the given
// IPv4 address, or nil if none is found.
func interfaceForIP(ip string) *net.Interface {
ifaces, err := net.Interfaces()
if err != nil {
return nil
}
for i := range ifaces {
iface := &ifaces[i]
if iface.Flags&net.FlagUp == 0 || iface.Flags&net.FlagMulticast == 0 {
continue
}
addrs, aerr := iface.Addrs()
if aerr != nil {
continue
}
for _, addr := range addrs {
if ipNet, ok := addr.(*net.IPNet); ok {
if v4 := ipNet.IP.To4(); v4 != nil && v4.String() == ip {
return iface
}
}
}
}
return nil
}
// extractHeader extracts a header value from a raw HTTP-style SSDP message.
// Key comparison is case-insensitive.
func extractHeader(msg, key string) string {
lower := strings.ToLower(key) + ":"
for _, line := range strings.Split(msg, "\n") {
trimmed := strings.TrimRight(line, "\r")
if strings.HasPrefix(strings.ToLower(trimmed), lower) {
return strings.TrimSpace(trimmed[len(lower):])
}
}
return ""
}
+56
View File
@@ -0,0 +1,56 @@
package main
import (
"fmt"
"github.com/gesellix/bose-soundtouch/pkg/service/updatecheck"
"github.com/urfave/cli/v2"
)
// updateCheckRepo is the GitHub repo checked for newer releases, matching
// soundtouch-service's periodic background check (#591,
// _/i591/design-update-check.md).
const updateCheckRepo = "gesellix/Bose-SoundTouch"
// updateCheckCommand assembles the on-demand `soundtouch-backup
// update-check` command, the CLI-side answer to that design doc's open
// question 2 (CLI-only users get no update notice from the service's
// background checker). Unlike the service's opt-in periodic check, running
// this command *is* the opt-in: no config flag, no persisted state, just
// one GitHub API request each time it's invoked.
func updateCheckCommand() *cli.Command {
return &cli.Command{
Name: "update-check",
Usage: "Check GitHub for a newer soundtouch-backup release",
Action: runUpdateCheck,
}
}
func runUpdateCheck(c *cli.Context) error {
checker := updatecheck.NewChecker(nil, updateCheckRepo, version)
result, err := checker.CheckNow(c.Context)
if err != nil {
return fmt.Errorf("update check failed: %w", err)
}
printUpdateCheckResult(result)
return nil
}
func printUpdateCheckResult(result updatecheck.Result) {
if result.LatestVersion == "" {
fmt.Printf("Running %s, not a released version, skipping comparison.\n", result.CurrentVersion)
return
}
if result.Available {
fmt.Printf("A newer version is available: %s (you're on %s)\n", result.LatestVersion, result.CurrentVersion)
fmt.Println(result.ReleaseURL)
return
}
fmt.Printf("You're on the latest version (%s).\n", result.CurrentVersion)
}
@@ -0,0 +1,42 @@
package main
import (
"testing"
"github.com/gesellix/bose-soundtouch/pkg/service/updatecheck"
)
// TestUpdateCheckCommand_Registered checks the command is wired up with the
// expected name and an Action, without making any real GitHub API calls.
func TestUpdateCheckCommand_Registered(t *testing.T) {
cmd := updateCheckCommand()
if cmd.Name != "update-check" {
t.Errorf("command name = %q; want %q", cmd.Name, "update-check")
}
if cmd.Action == nil {
t.Error("expected an Action to be set")
}
}
// TestPrintUpdateCheckResult_DoesNotPanic exercises all three result shapes
// (unparseable current version, update available, up to date) purely for
// the "does not panic" guarantee; updatecheck.Checker's own tests already
// cover the comparison logic itself.
func TestPrintUpdateCheckResult_DoesNotPanic(t *testing.T) {
cases := []struct {
name string
result updatecheck.Result
}{
{"unparseable current version", updatecheck.Result{CurrentVersion: "dev"}},
{"update available", updatecheck.Result{CurrentVersion: "v1.0.0", LatestVersion: "v1.1.0", Available: true, ReleaseURL: "https://example.invalid"}},
{"up to date", updatecheck.Result{CurrentVersion: "v1.1.0", LatestVersion: "v1.1.0", Available: false}},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
printUpdateCheckResult(tc.result)
})
}
}
+6 -1
View File
@@ -14,7 +14,11 @@ var version = "dev"
func init() {
if info, ok := debug.ReadBuildInfo(); ok {
if info.Main.Version != "" && info.Main.Version != "(devel)" {
// Only fall back to build info when the version was not injected via
// -ldflags (i.e. still the "dev" default, e.g. `go install …@vX.Y.Z`).
// This keeps an explicitly stamped release version from being clobbered
// by a VCS pseudo-version (e.g. v0.0.0-… from a shallow checkout).
if version == "dev" && info.Main.Version != "" && info.Main.Version != "(devel)" {
version = info.Main.Version
}
}
@@ -29,6 +33,7 @@ func main() {
allCommand(),
cloudCommand(),
localCommand(),
updateCheckCommand(),
},
}
if err := app.Run(os.Args); err != nil {
+15 -2
View File
@@ -318,7 +318,7 @@ func removePandoraAccount(c *cli.Context) error {
func addStoredMusicAccount(c *cli.Context) error {
clientConfig := GetClientConfig(c)
client, err := CreateSoundTouchClient(clientConfig)
stClient, err := CreateSoundTouchClient(clientConfig)
if err != nil {
return err
}
@@ -340,13 +340,26 @@ func addStoredMusicAccount(c *cli.Context) error {
fmt.Printf(" Display Name: %s\n", displayName)
fmt.Printf(" Type: UPnP/DLNA Media Server\n")
err = client.AddStoredMusicAccount(user, displayName)
err = stClient.AddStoredMusicAccount(user, displayName)
if err != nil {
return fmt.Errorf("failed to add network music library: %w", err)
}
PrintSuccess("Network music library added successfully")
// Send a sourcesUpdated nudge so the speaker re-fetches its account list and
// registers the new source without requiring a power-cycle. This is
// best-effort: a failure here does not abort the command.
if info, infoErr := stClient.GetDeviceInfo(); infoErr == nil && info != nil && info.DeviceID != "" {
if nudgeErr := stClient.NotifySourcesUpdated(info.DeviceID); nudgeErr == nil {
fmt.Println(" Sent a sources refresh to the speaker (no reboot needed).")
} else {
fmt.Println(" Warning: could not send sources refresh; you may need to power-cycle the speaker for the new source to register.")
}
} else {
fmt.Println(" Warning: could not retrieve device ID; you may need to power-cycle the speaker for the new source to register.")
}
// Show next steps
fmt.Printf("\n💡 Next Steps:\n")
fmt.Printf(" • Check available sources: soundtouch-cli --host %s source list\n", clientConfig.Host)
+192 -256
View File
@@ -3,11 +3,10 @@ package main
import (
"fmt"
"net"
"sync"
"net/http"
"github.com/gesellix/bose-soundtouch/pkg/client"
"github.com/gesellix/bose-soundtouch/pkg/models"
"github.com/gesellix/bose-soundtouch/pkg/speaker"
"github.com/gesellix/bose-soundtouch/pkg/stereopair"
"github.com/urfave/cli/v2"
)
@@ -16,32 +15,26 @@ func getGroupStatus(c *cli.Context) error {
clientConfig := GetClientConfig(c)
PrintDeviceHeader("Getting group information", clientConfig.Host, clientConfig.Port)
client, err := CreateSoundTouchClient(clientConfig)
if err != nil {
PrintError(fmt.Sprintf("Failed to create client: %v", err))
return err
}
group, err := client.GetGroup()
result, err := newGroupCoordinator(clientConfig).Inspect(clientConfig.Host)
if err != nil {
PrintError(fmt.Sprintf("Failed to get group: %v", err))
printGroupResultDetails(result)
return err
}
if group.IsEmpty() {
if result.Group == nil || result.Group.IsEmpty() {
fmt.Println("Device is not in a stereo pair")
return nil
}
printGroup(group)
printGroup(result.Group)
return nil
}
// createGroup forms a stereo pair by POSTing /addGroup to both speakers in
// parallel. LEFT is the master. Addressing each speaker directly (instead of
// only the master and letting it propagate via marge) sidesteps the
// inter-device round-trip that surfaced as client timeouts in #252.
// createGroup forms and verifies a stereo pair. LEFT is always the master.
func createGroup(c *cli.Context) error {
leftIP := c.String("left")
rightIP := c.String("right")
@@ -49,283 +42,117 @@ func createGroup(c *cli.Context) error {
if net.ParseIP(leftIP) == nil {
PrintError(fmt.Sprintf("Invalid left IP address: %s", leftIP))
return fmt.Errorf("invalid left IP: %s", leftIP)
}
if net.ParseIP(rightIP) == nil {
PrintError(fmt.Sprintf("Invalid right IP address: %s", rightIP))
return fmt.Errorf("invalid right IP: %s", rightIP)
}
PrintDeviceHeader(fmt.Sprintf("Creating stereo pair: LEFT=%s RIGHT=%s", leftIP, rightIP), leftIP, speaker.HTTPPort)
clientConfig := GetClientConfig(c)
PrintDeviceHeader(fmt.Sprintf("Creating stereo pair: LEFT=%s RIGHT=%s", leftIP, rightIP), leftIP, clientConfig.Port)
leftInfo, err := fetchDeviceInfo(c, leftIP)
if err != nil {
PrintError(fmt.Sprintf("Failed to read LEFT device info: %v", err))
return err
}
rightInfo, err := fetchDeviceInfo(c, rightIP)
if err != nil {
PrintError(fmt.Sprintf("Failed to read RIGHT device info: %v", err))
return err
}
if name == "" {
name = fmt.Sprintf("%s + %s", leftInfo.Name, rightInfo.Name)
}
req := &models.Group{
result, err := newGroupCoordinator(clientConfig).Create(stereopair.CreateRequest{
LeftIPAddress: leftIP,
RightIPAddress: rightIP,
Name: name,
MasterDeviceID: leftInfo.DeviceID,
Roles: models.GroupRoles{
Roles: []models.GroupRole{
{DeviceID: leftInfo.DeviceID, Role: "LEFT", IPAddress: leftIP},
{DeviceID: rightInfo.DeviceID, Role: "RIGHT", IPAddress: rightIP},
},
},
// SenderIPAddress is intentionally omitted on the base request.
// propagateAddGroup adds it to the slave's copy only — see comment there.
}
leftClient, err := clientForHost(c, leftIP)
})
if err != nil {
PrintError(fmt.Sprintf("Failed to create client for LEFT: %v", err))
PrintError(fmt.Sprintf("Failed to create stereo pair: %v", err))
printGroupResultDetails(result)
return err
}
rightClient, err := clientForHost(c, rightIP)
if err != nil {
PrintError(fmt.Sprintf("Failed to create client for RIGHT: %v", err))
return err
}
leftOut, rightOut := propagateAddGroup(leftClient, rightClient, leftIP, rightIP, req)
if leftOut.err != nil {
PrintError(fmt.Sprintf("LEFT (%s) /addGroup failed: %v", leftIP, leftOut.err))
}
if rightOut.err != nil {
PrintError(fmt.Sprintf("RIGHT (%s) /addGroup failed: %v", rightIP, rightOut.err))
}
if leftOut.err != nil || rightOut.err != nil {
if (leftOut.err == nil) != (rightOut.err == nil) {
succeeded := leftIP
if leftOut.err != nil {
succeeded = rightIP
}
PrintError(fmt.Sprintf("Partial group state on %s — clean up with `soundtouch-cli --host %s group remove`", succeeded, succeeded))
}
return fmt.Errorf("/addGroup propagation failed")
}
// The LEFT (master) response carries the assigned group ID; use it for display.
PrintSuccess(fmt.Sprintf("Stereo pair created (id=%s)", leftOut.group.ID))
printGroup(leftOut.group)
PrintSuccess(fmt.Sprintf("Stereo pair created (id=%s)", result.Group.ID))
printGroup(result.Group)
return nil
}
// addGroupOutcome is the per-speaker result of a parallel /addGroup call.
type addGroupOutcome struct {
host string
group *models.Group
err error
}
// propagateAddGroup POSTs /addGroup to both speakers concurrently and returns
// the (LEFT, RIGHT) outcomes. A non-GROUP_OK Status in the response is
// reported as an error so callers don't have to re-inspect the body.
//
// The two POSTs carry different payloads: the master (LEFT) receives the base
// request with no senderIPAddress so its state machine forms the group as the
// master, while the slave (RIGHT) receives a copy with senderIPAddress set to
// the master's IP so its state machine joins as the slave. Sending the same
// payload to both makes both speakers think they're the slave — they enter
// AddingSlave, wait for a master that never confirms, time out after 5 s, and
// revert (issue #252).
func propagateAddGroup(left, right *client.Client, leftIP, rightIP string, req *models.Group) (addGroupOutcome, addGroupOutcome) {
masterReq := *req
masterReq.SenderIPAddress = ""
slaveReq := *req
slaveReq.SenderIPAddress = leftIP
var (
wg sync.WaitGroup
leftOut, rightOut addGroupOutcome
)
wg.Add(2)
go func() {
defer wg.Done()
leftOut = postAddGroup(left, leftIP, &masterReq)
}()
go func() {
defer wg.Done()
rightOut = postAddGroup(right, rightIP, &slaveReq)
}()
wg.Wait()
return leftOut, rightOut
}
func postAddGroup(cli *client.Client, host string, req *models.Group) addGroupOutcome {
out := addGroupOutcome{host: host}
g, err := cli.AddGroup(req)
if err != nil {
out.err = err
return out
}
out.group = g
if g != nil && g.Status != "" && g.Status != "GROUP_OK" {
out.err = fmt.Errorf("device returned status %q (want GROUP_OK)", g.Status)
}
return out
}
// renameGroup updates the name of the existing stereo pair. The device
// requires the full structure on every update, so we fetch the current
// state first.
// renameGroup updates and verifies the name on both stereo-pair members.
func renameGroup(c *cli.Context) error {
clientConfig := GetClientConfig(c)
newName := c.String("name")
if newName == "" {
PrintError("--name is required")
return fmt.Errorf("name is required")
}
PrintDeviceHeader(fmt.Sprintf("Renaming stereo pair to %q", newName), clientConfig.Host, clientConfig.Port)
stClient, err := CreateSoundTouchClient(clientConfig)
coordinator := newGroupCoordinator(clientConfig)
current, err := coordinator.Inspect(clientConfig.Host)
if err != nil {
PrintError(fmt.Sprintf("Failed to create client: %v", err))
PrintError(fmt.Sprintf("Failed to inspect stereo pair before rename: %v", err))
printGroupResultDetails(current)
return err
}
current, err := stClient.GetGroup()
if err != nil {
PrintError(fmt.Sprintf("Failed to read current group: %v", err))
return err
if current.Group == nil || current.Group.IsEmpty() {
return fmt.Errorf("device is not in a stereo pair")
}
if current.IsEmpty() {
PrintError("Device is not in a stereo pair — nothing to rename")
return fmt.Errorf("no group configured")
}
// Status is read-only on the device side; don't echo it back.
current.Status = ""
current.Name = newName
result, err := stClient.UpdateGroup(current)
result, err := coordinator.Rename(stereopair.RenameRequest{
MemberIPAddress: clientConfig.Host,
ExpectedGroupID: current.Group.ID,
Name: newName,
})
if err != nil {
PrintError(fmt.Sprintf("Failed to rename group: %v", err))
printGroupResultDetails(result)
return err
}
PrintSuccess(fmt.Sprintf("Stereo pair renamed to %q", result.Name))
printGroup(result)
PrintSuccess(fmt.Sprintf("Stereo pair renamed to %q", result.Group.Name))
printGroup(result.Group)
return nil
}
// removeGroup tears down the device's stereo pair by sending /removeGroup to
// every member in parallel. Sending it only to the master (as the old code
// did) leaves the slave stuck in GroupSlave state indefinitely — mirrors the
// same symmetry as createGroup (see issue #252 comment there).
// removeGroup dissolves and verifies the stereo pair on every member.
func removeGroup(c *cli.Context) error {
clientConfig := GetClientConfig(c)
PrintDeviceHeader("Removing stereo pair", clientConfig.Host, clientConfig.Port)
stClient, err := CreateSoundTouchClient(clientConfig)
coordinator := newGroupCoordinator(clientConfig)
current, err := coordinator.Inspect(clientConfig.Host)
if err != nil {
PrintError(fmt.Sprintf("Failed to create client: %v", err))
return err
PrintWarning(fmt.Sprintf("Stereo pair is degraded before removal: %v", err))
printGroupResultDetails(current)
if current.Group == nil || current.Group.IsEmpty() {
return err
}
}
// Fetch current group to learn every member's IP before tearing down.
group, err := stClient.GetGroup()
if err != nil {
PrintError(fmt.Sprintf("Failed to read current group: %v", err))
return err
}
if group.IsEmpty() {
if current.Group == nil || current.Group.IsEmpty() {
fmt.Println("Device is not in a stereo pair — nothing to remove")
return nil
}
// Collect the unique set of member IPs. The master is always reachable
// via clientConfig.Host; the roles carry all members including slaves.
type memberResult struct {
ip string
err error
}
dissolveHost := dissolveRecoveryHost(current, clientConfig.Host)
members := make([]string, 0, len(group.Roles.Roles))
seen := map[string]bool{}
result, err := coordinator.Dissolve(stereopair.DissolveRequest{
MemberIPAddress: dissolveHost,
ExpectedGroupID: current.Group.ID,
ExpectedGroup: current.Group,
})
if err != nil {
PrintError(fmt.Sprintf("Failed to remove stereo pair: %v", err))
printGroupResultDetails(result)
for _, role := range group.Roles.Roles {
if role.IPAddress != "" && !seen[role.IPAddress] {
seen[role.IPAddress] = true
members = append(members, role.IPAddress)
}
}
// Always include the addressed host even if the group response omitted IPs.
if !seen[clientConfig.Host] {
members = append(members, clientConfig.Host)
}
results := make([]memberResult, len(members))
var wg sync.WaitGroup
for i, ip := range members {
wg.Add(1)
go func(idx int, host string) {
defer wg.Done()
mc, mcErr := clientForHost(c, host)
if mcErr != nil {
results[idx] = memberResult{ip: host, err: mcErr}
return
}
results[idx] = memberResult{ip: host, err: mc.RemoveGroup()}
}(i, ip)
}
wg.Wait()
anyErr := false
for _, r := range results {
if r.err != nil {
PrintError(fmt.Sprintf("%s /removeGroup failed: %v", r.ip, r.err))
anyErr = true
}
}
if anyErr {
return fmt.Errorf("/removeGroup propagation failed")
return err
}
PrintSuccess("Stereo pair removed")
@@ -333,32 +160,141 @@ func removeGroup(c *cli.Context) error {
return nil
}
// fetchDeviceInfo builds a one-off client for the given IP and reads /info.
// Reused for both halves of a `create` invocation so the caller doesn't have
// to babysit two host/port pairs.
func fetchDeviceInfo(c *cli.Context, host string) (*models.DeviceInfo, error) {
stClient, err := clientForHost(c, host)
if err != nil {
return nil, err
func dissolveRecoveryHost(result stereopair.Result, fallback string) string {
if result.Group == nil || result.Group.ID == "" {
return fallback
}
return stClient.GetDeviceInfo()
for i := range result.Members {
member := &result.Members[i]
if member.Group != nil && member.Group.ID == result.Group.ID && net.ParseIP(member.IPAddress) != nil {
return member.IPAddress
}
}
return fallback
}
// clientForHost mirrors CreateSoundTouchClient but overrides the host so we
// can talk to a speaker other than the one named in --host.
func clientForHost(c *cli.Context, host string) (*client.Client, error) {
cfg, err := loadConfig(c.Duration("timeout"))
if err != nil {
return nil, fmt.Errorf("failed to load config: %w", err)
func newGroupCoordinator(config *ClientConfig) *stereopair.Coordinator {
lifecycleConfig := *config
if lifecycleConfig.Timeout < stereopair.RequestTimeout {
lifecycleConfig.Timeout = stereopair.RequestTimeout
}
return client.NewClient(&client.Config{
Host: host,
Port: speaker.HTTPPort,
Timeout: cfg.HTTPTimeout,
UserAgent: cfg.UserAgent,
}), nil
cleanupClient := &http.Client{Timeout: lifecycleConfig.Timeout}
cleanup, preflight, rename := cliStereoPairGenerationPersistence(cleanupClient)
return stereopair.NewWithGenerationLifecyclePersistence(
groupClientFactory(&lifecycleConfig),
cleanup, preflight, rename,
)
}
// cliStereoPairGenerationPersistence wires generation-lifecycle hooks for
// the CLI: cleanup and rename are no-ops, and preflight's read-only
// dangling-generation check is advisory (mirrors -service's and -player's
// own equivalents).
//
// A speaker self-reports its own group create/rename/teardown to whatever
// Marge backend it's configured with -- that's the entire reason
// HandleMargeAddGroup/HandleMargeModifyGroup/HandleMargeDeleteGroup exist,
// they're only ever called by speakers, never by us. Proactively pushing the
// same update ourselves would duplicate that against a backend we generally
// can't authenticate to anyway (real Bose cloud, another AfterTouch/SoundCork
// instance, ...). The one part with a distinct purpose -- checking for a
// dangling stale generation before a new Create -- is still attempted, but
// its failure must not block Create: it's a best-effort safety net on top of
// the coordinator's own physical preflight, not the primary guard.
func cliStereoPairGenerationPersistence(
cleanupClient *http.Client,
) (stereopair.GenerationCleanup, stereopair.GenerationPreflight, stereopair.GenerationRename) {
cleanup := func(stereopair.GenerationRef) error {
return nil
}
preflight := func(refs []stereopair.GenerationRef) error {
if err := stereopair.EnsureMargeNoGroupGenerations(cleanupClient, refs); err != nil {
PrintWarning(fmt.Sprintf("stereo-pair external generation preflight inconclusive, proceeding: %v", err))
}
return nil
}
rename := func(stereopair.GenerationRef, string) error {
return nil
}
return cleanup, preflight, rename
}
// groupClientFactory addresses every member directly while retaining the
// effective CLI port and timeout.
func groupClientFactory(config *ClientConfig) stereopair.ClientFactory {
baseConfig := *config
return func(ipAddress string) (stereopair.Client, error) {
memberConfig := baseConfig
memberConfig.Host = ipAddress
return CreateSoundTouchClient(&memberConfig)
}
}
func printGroupResultDetails(result stereopair.Result) {
if result.Status == stereopair.StatusDegraded {
PrintWarning(fmt.Sprintf("Stereo-pair %s result is degraded", result.Operation))
}
for i := range result.Members {
member := &result.Members[i]
label := groupMemberLabel(i, member)
if member.PreflightError != nil {
PrintError(fmt.Sprintf("%s preflight failed: %v", label, member.PreflightError))
}
if member.MutationError != nil {
PrintError(fmt.Sprintf("%s mutation failed: %v", label, member.MutationError))
}
if member.VerificationError != nil {
PrintError(fmt.Sprintf("%s verification failed: %v", label, member.VerificationError))
}
if member.CompensationError != nil {
PrintError(fmt.Sprintf("%s cleanup failed: %v", label, member.CompensationError))
} else if member.CompensationAttempted && !member.CompensationVerified {
PrintWarning(fmt.Sprintf("%s cleanup could not be verified", label))
}
}
if result.CompensationAttempted {
if result.CompensationComplete {
PrintWarning("Partial stereo-pair state was cleaned up and verified")
} else {
PrintError("Partial stereo-pair state cleanup is incomplete")
}
}
if result.PersistenceError != nil {
PrintError(fmt.Sprintf("Persistent group generation update failed: %v", result.PersistenceError))
}
}
func groupMemberLabel(index int, member *stereopair.MemberResult) string {
if member.IPAddress != "" && member.DeviceID != "" {
return fmt.Sprintf("%s (%s)", member.IPAddress, member.DeviceID)
}
if member.IPAddress != "" {
return member.IPAddress
}
if member.DeviceID != "" {
return member.DeviceID
}
return fmt.Sprintf("member %d", index+1)
}
func printGroup(g *models.Group) {
+228 -157
View File
@@ -1,184 +1,255 @@
package main
import (
"encoding/xml"
"io"
"errors"
"net"
"net/http"
"net/http/httptest"
"net/url"
"strconv"
"strings"
"testing"
"time"
"github.com/gesellix/bose-soundtouch/pkg/client"
"github.com/gesellix/bose-soundtouch/pkg/models"
"github.com/gesellix/bose-soundtouch/pkg/stereopair"
)
// happyAddGroupServer fakes a speaker's /addGroup that echoes the request
// with an assigned ID and GROUP_OK status, matching real hardware behaviour.
func happyAddGroupServer(t *testing.T, assignedID string) (*httptest.Server, *[]string) {
func TestGroupClientFactoryUsesMemberHostAndConfiguredPort(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/getGroup" {
t.Errorf("path = %q, want /getGroup", r.URL.Path)
}
w.Header().Set("Content-Type", "application/xml")
_, _ = w.Write([]byte(`<group id="pair-id"><name>Pair</name></group>`))
}))
defer srv.Close()
host, port := testServerHostPort(t, srv.URL)
factory := groupClientFactory(&ClientConfig{
Host: "192.0.2.200",
Port: port,
Timeout: time.Second,
})
memberClient, err := factory(host)
if err != nil {
t.Fatalf("factory: %v", err)
}
group, err := memberClient.GetGroup()
if err != nil {
t.Fatalf("GetGroup: %v", err)
}
if group.ID != "pair-id" || group.Name != "Pair" {
t.Fatalf("group = %+v, want test server response", group)
}
}
func TestGroupClientFactoryUsesConfiguredTimeout(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
time.Sleep(100 * time.Millisecond)
_, _ = w.Write([]byte(`<group/>`))
}))
defer srv.Close()
host, port := testServerHostPort(t, srv.URL)
factory := groupClientFactory(&ClientConfig{Port: port, Timeout: 5 * time.Millisecond})
memberClient, err := factory(host)
if err != nil {
t.Fatalf("factory: %v", err)
}
if _, err := memberClient.GetGroup(); err == nil {
t.Fatal("GetGroup succeeded, want configured timeout")
}
}
func TestMargeGroupGenerationURL(t *testing.T) {
tests := []struct {
base string
want string
}{
{base: "http://aftertouch.example:8000", want: "http://aftertouch.example:8000/streaming/account/ACCOUNT1/group/PAIR1"},
{base: "http://unifi:8001/marge", want: "http://unifi:8001/marge/streaming/account/ACCOUNT1/group/PAIR1"},
{base: "https://proxy.example/prefix/streaming/", want: "https://proxy.example/prefix/streaming/account/ACCOUNT1/group/PAIR1"},
}
for _, test := range tests {
got, err := stereopair.MargeGroupGenerationURL(stereopair.GenerationRef{
MargeURL: test.base, AccountID: "ACCOUNT1", GroupID: "PAIR1",
})
if err != nil {
t.Fatalf("margeGroupGenerationURL(%q): %v", test.base, err)
}
if got != test.want {
t.Errorf("margeGroupGenerationURL(%q) = %q, want %q", test.base, got, test.want)
}
}
}
func TestDeleteMargeGroupGenerationUsesExactEndpoint(t *testing.T) {
deleteSeen := false
getSeen := false
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.Method {
case http.MethodDelete:
deleteSeen = true
if r.URL.Path != "/streaming/account/ACCOUNT1/group/PAIR1" {
t.Errorf("DELETE path = %s", r.URL.Path)
}
w.WriteHeader(http.StatusOK)
case http.MethodGet:
getSeen = true
if r.URL.Path != "/streaming/account/ACCOUNT1/device/LEFT-ID/group" {
t.Errorf("GET path = %s", r.URL.Path)
}
if deleteSeen {
_, _ = w.Write([]byte(`<group/>`))
} else {
_, _ = w.Write([]byte(`<group id="PAIR1"><masterDeviceId>LEFT-ID</masterDeviceId><roles><groupRole><deviceId>LEFT-ID</deviceId><role>LEFT</role><ipAddress>192.0.2.10</ipAddress></groupRole><groupRole><deviceId>RIGHT-ID</deviceId><role>RIGHT</role><ipAddress>192.0.2.11</ipAddress></groupRole></roles></group>`))
}
}
}))
defer server.Close()
err := stereopair.DeleteMargeGroupGeneration(server.Client(), stereopair.GenerationRef{
MargeURL: server.URL, AccountID: "ACCOUNT1", GroupID: "PAIR1", DeviceID: "LEFT-ID",
ExpectedGroup: &models.Group{
ID: "PAIR1",
MasterDeviceID: "LEFT-ID",
Roles: models.GroupRoles{Roles: []models.GroupRole{
{DeviceID: "LEFT-ID", Role: "LEFT", IPAddress: "192.0.2.10"},
{DeviceID: "RIGHT-ID", Role: "RIGHT", IPAddress: "192.0.2.11"},
}},
},
})
if err != nil {
t.Fatalf("deleteMargeGroupGeneration: %v", err)
}
if !deleteSeen || !getSeen {
t.Fatalf("Marge cleanup requests DELETE=%t GET=%t, want both", deleteSeen, getSeen)
}
}
func TestPrintGroupResultDetailsReportsMemberFailuresAndCleanup(t *testing.T) {
result := stereopair.Result{
Operation: stereopair.OperationCreate,
Status: stereopair.StatusDegraded,
CompensationAttempted: true,
PersistenceError: errors.New("datastore unavailable"),
Members: []stereopair.MemberResult{
{
IPAddress: "192.0.2.10",
DeviceID: "LEFT-ID",
PreflightError: errors.New("offline"),
},
{
IPAddress: "192.0.2.11",
MutationError: errors.New("add failed"),
VerificationError: errors.New("unexpected group"),
CompensationAttempted: true,
CompensationError: errors.New("remove failed"),
},
},
}
output := captureStdout(t, func() {
printGroupResultDetails(result)
})
for _, expected := range []string{
"Stereo-pair create result is degraded",
"192.0.2.10 (LEFT-ID) preflight failed: offline",
"192.0.2.11 mutation failed: add failed",
"192.0.2.11 verification failed: unexpected group",
"192.0.2.11 cleanup failed: remove failed",
"Partial stereo-pair state cleanup is incomplete",
"Persistent group generation update failed: datastore unavailable",
} {
if !strings.Contains(output, expected) {
t.Errorf("output missing %q:\n%s", expected, output)
}
}
}
func TestDissolveRecoveryHostSelectsStillGroupedMember(t *testing.T) {
result := stereopair.Result{
Group: &models.Group{ID: "PAIR-ID"},
Members: []stereopair.MemberResult{
{IPAddress: "192.0.2.10", Group: &models.Group{}},
{IPAddress: "192.0.2.11", Group: &models.Group{ID: "PAIR-ID"}},
},
}
if got := dissolveRecoveryHost(result, "192.0.2.10"); got != "192.0.2.11" {
t.Fatalf("recovery host = %q, want surviving member", got)
}
}
func testServerHostPort(t *testing.T, serverURL string) (string, int) {
t.Helper()
bodies := make([]string, 0)
parsed, err := url.Parse(serverURL)
if err != nil {
t.Fatalf("parse server URL: %v", err)
}
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/addGroup" || r.Method != http.MethodPost {
t.Errorf("unexpected request: %s %s", r.Method, r.URL.Path)
http.NotFound(w, r)
host, portText, err := net.SplitHostPort(parsed.Host)
if err != nil {
t.Fatalf("split server host: %v", err)
}
port, err := strconv.Atoi(portText)
if err != nil {
t.Fatalf("parse server port: %v", err)
}
return host, port
}
// TestCLIStereoPairGenerationPersistenceSkipsWritesButAttemptsRead covers
// the CLI's generation-lifecycle wiring: cleanup and rename must never push
// to a Marge backend -- the speaker itself self-reports its own group
// teardown/rename to whatever backend it's configured with (see
// HandleMargeDeleteGroup/HandleMargeModifyGroup, only ever called by
// speakers). Preflight still attempts its read-only dangling-generation
// check, but a failure there (network error, wrong credentials, real Bose
// cloud rejecting us, ...) must not block Create.
func TestCLIStereoPairGenerationPersistenceSkipsWritesButAttemptsRead(t *testing.T) {
getCalls := 0
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method == http.MethodGet && strings.HasSuffix(r.URL.Path, "/device/LEFT-ID/group") {
getCalls++
http.Error(w, "unauthorized", http.StatusUnauthorized)
return
}
body, _ := io.ReadAll(r.Body)
bodies = append(bodies, string(body))
var got models.Group
if err := xml.Unmarshal(body, &got); err != nil {
t.Fatalf("decode request body: %v", err)
}
got.ID = assignedID
got.Status = "GROUP_OK"
w.Header().Set("Content-Type", "application/xml")
enc, _ := xml.Marshal(&got)
_, _ = w.Write(enc)
t.Fatalf("unexpected external %s %s: cleanup/rename must not write to a backend the CLI doesn't own", r.Method, r.URL.Path)
}))
defer server.Close()
return srv, &bodies
}
cleanup, preflight, rename := cliStereoPairGenerationPersistence(server.Client())
func newTestGroupClient(serverURL string) *client.Client {
return client.NewClientFromHost(serverURL)
}
ref := stereopair.GenerationRef{
DeviceID: "LEFT-ID", AccountID: "ACCOUNT1", MargeURL: server.URL + "/marge",
GroupID: "7654321",
}
func sampleGroupRequest(leftIP, rightIP string) *models.Group {
return &models.Group{
Name: "Living Room",
MasterDeviceID: "9070658C9D4A",
Roles: models.GroupRoles{
Roles: []models.GroupRole{
{DeviceID: "9070658C9D4A", Role: "LEFT", IPAddress: leftIP},
{DeviceID: "F45EAB3115DA", Role: "RIGHT", IPAddress: rightIP},
},
},
// senderIPAddress is intentionally not set here; propagateAddGroup
// adds it to the slave's copy only.
if err := rename(ref, "Renamed living room"); err != nil {
t.Fatalf("rename = %v, want nil (speaker self-reports its own rename)", err)
}
}
func TestPropagateAddGroup_BothSucceed(t *testing.T) {
leftSrv, leftBodies := happyAddGroupServer(t, "9999999")
defer leftSrv.Close()
rightSrv, rightBodies := happyAddGroupServer(t, "9999999")
defer rightSrv.Close()
leftClient := newTestGroupClient(leftSrv.URL)
rightClient := newTestGroupClient(rightSrv.URL)
req := sampleGroupRequest("192.0.2.131", "192.0.2.134")
leftOut, rightOut := propagateAddGroup(leftClient, rightClient, "192.0.2.131", "192.0.2.134", req)
if leftOut.err != nil {
t.Errorf("LEFT err = %v, want nil", leftOut.err)
if err := cleanup(ref); err != nil {
t.Fatalf("cleanup = %v, want nil (speaker self-reports its own teardown)", err)
}
if rightOut.err != nil {
t.Errorf("RIGHT err = %v, want nil", rightOut.err)
if err := preflight([]stereopair.GenerationRef{ref}); err != nil {
t.Fatalf("preflight = %v, want nil: an unauthenticated external check must not block Create", err)
}
if leftOut.group == nil || leftOut.group.ID != "9999999" || leftOut.group.Status != "GROUP_OK" {
t.Errorf("LEFT group = %+v, want id=9999999 status=GROUP_OK", leftOut.group)
}
if rightOut.group == nil || rightOut.group.Status != "GROUP_OK" {
t.Errorf("RIGHT group = %+v, want status=GROUP_OK", rightOut.group)
}
// Both speakers must have received the roles, but only the slave's payload
// carries senderIPAddress — see propagateAddGroup for the why.
for label, bodies := range map[string]*[]string{"LEFT": leftBodies, "RIGHT": rightBodies} {
if len(*bodies) != 1 {
t.Fatalf("%s: expected exactly one POST, got %d", label, len(*bodies))
}
body := (*bodies)[0]
for _, want := range []string{"<role>LEFT</role>", "<role>RIGHT</role>"} {
if !strings.Contains(body, want) {
t.Errorf("%s body missing %q\nbody:\n%s", label, want, body)
}
}
}
leftBody := (*leftBodies)[0]
if strings.Contains(leftBody, "<senderIPAddress>") {
t.Errorf("LEFT (master) body must NOT carry <senderIPAddress>, otherwise the master flips into slave mode (issue #252)\nbody:\n%s", leftBody)
}
rightBody := (*rightBodies)[0]
if !strings.Contains(rightBody, "<senderIPAddress>192.0.2.131</senderIPAddress>") {
t.Errorf("RIGHT (slave) body must carry <senderIPAddress>192.0.2.131</senderIPAddress>\nbody:\n%s", rightBody)
}
}
func TestPropagateAddGroup_RightFails(t *testing.T) {
leftSrv, _ := happyAddGroupServer(t, "9999999")
defer leftSrv.Close()
rightSrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
http.Error(w, "boom", http.StatusInternalServerError)
}))
defer rightSrv.Close()
leftClient := newTestGroupClient(leftSrv.URL)
rightClient := newTestGroupClient(rightSrv.URL)
req := sampleGroupRequest("192.0.2.131", "192.0.2.134")
leftOut, rightOut := propagateAddGroup(leftClient, rightClient, "192.0.2.131", "192.0.2.134", req)
if leftOut.err != nil {
t.Errorf("LEFT err = %v, want nil", leftOut.err)
}
if rightOut.err == nil {
t.Error("RIGHT err = nil, want non-nil")
}
}
func TestPostAddGroup_StatusOtherThanGroupOKIsError(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.Header().Set("Content-Type", "application/xml")
_, _ = w.Write([]byte(`<group><status>GROUP_NOT_READY</status></group>`))
}))
defer srv.Close()
out := postAddGroup(newTestGroupClient(srv.URL), "test", sampleGroupRequest("1.1.1.1", "2.2.2.2"))
if out.err == nil {
t.Fatal("expected error for non-GROUP_OK status")
}
if !strings.Contains(out.err.Error(), "GROUP_NOT_READY") {
t.Errorf("error %q does not mention returned status", out.err)
}
}
func TestPostAddGroup_EmptyStatusIsAccepted(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.Header().Set("Content-Type", "application/xml")
_, _ = w.Write([]byte(`<group id="42"><name>n</name></group>`))
}))
defer srv.Close()
out := postAddGroup(newTestGroupClient(srv.URL), "test", sampleGroupRequest("1.1.1.1", "2.2.2.2"))
if out.err != nil {
t.Errorf("err = %v, want nil for empty status (some firmware omits it)", out.err)
}
if out.group == nil || out.group.ID != "42" {
t.Errorf("group = %+v, want id=42", out.group)
if getCalls != 1 {
t.Fatalf("external GET calls = %d, want exactly 1 (preflight must still attempt the read)", getCalls)
}
}
+443
View File
@@ -0,0 +1,443 @@
// Package main — `soundtouch-cli library` command group.
//
// Three subcommands:
//
// - library servers: discover DLNA media servers on the LAN, either via an
// app-side SSDP sweep (default) or via the speaker's own list (--via-speaker).
// - library browse: walk a DLNA ContentDirectory tree by UDN.
// - library play: play a DLNA track on a speaker via native STORED_MUSIC playback.
package main
import (
"context"
"fmt"
"strings"
"time"
"github.com/gesellix/bose-soundtouch/pkg/discovery"
"github.com/gesellix/bose-soundtouch/pkg/dlna"
"github.com/gesellix/bose-soundtouch/pkg/models"
"github.com/urfave/cli/v2"
)
// libraryCommand returns the top-level `library` command group.
func libraryCommand() *cli.Command {
return &cli.Command{
Name: "library",
Usage: "DLNA music library commands (server discovery, browse, play)",
Subcommands: []*cli.Command{
{
Name: "browse",
Usage: "Browse a DLNA ContentDirectory tree",
Action: libraryBrowse,
Flags: []cli.Flag{
&cli.IntFlag{
Name: "count",
Usage: "Page size (number of entries to request)",
Value: 50,
},
&cli.StringFlag{
Name: "object",
Usage: `ContentDirectory object ID to browse ("0" = root)`,
Value: "0",
},
&cli.IntFlag{
Name: "start",
Usage: "Page offset (starting index)",
Value: 0,
},
&cli.DurationFlag{
Name: "timeout",
Usage: "SSDP discovery + SOAP timeout",
Value: 5 * time.Second,
},
&cli.StringFlag{
Name: "udn",
Usage: "UDN (uuid:...) of the DLNA media server to browse",
Required: true,
},
},
},
{
Name: "play",
Usage: "Play a DLNA track on a speaker via native STORED_MUSIC playback",
Action: libraryPlay,
Before: RequireHost,
Flags: []cli.Flag{
&cli.StringFlag{
Name: "art",
Usage: "Container art URL (optional)",
},
&cli.StringFlag{
Name: "name",
Usage: "Display name shown on the speaker (optional)",
},
&cli.StringFlag{
Name: "source-account",
Usage: "STORED_MUSIC source account (media-server UDN with /0 suffix, e.g. fa095ecc-e13e-40e7-8e6c-e0286d5bc000/0)",
Required: true,
},
&cli.StringFlag{
Name: "type",
Usage: `ContentItem type: "track" or "dir"`,
Value: "track",
},
&cli.StringFlag{
Name: "location",
Usage: "Object ID from a browse result (e.g. 5:audio5:part13:3171:5 TRACK)",
Required: true,
},
},
},
{
Name: "servers",
Usage: "List DLNA media servers visible on the LAN",
Action: libraryServers,
Flags: []cli.Flag{
&cli.DurationFlag{
Name: "timeout",
Usage: "SSDP sweep timeout",
Value: 5 * time.Second,
},
&cli.BoolFlag{
Name: "via-speaker",
Usage: "Ask the speaker (--host required) instead of doing an app-side SSDP sweep",
},
},
},
},
}
}
// libraryServers implements `library servers`.
func libraryServers(c *cli.Context) error {
if c.Bool("via-speaker") {
return libraryServersViaSpeaker(c)
}
return libraryServersAppSide(c)
}
// libraryServersAppSide runs an SSDP sweep from the CLI process itself.
func libraryServersAppSide(c *cli.Context) error {
timeout := c.Duration("timeout")
ctx, cancel := context.WithTimeout(context.Background(), timeout+5*time.Second)
defer cancel()
servers, err := discovery.DiscoverMediaServers(ctx, timeout)
if err != nil {
PrintError(fmt.Sprintf("SSDP discovery failed: %v", err))
return err
}
if len(servers) == 0 {
fmt.Println("No DLNA media servers found on the LAN.")
return nil
}
fmt.Printf("Found %d DLNA media server(s):\n\n", len(servers))
for _, srv := range servers {
printAppSideServer(srv)
}
return nil
}
// libraryServersViaSpeaker asks the speaker for its own DLNA server list.
func libraryServersViaSpeaker(c *cli.Context) error {
clientConfig := GetClientConfig(c)
speakerClient, err := CreateSoundTouchClient(clientConfig)
if err != nil {
PrintError(fmt.Sprintf("Failed to create speaker client: %v", err))
return err
}
resp, err := speakerClient.ListMediaServers()
if err != nil {
PrintError(fmt.Sprintf("Failed to list media servers via speaker: %v", err))
return err
}
if len(resp.MediaServers) == 0 {
fmt.Println("Speaker reports no DLNA media servers.")
return nil
}
fmt.Printf("Speaker reports %d DLNA media server(s):\n\n", len(resp.MediaServers))
for i := range resp.MediaServers {
printSpeakerServer(resp.MediaServers[i])
}
return nil
}
// printAppSideServer prints a single server discovered by the app-side sweep.
func printAppSideServer(srv discovery.MediaServer) {
fmt.Printf(" Name: %s\n", srv.FriendlyName)
fmt.Printf(" Vendor: %s / %s\n", srv.Manufacturer, srv.ModelName)
fmt.Printf(" UDN: %s\n", srv.UDN)
fmt.Printf(" CDS: %s\n", srv.CDSControlURL)
if srv.IconURL != "" {
fmt.Printf(" Icon: %s\n", srv.IconURL)
}
fmt.Println()
}
// printSpeakerServer prints a single server as reported by the speaker.
func printSpeakerServer(srv models.MediaServerInfo) {
name := srv.FriendlyName
if name == "" {
name = "(unnamed)"
}
vendor := srv.Manufacturer
if srv.ModelName != "" {
if vendor != "" {
vendor += " / " + srv.ModelName
} else {
vendor = srv.ModelName
}
}
fmt.Printf(" Name: %s\n", name)
if vendor != "" {
fmt.Printf(" Vendor: %s\n", vendor)
}
fmt.Printf(" UDN: %s\n", srv.ID)
if srv.IP != "" {
fmt.Printf(" IP: %s\n", srv.IP)
}
if srv.Location != "" {
fmt.Printf(" Location: %s\n", srv.Location)
}
fmt.Println()
}
// libraryBrowse implements `library browse`.
func libraryBrowse(c *cli.Context) error {
udn := strings.TrimSpace(c.String("udn"))
objectID := c.String("object")
start := c.Int("start")
count := c.Int("count")
timeout := c.Duration("timeout")
ctx, cancel := context.WithTimeout(context.Background(), timeout+5*time.Second)
defer cancel()
servers, err := discovery.DiscoverMediaServers(ctx, timeout)
if err != nil {
PrintError(fmt.Sprintf("SSDP discovery failed: %v", err))
return err
}
var target *discovery.MediaServer
for i := range servers {
if servers[i].UDN == udn {
target = &servers[i]
break
}
}
if target == nil {
var udns []string
for _, srv := range servers {
udns = append(udns, fmt.Sprintf(" %s (%s)", srv.UDN, srv.FriendlyName))
}
if len(udns) == 0 {
PrintError(fmt.Sprintf("No server with UDN %q found; no servers discovered.", udn))
} else {
PrintError(fmt.Sprintf(
"No server with UDN %q found.\nKnown servers:\n%s",
udn, strings.Join(udns, "\n"),
))
}
return fmt.Errorf("server %q not found", udn)
}
fmt.Printf("Browsing %q (object %q, offset %d, page %d)\n\n", target.FriendlyName, objectID, start, count)
browseCtx, browseCancel := context.WithTimeout(context.Background(), 15*time.Second)
defer browseCancel()
result, err := dlna.Browse(browseCtx, *target, objectID, start, count)
if err != nil {
PrintError(fmt.Sprintf("Browse failed: %v", err))
return err
}
fmt.Printf("TotalMatches: %d Returned: %d\n\n", result.TotalMatches, result.Returned)
for _, con := range result.Containers {
fmt.Printf(" [dir] %s (id=%s, children=%d)\n", con.Title, con.ID, con.ChildCount)
}
for i := range result.Items {
it := &result.Items[i]
audio := ""
if it.IsAudioItem() {
audio = " [audio]"
}
meta := ""
if it.Artist != "" || it.Album != "" {
parts := []string{}
if it.Artist != "" {
parts = append(parts, it.Artist)
}
if it.Album != "" {
parts = append(parts, it.Album)
}
meta = " — " + strings.Join(parts, " / ")
}
dur := ""
if it.DurationSec > 0 {
m := it.DurationSec / 60
s := it.DurationSec % 60
dur = fmt.Sprintf(" [%d:%02d]", m, s)
}
fmt.Printf(" [item]%s %s%s%s\n", audio, it.Title, meta, dur)
if it.StreamURL != "" {
fmt.Printf(" url: %s\n", it.StreamURL)
}
}
return nil
}
// libraryPlay implements `library play` using native STORED_MUSIC playback.
func libraryPlay(c *cli.Context) error {
sourceAccount := strings.TrimSpace(c.String("source-account"))
location := strings.TrimSpace(c.String("location"))
name := c.String("name")
itemType := c.String("type")
art := c.String("art")
if sourceAccount == "" {
PrintError("--source-account is required")
return fmt.Errorf("--source-account is required")
}
if location == "" {
PrintError("--location is required")
return fmt.Errorf("--location is required")
}
clientConfig := GetClientConfig(c)
speakerClient, err := CreateSoundTouchClient(clientConfig)
if err != nil {
PrintError(fmt.Sprintf("Failed to create speaker client: %v", err))
return err
}
// Check that the STORED_MUSIC source for this account is READY before
// attempting playback. Re-registering an already-READY account can flip
// it to UNAVAILABLE, so we intentionally do NOT auto-register here.
sources, err := speakerClient.GetSources()
if err != nil {
PrintError(fmt.Sprintf("Failed to retrieve sources: %v", err))
return err
}
ready := false
for _, si := range sources.SourceItem {
if si.Source == "STORED_MUSIC" && si.SourceAccount == sourceAccount {
if si.Status.IsReady() {
ready = true
}
break
}
}
if !ready {
host := clientConfig.Host
PrintError(fmt.Sprintf(
"STORED_MUSIC source account %q is not READY on the speaker.\n"+
"Register it first:\n"+
" soundtouch-cli --host %s account add-nas --user %s --name <server-display-name>",
sourceAccount, host, sourceAccount,
))
return fmt.Errorf("STORED_MUSIC source account %q not ready", sourceAccount)
}
PrintDeviceHeader("STORED_MUSIC play", clientConfig.Host, clientConfig.Port)
fmt.Printf(" Source account: %s\n", sourceAccount)
fmt.Printf(" Location: %s\n", location)
fmt.Printf(" Type: %s\n", itemType)
if name != "" {
fmt.Printf(" Name: %s\n", name)
}
if art != "" {
fmt.Printf(" Art: %s\n", art)
}
fmt.Println()
// SelectStoredMusic does not set Type, so we build the ContentItem directly
// so we can pass the correct type ("track" or "dir") to the speaker.
ci := &models.ContentItem{
Source: "STORED_MUSIC",
SourceAccount: sourceAccount,
Location: location,
Type: itemType,
ItemName: name,
ContainerArt: art,
IsPresetable: true,
}
if err = speakerClient.SelectContentItem(ci); err != nil {
PrintError(fmt.Sprintf("Playback command failed: %v", err))
return err
}
label := name
if label == "" {
label = location
}
PrintSuccess(fmt.Sprintf("Playing %q (STORED_MUSIC, location=%s)", label, location))
return nil
}
+130
View File
@@ -0,0 +1,130 @@
package main
import (
"testing"
"github.com/urfave/cli/v2"
)
// TestLibraryCommand_Registered checks that the library command and its three
// subcommands are wired up with the expected names and flags. No live multicast
// or real speaker calls are made.
func TestLibraryCommand_Registered(t *testing.T) {
cmd := libraryCommand()
if cmd.Name != "library" {
t.Errorf("top-level command name = %q; want %q", cmd.Name, "library")
}
// Index subcommands by name for easy lookup.
sub := make(map[string]interface{})
for _, sc := range cmd.Subcommands {
sub[sc.Name] = sc
}
for _, name := range []string{"servers", "browse", "play"} {
if _, ok := sub[name]; !ok {
t.Errorf("expected subcommand %q to be registered", name)
}
}
}
// TestLibraryServersFlags checks the flags on `library servers`.
func TestLibraryServersFlags(t *testing.T) {
cmd := libraryCommand()
for _, s := range cmd.Subcommands {
if s.Name != "servers" {
continue
}
flags := flagNames(s.Flags)
for _, want := range []string{"timeout", "via-speaker"} {
if !contains(flags, want) {
t.Errorf("servers subcommand missing flag %q; got %v", want, flags)
}
}
return
}
t.Fatal("servers subcommand not found")
}
// TestLibraryBrowseFlags checks the flags on `library browse`.
func TestLibraryBrowseFlags(t *testing.T) {
cmd := libraryCommand()
for _, s := range cmd.Subcommands {
if s.Name != "browse" {
continue
}
flags := flagNames(s.Flags)
for _, want := range []string{"udn", "object", "start", "count", "timeout"} {
if !contains(flags, want) {
t.Errorf("browse subcommand missing flag %q; got %v", want, flags)
}
}
return
}
t.Fatal("browse subcommand not found")
}
// TestLibraryPlayFlags checks the flags on `library play`.
func TestLibraryPlayFlags(t *testing.T) {
cmd := libraryCommand()
for _, s := range cmd.Subcommands {
if s.Name != "play" {
continue
}
flags := flagNames(s.Flags)
// source-account and location are required; name, type, art are optional.
for _, want := range []string{"source-account", "location", "name", "type", "art"} {
if !contains(flags, want) {
t.Errorf("play subcommand missing flag %q; got %v", want, flags)
}
}
// Old URL-mode flags must no longer be present.
for _, gone := range []string{"url", "mode"} {
if contains(flags, gone) {
t.Errorf("play subcommand should not have flag %q", gone)
}
}
return
}
t.Fatal("play subcommand not found")
}
// flagNames extracts the primary Name from each flag in a slice.
func flagNames(flags []cli.Flag) []string {
names := make([]string, 0, len(flags))
for _, f := range flags {
names = append(names, getFlagName(f))
}
return names
}
// contains reports whether needle is in haystack.
func contains(haystack []string, needle string) bool {
for _, s := range haystack {
if s == needle {
return true
}
}
return false
}
+324 -17
View File
@@ -15,6 +15,7 @@ import (
"github.com/gesellix/bose-soundtouch/pkg/models"
"github.com/gesellix/bose-soundtouch/pkg/service/constants"
"github.com/gesellix/bose-soundtouch/pkg/service/datastore"
"github.com/gesellix/bose-soundtouch/pkg/service/setup"
"github.com/urfave/cli/v2"
"golang.org/x/term"
@@ -52,10 +53,12 @@ func setupCommand() *cli.Command {
setupRemoteServicesCmd(),
setupInstallCACmd(),
setupMigrateCmd(),
setupRevertCmd(),
setupRebootCmd(),
setupVerifyCmd(),
setupPlanCmd(),
setupPairCmd(),
setupSyncCmd(),
},
}
}
@@ -517,8 +520,13 @@ func setupSSHCheckCmd() *cli.Command {
if err != nil {
PrintError(fmt.Sprintf("port 22 not reachable: %v", err))
fmt.Println()
fmt.Println("Modern SoundTouch firmware (27.x) does not let us enable SSH from")
fmt.Println("telnet — those commands were removed. To enable SSH on the speaker:")
fmt.Println("Try enabling it over telnet first — this works on many (not all) FW 27.x")
fmt.Println("speakers via the port-17000 envswitch trick (#471):")
fmt.Println(" soundtouch-cli setup enable-ssh")
fmt.Println("For stubborn devices (ST Portable, CineMate 520) where the default")
fmt.Println("injection is accepted but sshd never starts, add --full-config.")
fmt.Println()
fmt.Println("If enable-ssh doesn't work on this device, fall back to the USB-stick method:")
fmt.Println(" 1. Format a FAT32 USB stick.")
fmt.Println(" 2. Create an empty file named `remote_services` at its root.")
fmt.Println(" 3. Plug the stick into the speaker (rear USB port) while it is on.")
@@ -538,6 +546,98 @@ func setupSSHCheckCmd() *cli.Command {
}
}
// runEnableSSHInjection runs the port-17000 SSH-enable injection over telnet,
// printing the device transcript as it goes. With fullConfig it sends the
// #515 sequence (all four config URLs with the injection on margeServerUrl, not
// just envswitch), pausing commandDelay between each of the 6 steps (5
// commands + reboot) — see setup.DefaultTelnetCommandDelay for why the pause
// exists — then reboots; otherwise it sends the single-envswitch default that
// fires on the speaker's next boseurls check (no pause needed, it's one
// command).
func runEnableSSHInjection(m *setup.Manager, host, serviceURL string, fullConfig bool, commandDelay time.Duration) error {
var (
logs string
err error
)
if fullConfig {
// 6 steps total (5 commands + reboot), so 6 gaps between/around them.
fmt.Printf("Enabling SSH on %s via telnet :17000 (full #515 sequence: all four config URLs with "+
"the injection on margeServerUrl, %s between each of 6 steps — about %s before the reboot fires "+
"— then reboot)...\n", host, commandDelay, 6*commandDelay)
logs, err = m.EnableSSHViaTelnetFullConfig(host, serviceURL, commandDelay)
} else {
fmt.Printf("Enabling SSH on %s via telnet :17000 (runs on the speaker's next boseurls check, up to ~60s)...\n", host)
logs, err = m.EnableSSHViaTelnet(host, serviceURL)
}
if logs != "" {
fmt.Print(logs)
}
if err != nil {
PrintError(err.Error())
return err
}
if !fullConfig {
return nil
}
if commandDelay > 0 {
time.Sleep(commandDelay)
}
fmt.Println("Rebooting the speaker to apply the new configuration...")
rlogs, rerr := m.Reboot(host, setup.RebootMethodTelnet)
if rlogs != "" {
fmt.Print(rlogs)
}
if rerr != nil {
PrintError(rerr.Error())
return rerr
}
return nil
}
// ensureMargeAccountPaired checks /info and pairs an unpaired device before
// the SSH-enable injection runs — see setup.EnsureMargeAccountPaired for why.
// Pairing failure is logged as a warning, not fatal: the claim that an
// unpaired device never polls margeServerUrl is not yet confirmed on every
// device this command targets, so the injection is still worth attempting
// even if the pairing step itself couldn't be verified.
func ensureMargeAccountPaired(m *setup.Manager, deviceIP, wantAccountID string) {
var t setup.TelnetClient
if m.NewTelnet != nil {
t = m.NewTelnet(deviceIP)
if dialErr := t.Dial(); dialErr != nil {
t = nil
} else {
defer func() { _ = t.Close() }()
}
}
accountID, alreadyPaired, logs, err := m.EnsureMargeAccountPaired(deviceIP, wantAccountID, t)
if logs != "" {
fmt.Print(logs)
}
switch {
case err != nil:
PrintWarning(fmt.Sprintf("Pairing check failed (%v) — continuing anyway; the SSH-enable injection may not "+
"fire on an unpaired device (#515).", err))
case alreadyPaired:
fmt.Printf("Device already paired (margeAccountUUID=%s).\n", accountID)
default:
fmt.Printf("Device was unpaired — paired it with generated account %s so margeServerUrl gets polled (#515).\n", accountID)
}
}
func setupEnableSSHCmd() *cli.Command {
return &cli.Command{
Name: "enable-ssh",
@@ -556,6 +656,29 @@ func setupEnableSSHCmd() *cli.Command {
Value: 90 * time.Second,
Usage: "How long to wait for sshd (:22) after the envswitch injection (it runs on the speaker's next boseurls check, ~60s)",
},
&cli.BoolFlag{
Name: "full-config",
Usage: "For stubborn devices (ST Portable, CineMate 520) where the default single-envswitch injection is accepted but sshd never starts: " +
"replicate the #515 manual sequence — write all four sys configuration URL keys with the SSH-enable injection on margeServerUrl (not just envswitch), then reboot",
},
&cli.DurationFlag{
Name: "command-delay",
Value: setup.DefaultTelnetCommandDelay,
Usage: "Only affects --full-config: pause between each of its 6 steps (5 commands + reboot). " +
"Raise this if the default doesn't work on your device; 0 sends everything back-to-back",
},
&cli.BoolFlag{
Name: "no-auto-pair",
Usage: "Skip the automatic pairing check: by default, enable-ssh reads /info first and pairs an unpaired " +
"(factory-reset) device with an account ID, since an unpaired device reportedly never " +
"polls margeServerUrl at all (#515) — the injection would have nothing to fire on otherwise",
},
&cli.StringFlag{
Name: "account",
Usage: "Only used when the device is unpaired and --no-auto-pair is not set: account ID to pair with " +
"(empty = generate a fresh 7-digit one). Use this if you already know which account this device " +
"should end up on (e.g. to match one already in the datastore) rather than getting a random one now",
},
&cli.BoolFlag{
Name: "no-reset-urls",
Usage: "Skip restoring clean boseurls after SSH is up (leaves the injected marge URL in place)",
@@ -589,23 +712,36 @@ func setupEnableSSHCmd() *cli.Command {
serviceURL = "https://aftertouch.invalid"
}
fmt.Printf("Enabling SSH on %s via telnet :17000 (runs on the speaker's next boseurls check, up to ~60s)...\n", cfg.Host)
logs, err := m.EnableSSHViaTelnet(cfg.Host, serviceURL)
if logs != "" {
fmt.Print(logs)
if !c.Bool("no-auto-pair") {
ensureMargeAccountPaired(m, cfg.Host, c.String("account"))
}
if err != nil {
PrintError(err.Error())
if err := runEnableSSHInjection(m, cfg.Host, serviceURL, c.Bool("full-config"), c.Duration("command-delay")); err != nil {
return err
}
fmt.Printf("Waiting up to %s for sshd (:22) to come up...\n", c.Duration("wait"))
if err := setup.WaitForSSHPort(cfg.Host, c.Duration("wait")); err != nil {
PrintError(err.Error())
return err
// Not a hard failure: on some devices (e.g. the Wireless Link
// Adapter, see #471) the envswitch injection is accepted but
// sshd only actually starts after the speaker restarts. We
// deliberately leave the injected boseurls in place (no reset)
// so a power-cycle re-triggers the unlock, and guide the user
// to reboot and retry rather than exiting with an error.
fmt.Println()
PrintWarning(fmt.Sprintf("sshd (:22) did not come up within %s, but the speaker accepted the SSH-enable command.", c.Duration("wait")))
fmt.Println("On some devices sshd only starts after a restart. Next steps:")
fmt.Println(" 1. Power-cycle the speaker (unplug it, wait a few seconds, plug it back in).")
fmt.Println(" 2. Once it is back online, run this same command again, or just connect with:")
fmt.Printf(" ssh -o HostKeyAlgorithms=+ssh-rsa,ssh-dss root@%s\n", cfg.Host)
fmt.Println("The temporary boseurls were left in place on purpose, so the restart re-triggers the unlock.")
if placeholder {
fmt.Println("(No --service-url was given; you'll set the real service URLs later during migration.)")
}
return nil
}
PrintSuccess("SSH is up on " + cfg.Host)
@@ -880,6 +1016,116 @@ func promptBasicAuth() (string, string, error) {
return user, string(pass), nil
}
// setupSyncCmd wraps POST /api/setup/sync/{deviceId} — the same operation
// as the web UI's Devices → Sync Data button. It only reads from the
// speaker (presets, recents, sources) into AfterTouch's datastore; it never
// writes anything back to the speaker. Useful for scripting or reproducing
// what Sync does in isolation (see issue #614: Sync's own code cannot wipe
// the speaker's preset table, since it never sends anything back).
func setupSyncCmd() *cli.Command {
return &cli.Command{
Name: "sync",
Usage: "Pull presets/recents/sources from the speaker into AfterTouch's datastore (same as the web UI's \"Sync Data\" button)",
Before: RequireHost,
Flags: []cli.Flag{
&cli.StringFlag{Name: "service-url", Required: true, Usage: "AfterTouch base URL"},
&cli.StringFlag{Name: "auth", Usage: "Basic-auth credentials for AfterTouch as user:pass (omit to be prompted on 401)"},
},
Action: func(c *cli.Context) error {
cfg := GetClientConfig(c)
serviceURL := strings.TrimRight(c.String("service-url"), "/")
if err := validateServiceURL(serviceURL); err != nil {
PrintError(err.Error())
return err
}
client, err := CreateSoundTouchClient(cfg)
if err != nil {
PrintError(fmt.Sprintf("Failed to create client: %v", err))
return err
}
deviceInfo, err := client.GetDeviceInfo()
if err != nil {
PrintError(fmt.Sprintf("Failed to get device info from speaker: %v", err))
return err
}
if deviceInfo.DeviceID == "" {
err := fmt.Errorf("speaker at %s did not report a DeviceID", cfg.Host)
PrintError(err.Error())
return err
}
PrintDeviceHeader(fmt.Sprintf("Syncing %s into AfterTouch", deviceInfo.DeviceID), cfg.Host, cfg.Port)
if err := postSetupSync(serviceURL, deviceInfo.DeviceID, c.String("auth")); err != nil {
PrintError(err.Error())
return err
}
PrintSuccess(fmt.Sprintf("Synced presets, recents, and sources for %s.", deviceInfo.DeviceID))
return nil
},
}
}
// postSetupSync POSTs to AfterTouch's /api/setup/sync/{deviceId}, prompting
// for basic-auth credentials on 401 (matches fetchCACert's pattern).
func postSetupSync(serviceURL, deviceID, authFlag string) error {
endpoint := fmt.Sprintf("%s/api/setup/sync/%s", serviceURL, deviceID)
doRequest := func(user, pass string) (*http.Response, error) {
req, err := http.NewRequest(http.MethodPost, endpoint, nil)
if err != nil {
return nil, err
}
if user != "" {
req.SetBasicAuth(user, pass)
}
client := &http.Client{Timeout: 30 * time.Second}
return client.Do(req)
}
user, pass := splitAuth(authFlag)
resp, err := doRequest(user, pass)
if err != nil {
return fmt.Errorf("POST %s: %w", endpoint, err)
}
if resp.StatusCode == http.StatusUnauthorized {
_ = resp.Body.Close()
fmt.Printf("%s requires basic auth.\n", endpoint)
user, pass, err = promptBasicAuth()
if err != nil {
return err
}
resp, err = doRequest(user, pass)
if err != nil {
return fmt.Errorf("POST %s (with auth): %w", endpoint, err)
}
}
defer func() { _ = resp.Body.Close() }()
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
body, _ := io.ReadAll(resp.Body)
return fmt.Errorf("POST %s returned %d: %s", endpoint, resp.StatusCode, strings.TrimSpace(string(body)))
}
return nil
}
func setupMigrateCmd() *cli.Command {
return &cli.Command{
Name: "migrate",
@@ -890,6 +1136,10 @@ func setupMigrateCmd() *cli.Command {
&cli.StringFlag{Name: "method", Value: string(setup.MigrationMethodTelnet), Usage: "telnet | hosts | resolv | xml"},
&cli.StringFlag{Name: "proxy-url", Usage: "Optional upstream proxy URL (for --method=xml)"},
&cli.BoolFlag{Name: "skip-preflight", Usage: "Skip the AfterTouch settings preflight (use when AfterTouch's settings endpoint is unreachable)"},
&cli.StringFlag{Name: "marge-url", Usage: "Override margeServerUrl instead of deriving it from --service-url (e.g. to restore the original Bose cloud URL). Applies to --method=telnet and --method=xml"},
&cli.StringFlag{Name: "stats-url", Usage: "Override statsServerUrl (telnet/xml)"},
&cli.StringFlag{Name: "sw-update-url", Usage: "Override swUpdateUrl (telnet/xml)"},
&cli.StringFlag{Name: "bmx-url", Usage: "Override bmxRegistryUrl (telnet/xml)"},
},
Action: func(c *cli.Context) error {
cfg := GetClientConfig(c)
@@ -901,6 +1151,13 @@ func setupMigrateCmd() *cli.Command {
return err
}
options := map[string]string{
"marge_url": c.String("marge-url"),
"stats_url": c.String("stats-url"),
"sw_update_url": c.String("sw-update-url"),
"bmx_url": c.String("bmx-url"),
}
m := setup.NewManager(serviceURL, nil, nil)
// For DNS-redirect methods check that AfterTouch's DNS listener
@@ -924,7 +1181,7 @@ func setupMigrateCmd() *cli.Command {
fmt.Printf("Migrating %s → %s using method=%s\n", cfg.Host, serviceURL, method)
logs, err := m.MigrateSpeaker(cfg.Host, serviceURL, c.String("proxy-url"), nil, method)
logs, err := m.MigrateSpeaker(cfg.Host, serviceURL, c.String("proxy-url"), options, method)
if logs != "" {
fmt.Print(logs)
}
@@ -1253,6 +1510,45 @@ func renderMigrationSummary(deviceIP, serviceURL string, s *setup.MigrationSumma
}
}
// setupRevertCmd wraps setup.Manager.RevertMigration — the same operation
// as the web UI's "Revert to Defaults" button (Migrate tab). Restores
// SoundTouchSdkPrivateCfg.xml, /etc/hosts, and /etc/resolv.conf from their
// .original backups, removes the AfterTouch DNS-hook artifacts, and strips
// just the AfterTouch-labeled cert out of the trust bundle. No --service-url
// needed: everything it touches already lives on the speaker.
//
// Deliberately out of scope (matches the web UI button): SSH/remote_services
// persistence (use `setup remote-services --remove`) and account pairing
// (use `account unpair`) — see #614 self-test notes for the full checklist.
func setupRevertCmd() *cli.Command {
return &cli.Command{
Name: "revert",
Usage: "Undo a migration: restore SoundTouchSdkPrivateCfg.xml/hosts/resolv.conf from backups and remove the AfterTouch CA cert",
Before: RequireHost,
Action: func(c *cli.Context) error {
cfg := GetClientConfig(c)
m := setup.NewManager("", nil, nil)
fmt.Printf("Reverting migration on %s...\n", cfg.Host)
logs, err := m.RevertMigration(cfg.Host)
if logs != "" {
fmt.Print(logs)
}
if err != nil {
PrintError(err.Error())
return err
}
PrintSuccess("Migration reverted. SSH access and account pairing are untouched by this — " +
"see `setup remote-services --remove` and `account unpair` if you want those cleared too.")
return nil
},
}
}
func setupRebootCmd() *cli.Command {
return &cli.Command{
Name: "reboot",
@@ -1679,11 +1975,11 @@ func setupPairCmd() *cli.Command {
Usage: "Pair the speaker with an account via WebSocket SETUP state machine",
Before: RequireHost,
Flags: []cli.Flag{
&cli.StringFlag{Name: "account", Usage: "7-digit account ID (empty = generate)"},
&cli.StringFlag{Name: "account", Usage: "Account ID to pair with (empty = generate a fresh 7-digit one)"},
&cli.StringFlag{Name: "mode", Value: "full", Usage: "full (state machine) or bare (setMargeAccount only — experimental)"},
&cli.StringFlag{Name: "service-url", Value: "http://aftertouch.local:8000", Usage: "AfterTouch base URL (also populates <boseServer>/<updateServer> in setMargeAccount)"},
&cli.StringFlag{Name: "name", Usage: "Speaker name to set during pairing (empty = keep current)"},
&cli.IntFlag{Name: "language", Value: setup.LanguageEnglish, Usage: "sysLanguage code (2 = English)"},
&cli.IntFlag{Name: "language", Value: setup.LanguageEnglish, Usage: "sysLanguage code (3 = English)"},
&cli.DurationFlag{Name: "step-timeout", Value: 8 * time.Second},
&cli.StringFlag{Name: "token", Usage: "userAuthToken value (empty = use built-in placeholder matching the Bose app token shape)"},
},
@@ -1703,8 +1999,8 @@ func setupPairCmd() *cli.Command {
fmt.Printf("Generated account id: %s\n", accountID)
}
if !setup.IsValidAccountID(accountID) {
return fmt.Errorf("invalid account id %q: must be 7 digits", accountID)
if !datastore.IsSafeIdentifier(accountID) {
return fmt.Errorf("invalid account id %q: must be a non-empty, path-safe identifier", accountID)
}
switch mode {
@@ -1786,6 +2082,17 @@ func runPairBare(c *cli.Context, deviceIP, accountID string) error {
func runPairFull(c *cli.Context, deviceIP, accountID string) error {
m := setup.NewManager(c.String("service-url"), nil, nil)
needed, status, err := m.PreflightInitPlan(deviceIP)
if err != nil {
PrintError(fmt.Sprintf("preflight: %v", err))
return err
}
if !needed {
PrintSuccess(fmt.Sprintf("Device already configured (status=%s) — nothing to do.", status))
return nil
}
plan := setup.InitPlan{
DeviceIP: deviceIP,
ServiceURL: c.String("service-url"),
@@ -1799,7 +2106,7 @@ func runPairFull(c *cli.Context, deviceIP, accountID string) error {
ctx, cancel := context.WithTimeout(c.Context, 60*time.Second)
defer cancel()
_, err := m.ExecuteInitPlan(ctx, plan, func(e setup.StepEvent) {
_, err = m.ExecuteInitPlan(ctx, plan, func(e setup.StepEvent) {
switch e.Status {
case setup.StatusOK:
fmt.Printf("[%d] %s — ok\n", e.Kind, e.Name)
+42
View File
@@ -3,6 +3,8 @@ package main
import (
"bytes"
"io"
"net/http"
"net/http/httptest"
"os"
"strings"
"testing"
@@ -43,6 +45,46 @@ func captureStdout(t *testing.T, fn func()) string {
return buf.String()
}
func TestPostSetupSync_PostsToDeviceScopedURL(t *testing.T) {
var gotMethod, gotPath string
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
gotMethod = r.Method
gotPath = r.URL.Path
w.WriteHeader(http.StatusOK)
_, _ = w.Write([]byte(`{"ok": true}`))
}))
defer srv.Close()
if err := postSetupSync(srv.URL, "DEVICEID01", ""); err != nil {
t.Fatalf("postSetupSync: %v", err)
}
if gotMethod != http.MethodPost {
t.Errorf("expected POST, got %s", gotMethod)
}
if want := "/api/setup/sync/DEVICEID01"; gotPath != want {
t.Errorf("expected path %q, got %q", want, gotPath)
}
}
func TestPostSetupSync_PropagatesServerError(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
http.Error(w, "device not found", http.StatusNotFound)
}))
defer srv.Close()
err := postSetupSync(srv.URL, "DEVICEID01", "")
if err == nil {
t.Fatal("expected an error for a 404 response")
}
if !strings.Contains(err.Error(), "device not found") {
t.Errorf("expected error to include server body, got %q", err.Error())
}
}
func TestRenderSourceTable_AlignsColumnsAndDedupsDisplayName(t *testing.T) {
items := []models.SourceItem{
// displayName != account → kept as "AUX (AUX IN)"
+39
View File
@@ -136,6 +136,40 @@ func playURL(c *cli.Context) error {
return nil
}
// playURLUPnP plays audio from a URL via the speaker's UPnP AVTransport service.
// Unlike `speaker url` (the /speaker play_info path), it needs no app-key and no
// DNS interception, so it works on a plain LAN. It switches the speaker to the
// UPNP source and replaces the current playback (no duck-and-resume), and the
// speaker itself must be able to reach the URL.
func playURLUPnP(c *cli.Context) error {
clientConfig := GetClientConfig(c)
urlStr := c.String("url")
if urlStr == "" {
PrintError("URL is required")
return fmt.Errorf("URL cannot be empty")
}
PrintDeviceHeader(fmt.Sprintf("Playing URL via UPnP: %s", urlStr), clientConfig.Host, clientConfig.Port)
client, err := CreateSoundTouchClient(clientConfig)
if err != nil {
PrintError(fmt.Sprintf("Failed to create client: %v", err))
return err
}
if err := client.PlayURLViaUPnP(urlStr); err != nil {
PrintError(fmt.Sprintf("Failed to play URL via UPnP: %v", err))
return err
}
fmt.Printf("✅ URL playback started via UPnP\n")
fmt.Printf(" URL: %s\n", urlStr)
fmt.Printf(" Note: replaces the current source (UPNP); no app-key or DNS needed\n")
return nil
}
// playNotification plays a notification sound or a local file on the speaker
func playNotification(c *cli.Context) error {
clientConfig := GetClientConfig(c)
@@ -193,6 +227,11 @@ func showSpeakerHelp(_ *cli.Context) error {
fmt.Println(" Play audio files from HTTP/HTTPS URLs")
fmt.Println(" Example: soundtouch-cli speaker url --url \"https://example.com/audio.mp3\" --app-key YOUR_KEY")
fmt.Println()
fmt.Println("• URL via UPnP/AVTransport (no app key, no DNS):")
fmt.Println(" Play an http:// audio URL directly via the speaker's UPnP renderer.")
fmt.Println(" Replaces the current source; http:// only (https is rejected).")
fmt.Println(" Example: soundtouch-cli speaker url-upnp --url \"http://192.0.2.10/audio.mp3\"")
fmt.Println()
fmt.Println("• Notification Beep:")
fmt.Println(" Play a simple notification sound")
fmt.Println(" Example: soundtouch-cli speaker beep")
+56
View File
@@ -0,0 +1,56 @@
package main
import (
"fmt"
"github.com/gesellix/bose-soundtouch/pkg/service/updatecheck"
"github.com/urfave/cli/v2"
)
// updateCheckRepo is the GitHub repo checked for newer releases, matching
// soundtouch-service's periodic background check (#591,
// _/i591/design-update-check.md).
const updateCheckRepo = "gesellix/Bose-SoundTouch"
// updateCheckCommand assembles the on-demand `soundtouch-cli update-check`
// command, the CLI-side answer to that design doc's open question 2
// (CLI-only users get no update notice from the service's background
// checker). Unlike the service's opt-in periodic check, running this
// command *is* the opt-in: no config flag, no persisted state, just one
// GitHub API request each time it's invoked.
func updateCheckCommand() *cli.Command {
return &cli.Command{
Name: "update-check",
Usage: "Check GitHub for a newer soundtouch-cli release",
Action: runUpdateCheck,
}
}
func runUpdateCheck(c *cli.Context) error {
checker := updatecheck.NewChecker(nil, updateCheckRepo, version)
result, err := checker.CheckNow(c.Context)
if err != nil {
return fmt.Errorf("update check failed: %w", err)
}
printUpdateCheckResult(result)
return nil
}
func printUpdateCheckResult(result updatecheck.Result) {
if result.LatestVersion == "" {
fmt.Printf("Running %s, not a released version, skipping comparison.\n", result.CurrentVersion)
return
}
if result.Available {
fmt.Printf("A newer version is available: %s (you're on %s)\n", result.LatestVersion, result.CurrentVersion)
fmt.Println(result.ReleaseURL)
return
}
fmt.Printf("You're on the latest version (%s).\n", result.CurrentVersion)
}
@@ -0,0 +1,42 @@
package main
import (
"testing"
"github.com/gesellix/bose-soundtouch/pkg/service/updatecheck"
)
// TestUpdateCheckCommand_Registered checks the command is wired up with the
// expected name and an Action, without making any real GitHub API calls.
func TestUpdateCheckCommand_Registered(t *testing.T) {
cmd := updateCheckCommand()
if cmd.Name != "update-check" {
t.Errorf("command name = %q; want %q", cmd.Name, "update-check")
}
if cmd.Action == nil {
t.Error("expected an Action to be set")
}
}
// TestPrintUpdateCheckResult_DoesNotPanic exercises all three result shapes
// (unparseable current version, update available, up to date) purely for
// the "does not panic" guarantee; updatecheck.Checker's own tests already
// cover the comparison logic itself.
func TestPrintUpdateCheckResult_DoesNotPanic(t *testing.T) {
cases := []struct {
name string
result updatecheck.Result
}{
{"unparseable current version", updatecheck.Result{CurrentVersion: "dev"}},
{"update available", updatecheck.Result{CurrentVersion: "v1.0.0", LatestVersion: "v1.1.0", Available: true, ReleaseURL: "https://example.invalid"}},
{"up to date", updatecheck.Result{CurrentVersion: "v1.1.0", LatestVersion: "v1.1.0", Available: false}},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
printUpdateCheckResult(tc.result)
})
}
}
+27 -2
View File
@@ -73,8 +73,12 @@ func getFlagName(flag cli.Flag) string {
// updateBuildInfo extracts version information from debug.BuildInfo and updates package variables
func updateBuildInfo() {
if info, ok := debug.ReadBuildInfo(); ok {
// Get version from module info
if info.Main.Version != "" && info.Main.Version != "(devel)" {
// Get version from module info. Only fall back to build info when the
// version was not injected via -ldflags (i.e. still the "dev" default,
// e.g. `go install …@vX.Y.Z`). This keeps an explicitly stamped release
// version from being clobbered by a VCS pseudo-version (e.g. v0.0.0-…
// from a shallow checkout).
if version == "dev" && info.Main.Version != "" && info.Main.Version != "(devel)" {
version = info.Main.Version
}
@@ -1922,6 +1926,20 @@ func main() {
},
},
},
{
Name: "url-upnp",
Usage: "Play a URL via UPnP/AVTransport (no app-key, no DNS; replaces current source)",
Action: playURLUPnP,
Before: RequireHost,
Flags: []cli.Flag{
&cli.StringFlag{
Name: "url",
Aliases: []string{"u"},
Usage: "URL of the audio content to play (must be reachable by the speaker)",
Required: true,
},
},
},
{
Name: "notify",
Usage: "Play a notification sound or local file",
@@ -2317,6 +2335,13 @@ func main() {
// Defined in cmd_cloud.go.
app.Commands = append(app.Commands, cloudCommand())
// DLNA music library (server discovery, browse, play).
// Defined in cmd_library.go.
app.Commands = append(app.Commands, libraryCommand())
// On-demand GitHub release check (#591). Defined in cmd_updatecheck.go.
app.Commands = append(app.Commands, updateCheckCommand())
// Sort commands alphabetically (including subcommands and flags recursively)
sortCommands(app.Commands)
+13 -2
View File
@@ -25,6 +25,9 @@ Based on captured WebSocket interactions and device API capabilities, this web U
- **Real-time status monitoring** via WebSocket connections
- **Multi-device support** with centralized control
- **Connection status** indicators and health monitoring
- **SoundTouch 10 stereo pairs** shown as one target, with verified create,
rename, and dissolve operations across both physical speakers and their
exact persisted group generation
### Playback Control
- **Play/Pause/Stop/Next/Previous** controls
@@ -88,7 +91,7 @@ go build -o soundtouch-player
./soundtouch-player -port 8888
# Connect to specific device
./soundtouch-player -host 192.0.2.100
./soundtouch-player --devices 192.0.2.100
```
### Command Line Options
@@ -137,7 +140,7 @@ device datastore).
The application automatically discovers SoundTouch devices using:
- **mDNS discovery** for local network devices
- **UPnP/SSDP discovery** as fallback
- **Manual device addition** via IP address
- **Configured devices** via `--devices`, retried whenever discovery runs
### Real-time Updates
The interface maintains WebSocket connections to each device for instant updates of:
@@ -345,6 +348,14 @@ Based on WebSocket interaction analysis, potential future features:
- Advanced preset programming
- Progressive Web App (PWA) features
## Behaviour reference
[Player: Sources and Selection State](../../docs/content/docs/reference/PLAYER-SOURCE-BEHAVIOUR.md)
covers the parts that are not obvious from the code: which advertised sources
can actually be selected and which need a station ContentItem, how a selection
is confirmed against a speaker that answers 200 either way, and the revision
and epoch fields the browser uses to order status updates.
## License
Same as the parent project - see main repository LICENSE file.
+17 -9
View File
@@ -4,8 +4,9 @@
// remote AfterTouch service via --service-url, which is why it stays useful
// when soundtouch-service runs off-LAN (e.g. in the cloud).
//
// It was previously named soundtouch-web; that name is still published as a
// transitional alias and will be dropped in a future release.
// It was previously named soundtouch-web; that name is no longer published.
// If you still run the binary under the old name, it prints a rename notice
// and otherwise behaves identically.
package main
import (
@@ -38,7 +39,11 @@ func updateBuildInfo() {
repoURL = "https://" + info.Main.Path
}
if info.Main.Version != "" && info.Main.Version != "(devel)" {
// Only fall back to build info when the version was not injected via
// -ldflags (i.e. still the "dev" default, e.g. `go install …@vX.Y.Z`).
// This keeps an explicitly stamped release version from being clobbered
// by a VCS pseudo-version (e.g. v0.0.0-… from a shallow checkout).
if version == "dev" && info.Main.Version != "" && info.Main.Version != "(devel)" {
version = info.Main.Version
}
@@ -56,9 +61,8 @@ func updateBuildInfo() {
}
// warnIfInvokedAsWeb prints a one-line deprecation notice when the binary is
// run under its old name (soundtouch-web). The soundtouch-web artifact is a
// transitional alias built from this same source; this nudges operators to
// switch to soundtouch-player before the alias is dropped.
// run under its old name (soundtouch-web). That name is no longer published,
// but anyone who renamed the binary still gets nudged to soundtouch-player.
func warnIfInvokedAsWeb() {
if len(os.Args) == 0 {
return
@@ -67,8 +71,7 @@ func warnIfInvokedAsWeb() {
name := filepath.Base(os.Args[0])
if name == "soundtouch-web" || name == "soundtouch-web.exe" {
log.Println("notice: 'soundtouch-web' has been renamed to 'soundtouch-player'. " +
"This name is a transitional alias and will stop being published in a future release; " +
"please switch to 'soundtouch-player'.")
"The 'soundtouch-web' name is no longer published; please switch to 'soundtouch-player'.")
}
}
@@ -158,7 +161,7 @@ func main() {
log.Printf("Trusting AfterTouch service CA from %s", sanitizeLog(caPath))
}
discoveryService := soundtouchweb.NewDiscoveryService(ifaceName)
discoveryService := soundtouchweb.NewDiscoveryService(ifaceName, manualHosts...)
// Discover devices on startup
go func() {
@@ -167,6 +170,11 @@ func main() {
webApp.BroadcastDiscoveryStatus("starting", webApp.DeviceCount())
// Register configured devices immediately rather than waiting for
// the full mDNS/UPnP sweep below (bounded by cfg.DiscoveryTimeout,
// currently 10s) to complete. manualHosts are also folded into
// discoveryService's PreferredDevices so a host that's offline
// right now still gets retried on every subsequent discovery pass.
for _, host := range manualHosts {
webApp.AddDeviceByHost(host, 8090, "manual")
}
@@ -0,0 +1,117 @@
package main
import (
"net/http"
"net/http/httptest"
"path/filepath"
"testing"
"github.com/gesellix/bose-soundtouch/pkg/service/certmanager"
"github.com/gesellix/bose-soundtouch/pkg/service/datastore"
"github.com/gesellix/bose-soundtouch/pkg/service/handlers"
"github.com/gesellix/bose-soundtouch/pkg/service/setup"
)
// TestAdminAreaAuthGate is the wiring-level regression test for #419: it
// exercises the real production router (setupRouter), not just the
// BasicAuthAdmin middleware in isolation, to pin two things at once:
// 1. /admin and /api/setup/* (and their /setup/* legacy aliases) are open
// by default and become gated once AdminAreaAuth is "enabled".
// 2. A handful of routes deliberately stay reachable WITHOUT credentials
// regardless of the gate: ca.crt/tts/speak/tts/config because
// soundtouch-cli/soundtouch-player call them directly (the whole reason
// mountSetupAPI was split into mountSetupAPIShared/mountSetupAPIAdmin),
// and /api/announcements because it specifically needs to reach
// operators who haven't set up credentials yet.
func TestAdminAreaAuthGate(t *testing.T) {
tempDir := t.TempDir()
ds := datastore.NewDataStore(tempDir)
_ = ds.Initialize()
// A real setup.Manager (with an actual CA) so /setup/ca.crt genuinely
// succeeds instead of failing on a nil dependency for an unrelated
// reason, which would make the "stays reachable" assertion meaningless.
cm := certmanager.NewCertificateManager(filepath.Join(tempDir, "certs"))
_ = cm.EnsureCA()
sm := setup.NewManager("http://localhost:8000", ds, cm)
server := handlers.NewServer(ds, sm, "http://localhost:8000", true, false, false)
server.SetMgmtConfig("custom-admin", "custom-password")
r := setupRouter(server, nil, nil)
ts := httptest.NewServer(r)
defer ts.Close()
adminGatedPaths := []string{
"/admin",
"/setup/settings",
"/api/setup/settings",
}
alwaysUngatedPaths := []string{
"/setup/ca.crt",
"/api/setup/ca.crt",
"/setup/tts/config",
"/api/setup/tts/config",
"/api/announcements?target=admin",
}
t.Run("open by default (AdminAreaAuth unset)", func(t *testing.T) {
for _, path := range adminGatedPaths {
status := getStatus(t, ts.URL, path, "", "")
if status == http.StatusUnauthorized {
t.Errorf("%s: expected open access by default, got 401", path)
}
}
})
server.SetAdminAreaAuth("enabled")
defer server.SetAdminAreaAuth("")
t.Run("gated paths reject without credentials once enabled", func(t *testing.T) {
for _, path := range adminGatedPaths {
status := getStatus(t, ts.URL, path, "", "")
if status != http.StatusUnauthorized {
t.Errorf("%s: expected 401 without credentials once enabled, got %d", path, status)
}
}
})
t.Run("gated paths accept correct credentials once enabled", func(t *testing.T) {
for _, path := range adminGatedPaths {
status := getStatus(t, ts.URL, path, "custom-admin", "custom-password")
if status == http.StatusUnauthorized {
t.Errorf("%s: expected access with correct credentials, got 401", path)
}
}
})
t.Run("routes intentionally left outside the gate stay reachable without credentials", func(t *testing.T) {
for _, path := range alwaysUngatedPaths {
status := getStatus(t, ts.URL, path, "", "")
if status != http.StatusOK {
t.Errorf("%s: expected 200 without credentials even with the gate enabled, got %d", path, status)
}
}
})
}
func getStatus(t *testing.T, base, path, user, pass string) int {
t.Helper()
req, err := http.NewRequest(http.MethodGet, base+path, nil)
if err != nil {
t.Fatalf("Failed to build request for %s: %v", path, err)
}
if user != "" || pass != "" {
req.SetBasicAuth(user, pass)
}
res, err := http.DefaultClient.Do(req)
if err != nil {
t.Fatalf("Request to %s failed: %v", path, err)
}
defer res.Body.Close()
return res.StatusCode
}
File diff suppressed because it is too large Load Diff
+267
View File
@@ -1,12 +1,199 @@
package main
import (
"flag"
"os"
"path/filepath"
"strings"
"testing"
"time"
"github.com/gesellix/bose-soundtouch/pkg/service/datastore"
"github.com/urfave/cli/v2"
)
// newTestServiceContext builds a real *cli.Context against serviceFlags (the
// exact flags soundtouch-service registers), so loadConfig tests exercise the
// same parsing/env-var wiring production code does, instead of a hand-rolled
// stand-in that could silently drift from it.
func newTestServiceContext(t *testing.T, args ...string) *cli.Context {
t.Helper()
app := &cli.App{Flags: serviceFlags}
set := flag.NewFlagSet("test", flag.ContinueOnError)
for _, f := range serviceFlags {
if err := f.Apply(set); err != nil {
t.Fatalf("apply flag %v: %v", f.Names(), err)
}
}
if err := set.Parse(args); err != nil {
t.Fatalf("parse args %v: %v", args, err)
}
return cli.NewContext(app, set, nil)
}
func TestResolveFallbackHost(t *testing.T) {
hostname, _ := os.Hostname()
if hostname == "" {
hostname = "localhost"
}
hostname = strings.ToLower(hostname)
cases := []struct {
name string
deploymentMode string
wantHost string
wantWarn bool
}{
{"on-device uses localhost, no warning", "on-device", "localhost", false},
{"public-network returns no fallback, no warning (caller must fail fast)", "public-network", "", false},
{"private-network uses this host's own hostname, with warning", "private-network", hostname, true},
{"unset/legacy behaves like private-network", "", hostname, true},
{"unrecognized mode behaves like private-network", "some-typo", hostname, true},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
gotHost, gotWarn := resolveFallbackHost(tc.deploymentMode)
if gotHost != tc.wantHost {
t.Errorf("host: got %q, want %q", gotHost, tc.wantHost)
}
if gotWarn != tc.wantWarn {
t.Errorf("warnOnUse: got %v, want %v", gotWarn, tc.wantWarn)
}
})
}
}
func TestLoadConfig_DeviceSeedRetryTuning(t *testing.T) {
t.Run("defaults", func(t *testing.T) {
config, err := loadConfig(newTestServiceContext(t))
if err != nil {
t.Fatalf("loadConfig() error = %v", err)
}
if config.deviceSeedRetryInterval != 30*time.Second {
t.Errorf("deviceSeedRetryInterval = %s, want 30s", config.deviceSeedRetryInterval)
}
if config.deviceSeedRetryWindow != 10*time.Minute {
t.Errorf("deviceSeedRetryWindow = %s, want 10m", config.deviceSeedRetryWindow)
}
})
t.Run("flags override the defaults", func(t *testing.T) {
config, err := loadConfig(newTestServiceContext(t,
"--device-seed-retry-interval=5s",
"--device-seed-retry-window=1m"))
if err != nil {
t.Fatalf("loadConfig() error = %v", err)
}
if config.deviceSeedRetryInterval != 5*time.Second {
t.Errorf("deviceSeedRetryInterval = %s, want 5s", config.deviceSeedRetryInterval)
}
if config.deviceSeedRetryWindow != time.Minute {
t.Errorf("deviceSeedRetryWindow = %s, want 1m", config.deviceSeedRetryWindow)
}
})
t.Run("unparseable values fall back to the defaults", func(t *testing.T) {
config, err := loadConfig(newTestServiceContext(t,
"--device-seed-retry-interval=not-a-duration",
"--device-seed-retry-window=also-not-a-duration"))
if err != nil {
t.Fatalf("loadConfig() error = %v", err)
}
if config.deviceSeedRetryInterval != 30*time.Second {
t.Errorf("deviceSeedRetryInterval = %s, want fallback 30s", config.deviceSeedRetryInterval)
}
if config.deviceSeedRetryWindow != 10*time.Minute {
t.Errorf("deviceSeedRetryWindow = %s, want fallback 10m", config.deviceSeedRetryWindow)
}
})
}
func TestLoadConfig_DeploymentMode(t *testing.T) {
t.Run("on-device with no --server-url defaults to localhost", func(t *testing.T) {
config, err := loadConfig(newTestServiceContext(t, "--deployment-mode=on-device", "--port=8000"))
if err != nil {
t.Fatalf("loadConfig: unexpected error: %v", err)
}
if config.serverURL != "http://localhost:8000" {
t.Errorf("serverURL: got %q, want %q", config.serverURL, "http://localhost:8000")
}
if config.httpsDefaultURL != "https://localhost:8443" {
t.Errorf("httpsDefaultURL: got %q, want %q", config.httpsDefaultURL, "https://localhost:8443")
}
})
t.Run("public-network with no --server-url fails fast instead of guessing", func(t *testing.T) {
_, err := loadConfig(newTestServiceContext(t, "--deployment-mode=public-network"))
if err == nil {
t.Fatal("expected an error, got nil")
}
if !strings.Contains(err.Error(), "public-network") {
t.Errorf("expected error to mention public-network, got: %v", err)
}
})
t.Run("public-network with an explicit --server-url succeeds", func(t *testing.T) {
config, err := loadConfig(newTestServiceContext(t,
"--deployment-mode=public-network", "--server-url=https://soundtouch.example.com"))
if err != nil {
t.Fatalf("loadConfig: unexpected error: %v", err)
}
if config.serverURL != "https://soundtouch.example.com" {
t.Errorf("serverURL: got %q, want %q", config.serverURL, "https://soundtouch.example.com")
}
})
t.Run("unset deployment-mode with no --server-url keeps today's hostname fallback", func(t *testing.T) {
hostname, _ := os.Hostname()
if hostname == "" {
hostname = "localhost"
}
hostname = strings.ToLower(hostname)
config, err := loadConfig(newTestServiceContext(t, "--port=8000"))
if err != nil {
t.Fatalf("loadConfig: unexpected error: %v", err)
}
want := "http://" + hostname + ":8000"
if config.serverURL != want {
t.Errorf("serverURL: got %q, want %q (legacy installs must keep working without --deployment-mode)", config.serverURL, want)
}
})
t.Run("explicit --server-url always wins regardless of deployment-mode", func(t *testing.T) {
for _, mode := range []string{"", "on-device", "private-network", "public-network"} {
config, err := loadConfig(newTestServiceContext(t,
"--deployment-mode="+mode, "--server-url=http://198.51.100.7:8000"))
if err != nil {
t.Fatalf("mode %q: loadConfig: unexpected error: %v", mode, err)
}
if config.serverURL != "http://198.51.100.7:8000" {
t.Errorf("mode %q: serverURL: got %q, want explicit override unchanged", mode, config.serverURL)
}
}
})
}
func TestApplyPersistedSettings(t *testing.T) {
tmpDir, err := os.MkdirTemp("", "main-test")
if err != nil {
@@ -187,3 +374,83 @@ func contains(haystack []string, needle string) bool {
return false
}
func TestSettingsFileExists(t *testing.T) {
dir := t.TempDir()
if settingsFileExists(dir) {
t.Fatal("expected false for a dir without settings.json")
}
if err := os.WriteFile(filepath.Join(dir, "settings.json"), []byte("{}"), 0o644); err != nil {
t.Fatalf("write settings.json: %v", err)
}
if !settingsFileExists(dir) {
t.Fatal("expected true once settings.json is present")
}
if settingsFileExists("") {
t.Fatal("expected false for an empty data dir")
}
}
// applyFirstRunSeed mirrors the startup gate in the CLI Action: a default
// settings.json is written only when none exists yet, so a hand-authored file
// is never clobbered.
func applyFirstRunSeed(ds *datastore.DataStore, config *serviceConfig) {
existed := settingsFileExists(config.dataDir)
applyPersistedSettings(ds, config)
if !existed {
createDefaultSettings(ds, *config)
}
}
func TestFirstRunSeed_PreservesHandAuthoredSettings(t *testing.T) {
dir := t.TempDir()
// Operator pre-seeds proxy trust but leaves server_url to the --server-url
// flag. Before the fix this was treated as "first run" and overwritten.
if err := os.WriteFile(filepath.Join(dir, "settings.json"),
[]byte(`{"trust_forwarded_headers":true,"trusted_proxy_cidrs":["10.0.0.0/8"]}`), 0o644); err != nil {
t.Fatalf("write settings.json: %v", err)
}
ds := datastore.NewDataStore(dir)
config := &serviceConfig{dataDir: dir, serverURL: "http://192.0.2.1:8000"}
applyFirstRunSeed(ds, config)
got, err := ds.GetSettings()
if err != nil {
t.Fatalf("GetSettings: %v", err)
}
if !got.TrustForwardedHeaders {
t.Error("trust_forwarded_headers was clobbered on startup")
}
if len(got.TrustedProxyCIDRs) != 1 || got.TrustedProxyCIDRs[0] != "10.0.0.0/8" {
t.Errorf("trusted_proxy_cidrs was clobbered, got %v", got.TrustedProxyCIDRs)
}
}
func TestFirstRunSeed_WritesDefaultsWhenAbsent(t *testing.T) {
dir := t.TempDir()
ds := datastore.NewDataStore(dir)
config := &serviceConfig{dataDir: dir, serverURL: "http://192.0.2.1:8000"}
applyFirstRunSeed(ds, config)
got, err := ds.GetSettings()
if err != nil {
t.Fatalf("GetSettings: %v", err)
}
if got.ServerURL != "http://192.0.2.1:8000" {
t.Errorf("expected defaults to be written with server_url, got %q", got.ServerURL)
}
}
+2 -5
View File
@@ -39,11 +39,8 @@ func TestPrintRoutes(t *testing.T) {
// Now we might have "soundtouch-service.setupRouter.func1"
// or "command-line-arguments.setupRouter.func1"
// or "main.setupRouter.func1"
// Let's remove the first part if it's a known varying package name
if idx := strings.Index(handlerName, "setupRouter"); idx != -1 {
handlerName = handlerName[idx:]
}
// In case it's not setupRouter but still has a package prefix
// Remove the leading package/binary-name segment(s), whatever form
// they take.
for {
dotIdx := strings.Index(handlerName, ".")
if dotIdx == -1 {
@@ -0,0 +1,245 @@
package main
import (
"errors"
"net/http"
"net/http/httptest"
"strings"
"testing"
"github.com/gesellix/bose-soundtouch/pkg/models"
"github.com/gesellix/bose-soundtouch/pkg/service/datastore"
"github.com/gesellix/bose-soundtouch/pkg/stereopair"
)
// neverDNSHijacked is the default DNS-hijack predicate for tests that don't
// exercise the DNS-migrated-speaker path (see
// TestEmbeddedStereoPairPersistenceTreatsDNSHijackedBoseHostAsLocal).
func neverDNSHijacked(string) bool { return false }
// rejectingRoundTripper errors on every request and counts how many it saw.
// A preflight failure is now logged and swallowed rather than propagated
// (see TestEmbeddedStereoPairPersistenceSkipsExternalWritesButAttemptsRead),
// so tests that need to prove an external dispatch actually happened check
// calls rather than the returned error.
type rejectingRoundTripper struct {
calls int
}
func (r *rejectingRoundTripper) RoundTrip(*http.Request) (*http.Response, error) {
r.calls++
return nil, errors.New("unexpected HTTP persistence request")
}
func persistenceTestGroup(id string) *models.Group {
return &models.Group{
ID: id,
Name: "Living room",
MasterDeviceID: "LEFT-ID",
Roles: models.GroupRoles{Roles: []models.GroupRole{
{DeviceID: "LEFT-ID", Role: "LEFT", IPAddress: "192.0.2.10"},
{DeviceID: "RIGHT-ID", Role: "RIGHT", IPAddress: "192.0.2.11"},
}},
}
}
func TestEmbeddedStereoPairPersistenceUsesLocalDatastoreAcrossAccounts(t *testing.T) {
ds := datastore.NewDataStore(t.TempDir())
group := persistenceTestGroup("")
groupID, err := ds.AddGroup("OLD-ACCOUNT", group)
if err != nil {
t.Fatalf("AddGroup: %v", err)
}
localURL := "https://aftertouch.invalid:18443"
cleanup, preflight, rename := embeddedStereoPairGenerationPersistence(
ds,
func() []string { return []string{localURL} },
neverDNSHijacked,
&http.Client{Transport: &rejectingRoundTripper{}},
)
err = preflight([]stereopair.GenerationRef{{
DeviceID: "LEFT-ID", AccountID: "NEW-ACCOUNT", MargeURL: localURL,
}})
if err == nil || !strings.Contains(err.Error(), groupID) {
t.Fatalf("preflight error = %v, want cross-account generation %s", err, groupID)
}
if err := rename(stereopair.GenerationRef{
DeviceID: "LEFT-ID", AccountID: "NEW-ACCOUNT", MargeURL: localURL,
GroupID: groupID, ExpectedGroup: group,
}, "Renamed living room"); err != nil {
t.Fatalf("rename: %v", err)
}
group.Name = "Renamed living room"
if err := cleanup(stereopair.GenerationRef{
DeviceID: "LEFT-ID", AccountID: "NEW-ACCOUNT", MargeURL: localURL,
GroupID: groupID, ExpectedGroup: group,
}); err != nil {
t.Fatalf("cleanup: %v", err)
}
if err := preflight([]stereopair.GenerationRef{{
DeviceID: "LEFT-ID", AccountID: "NEW-ACCOUNT", MargeURL: localURL,
}}); err != nil {
t.Fatalf("preflight after exact cleanup: %v", err)
}
}
func TestEmbeddedStereoPairCleanupMapsAmbiguousGenerationToConflict(t *testing.T) {
ds := datastore.NewDataStore(t.TempDir())
group := persistenceTestGroup("")
groupID, err := ds.AddGroup("ACCOUNT1", group)
if err != nil {
t.Fatalf("AddGroup: %v", err)
}
localURL := "https://aftertouch.invalid:18443"
cleanup, _, _ := embeddedStereoPairGenerationPersistence(
ds,
func() []string { return []string{localURL} },
neverDNSHijacked,
&http.Client{Transport: &rejectingRoundTripper{}},
)
wrongTopology := persistenceTestGroup(groupID)
wrongTopology.Roles.Roles[1].DeviceID = "SUBSTITUTE-RIGHT-ID"
err = cleanup(stereopair.GenerationRef{
DeviceID: "LEFT-ID", AccountID: "ACCOUNT1", MargeURL: localURL,
GroupID: groupID, ExpectedGroup: wrongTopology,
})
if !errors.Is(err, stereopair.ErrConflict) || errors.Is(err, stereopair.ErrUnavailable) {
t.Fatalf("cleanup error = %v, want ErrConflict only", err)
}
}
// TestEmbeddedStereoPairPersistenceSkipsExternalWritesButAttemptsRead covers
// an external (non-local) MargeURL: cleanup and rename must never push to a
// backend we don't own -- the speaker itself self-reports its own group
// teardown/rename to whatever Marge backend it's configured with, which is
// the entire reason HandleMargeDeleteGroup/HandleMargeModifyGroup exist
// (they're only ever called by speakers). Preflight still attempts its
// read-only dangling-generation check, but a failure there (network error,
// wrong credentials, real Bose cloud rejecting us, ...) must not block
// Create, since it's a best-effort check on top of the coordinator's own
// physical preflight, not the primary guard.
func TestEmbeddedStereoPairPersistenceSkipsExternalWritesButAttemptsRead(t *testing.T) {
getCalls := 0
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method == http.MethodGet && strings.HasSuffix(r.URL.Path, "/device/LEFT-ID/group") {
getCalls++
http.Error(w, "unauthorized", http.StatusUnauthorized)
return
}
t.Fatalf("unexpected external %s %s: cleanup/rename must not write to a backend we don't own", r.Method, r.URL.Path)
}))
defer server.Close()
cleanup, preflight, rename := embeddedStereoPairGenerationPersistence(
datastore.NewDataStore(t.TempDir()),
func() []string { return []string{"http://aftertouch.invalid:18000"} },
neverDNSHijacked,
server.Client(),
)
ref := stereopair.GenerationRef{
DeviceID: "LEFT-ID", AccountID: "ACCOUNT1", MargeURL: server.URL + "/marge",
GroupID: "7654321", ExpectedGroup: persistenceTestGroup("7654321"),
}
if err := rename(ref, "Renamed living room"); err != nil {
t.Fatalf("rename = %v, want nil (speaker self-reports its own rename)", err)
}
if err := cleanup(ref); err != nil {
t.Fatalf("cleanup = %v, want nil (speaker self-reports its own teardown)", err)
}
if err := preflight([]stereopair.GenerationRef{ref}); err != nil {
t.Fatalf("preflight = %v, want nil: an unauthenticated external check must not block Create", err)
}
if getCalls != 1 {
t.Fatalf("external GET calls = %d, want exactly 1 (preflight must still attempt the read)", getCalls)
}
}
func TestEmbeddedStereoPairPersistenceReadsOneCurrentURLSnapshot(t *testing.T) {
ds := datastore.NewDataStore(t.TempDir())
group := persistenceTestGroup("")
groupID, err := ds.AddGroup("OLD-ACCOUNT", group)
if err != nil {
t.Fatalf("AddGroup: %v", err)
}
currentURL := "http://old.invalid:18000"
providerCalls := 0
transport := &rejectingRoundTripper{}
_, preflight, _ := embeddedStereoPairGenerationPersistence(
ds,
func() []string {
providerCalls++
return []string{currentURL}
},
neverDNSHijacked,
&http.Client{Transport: transport},
)
currentURL = "http://new.invalid:18000"
err = preflight([]stereopair.GenerationRef{
{DeviceID: "LEFT-ID", AccountID: "NEW-ACCOUNT", MargeURL: currentURL},
{DeviceID: "RIGHT-ID", AccountID: "NEW-ACCOUNT", MargeURL: currentURL},
})
if err == nil || !strings.Contains(err.Error(), groupID) {
t.Fatalf("preflight error = %v, want current local generation %s", err, groupID)
}
if providerCalls != 1 {
t.Fatalf("URL provider calls = %d, want one coherent snapshot", providerCalls)
}
// The old URL no longer matches localMargeURLs()'s current snapshot, so
// this ref is external. Preflight still attempts the read (proven by the
// transport call count) but no longer propagates its failure -- an
// external check failing must not block Create.
err = preflight([]stereopair.GenerationRef{{
DeviceID: "LEFT-ID", AccountID: "OLD-ACCOUNT", MargeURL: "http://old.invalid:18000",
}})
if err != nil {
t.Fatalf("old URL preflight error = %v, want nil (external check failure must not block)", err)
}
if transport.calls != 1 {
t.Fatalf("external HTTP dispatch calls = %d, want exactly 1", transport.calls)
}
}
// TestEmbeddedStereoPairPersistenceTreatsDNSHijackedBoseHostAsLocal covers a
// speaker migrated at the DNS level: its own reported MargeURL is still the
// literal Bose cloud hostname (DNS migration never changes it), but this
// service's DNS hijack redirects that hostname to itself on the network.
// Routing it through the external HTTP path instead would reach the real,
// still-live Bose cloud and 401 there, hard-blocking Create for a normal
// DNS-migrated setup. Uses rejectingRoundTripper to prove no HTTP call is
// attempted at all.
func TestEmbeddedStereoPairPersistenceTreatsDNSHijackedBoseHostAsLocal(t *testing.T) {
ds := datastore.NewDataStore(t.TempDir())
group := persistenceTestGroup("")
groupID, err := ds.AddGroup("OLD-ACCOUNT", group)
if err != nil {
t.Fatalf("AddGroup: %v", err)
}
_, preflight, _ := embeddedStereoPairGenerationPersistence(
ds,
func() []string { return []string{"https://aftertouch.invalid:18443"} },
func(margeURL string) bool { return strings.Contains(margeURL, "streaming.bose.com") },
&http.Client{Transport: &rejectingRoundTripper{}},
)
err = preflight([]stereopair.GenerationRef{{
DeviceID: "LEFT-ID", AccountID: "NEW-ACCOUNT", MargeURL: "https://streaming.bose.com",
}})
if err == nil || !strings.Contains(err.Error(), groupID) {
t.Fatalf("preflight error = %v, want local generation %s found via datastore, not an external HTTP call", err, groupID)
}
}
+18 -1
View File
@@ -5,6 +5,8 @@ DELETE /accounts/{account}/group handlers.(
DELETE /accounts/{account}/group/ handlers.(*Server).HandleUnsupported-fm
DELETE /accounts/{account}/group/{groupId} handlers.(*Server).HandleUnsupported-fm
DELETE /api/control/devices/{id}/ soundtouchweb.(*WebApp).HandleDeleteDevice-fm
DELETE /api/control/devices/{id}/library/servers/{account} soundtouchweb.(*WebApp).HandleRemoveLibraryServer-fm
DELETE /api/control/devices/{id}/stereo-pair/ soundtouchweb.(*WebApp).HandleDissolveStereoPair-fm
DELETE /api/setup/devices/{deviceId} handlers.(*Server).HandleRemoveDevice-fm
DELETE /api/setup/dns-discoveries handlers.(*Server).HandleClearDNSDiscoveries-fm
DELETE /api/setup/interactions/sessions handlers.(*Server).HandleCleanupSessions-fm
@@ -35,13 +37,20 @@ GET /accounts/{account}/devices/{device}/recents handlers.(
GET /accounts/{account}/full handlers.(*Server).HandleUnsupported-fm
GET /accounts/{account}/sources handlers.(*Server).HandleUnsupported-fm
GET /admin handlers.(*Server).HandleAdmin-fm
GET /api/announcements handlers.(*Server).HandleListAnnouncements-fm
GET /api/control/devices/ soundtouchweb.(*WebApp).HandleAPIDevices-fm
GET /api/control/devices/{id}/ soundtouchweb.(*WebApp).HandleAPIDevice-fm
GET /api/control/devices/{id}/action/{action} soundtouchweb.(*WebApp).HandleAPIControl-fm
GET /api/control/devices/{id}/library/browse soundtouchweb.(*WebApp).HandleLibraryBrowse-fm
GET /api/control/devices/{id}/library/servers soundtouchweb.(*WebApp).HandleDeviceLibraryServers-fm
GET /api/control/devices/{id}/now-playing soundtouchweb.(*WebApp).HandleDeviceNowPlaying-fm
GET /api/control/devices/{id}/power-status soundtouchweb.(*WebApp).HandleDevicePowerStatus-fm
GET /api/control/devices/{id}/recents soundtouchweb.(*WebApp).HandleDeviceRecents-fm
GET /api/control/devices/{id}/stereo-pair/ soundtouchweb.(*WebApp).HandleGetStereoPair-fm
GET /api/control/devices/{id}/ws soundtouchweb.(*WebApp).HandleDeviceWebSocket-fm
GET /api/control/devices/{id}/zone/ soundtouchweb.(*WebApp).HandleGetZone-fm
GET /api/control/devices/{id}/zone/candidates soundtouchweb.(*WebApp).HandleGetZoneCandidates-fm
GET /api/control/providers/library/servers soundtouchweb.(*WebApp).HandleDiscoverLibraryServers-fm
GET /api/control/providers/radiobrowser/search soundtouchweb.(*WebApp).HandleRadioBrowserSearch-fm
GET /api/control/providers/tunein/navigate soundtouchweb.(*WebApp).HandleTuneInNavigate-fm
GET /api/control/providers/tunein/navigate/* soundtouchweb.(*WebApp).HandleTuneInNavigate-fm
@@ -81,6 +90,7 @@ GET /api/setup/version handlers.(
GET /app soundtouchweb.(*WebApp).serveIndex-fm
GET /app/device/* soundtouchweb.(*WebApp).serveIndex-fm
GET /app/devices soundtouchweb.(*WebApp).serveIndex-fm
GET /app/library soundtouchweb.(*WebApp).serveIndex-fm
GET /app/playurl soundtouchweb.(*WebApp).serveIndex-fm
GET /app/radiobrowser soundtouchweb.(*WebApp).serveIndex-fm
GET /app/static/* http.Handler.ServeHTTP-fm
@@ -163,11 +173,12 @@ GET /streaming/sourceproviders handlers.(
GET /updates/soundtouch handlers.(*Server).HandleMargeSoftwareUpdate-fm
GET /v1/auth handlers.(*Server).HandleSpeakerAuth-fm
GET /v1/blacklist/{deviceId} setupRouter
GET /web/* setupRouter.(*Server).HandleWeb
GET /web/* handlers.(*Server).HandleWeb
HEAD /core02/svc-bmx-adapter-siriusxm-everest-eco1/prod/live-adapter handlers.(*Server).HandleSiriusXMLiveAdapter-fm
HEAD /core02/svc-bmx-adapter-siriusxm-everest-eco1/prod/live-adapter/* handlers.(*Server).HandleSiriusXMLiveAdapterSubpath-fm
OPTIONS /core02/svc-bmx-adapter-siriusxm-everest-eco1/prod/live-adapter handlers.(*Server).HandleSiriusXMLiveAdapter-fm
OPTIONS /core02/svc-bmx-adapter-siriusxm-everest-eco1/prod/live-adapter/* handlers.(*Server).HandleSiriusXMLiveAdapterSubpath-fm
PATCH /api/control/devices/{id}/stereo-pair/ soundtouchweb.(*WebApp).HandleRenameStereoPair-fm
PATCH /core02/svc-bmx-adapter-siriusxm-everest-eco1/prod/live-adapter handlers.(*Server).HandleSiriusXMLiveAdapter-fm
PATCH /core02/svc-bmx-adapter-siriusxm-everest-eco1/prod/live-adapter/* handlers.(*Server).HandleSiriusXMLiveAdapterSubpath-fm
POST /accounts/{account}/devices handlers.(*Server).HandleUnsupported-fm
@@ -177,14 +188,18 @@ POST /accounts/{account}/group handlers.(
POST /accounts/{account}/group/ handlers.(*Server).HandleUnsupported-fm
POST /accounts/{account}/group/{groupId} handlers.(*Server).HandleUnsupported-fm
POST /alexa/certificate handlers.(*Server).HandleAlexaCertificate-fm
POST /api/announcements/{id}/dismiss handlers.(*Server).HandleDismissAnnouncement-fm
POST /api/control/devices/{id}/action/{action} soundtouchweb.(*WebApp).HandleAPIControl-fm
POST /api/control/devices/{id}/key/{key} soundtouchweb.(*WebApp).HandleDeviceKey-fm
POST /api/control/devices/{id}/library/play soundtouchweb.(*WebApp).HandlePlayLibrary-fm
POST /api/control/devices/{id}/library/servers soundtouchweb.(*WebApp).HandleAddLibraryServer-fm
POST /api/control/devices/{id}/play soundtouchweb.(*WebApp).HandleDevicePlay-fm
POST /api/control/devices/{id}/power soundtouchweb.(*WebApp).HandleDevicePower-fm
POST /api/control/devices/{id}/providers/radiobrowser/play soundtouchweb.(*WebApp).HandlePlayRadioBrowser-fm
POST /api/control/devices/{id}/providers/tts/play soundtouchweb.(*WebApp).HandleAPISpeakText-fm
POST /api/control/devices/{id}/providers/tunein/play soundtouchweb.(*WebApp).HandlePlayTuneIn-fm
POST /api/control/devices/{id}/providers/url/play soundtouchweb.(*WebApp).HandlePlayURL-fm
POST /api/control/devices/{id}/stereo-pair/ soundtouchweb.(*WebApp).HandleCreateStereoPair-fm
POST /api/control/devices/{id}/volume/{volume} soundtouchweb.(*WebApp).HandleDirectVolumeControl-fm
POST /api/control/devices/{id}/zone/add/{slaveId} soundtouchweb.(*WebApp).HandleZoneAdd-fm
POST /api/control/devices/{id}/zone/dissolve soundtouchweb.(*WebApp).HandleZoneDissolve-fm
@@ -284,5 +299,7 @@ PUT /core02/svc-bmx-adapter-siriusxm-everest-eco1/prod/live-adapter handler
PUT /core02/svc-bmx-adapter-siriusxm-everest-eco1/prod/live-adapter/* handlers.(*Server).HandleSiriusXMLiveAdapterSubpath-fm
PUT /streaming/account/{account}/device/{device} handlers.(*Server).HandleMargeUpdateDevice-fm
PUT /streaming/account/{account}/device/{device}/preset/{presetNumber} handlers.(*Server).HandleMargeUpdatePreset-fm
QUERY /core02/svc-bmx-adapter-siriusxm-everest-eco1/prod/live-adapter handlers.(*Server).HandleSiriusXMLiveAdapter-fm
QUERY /core02/svc-bmx-adapter-siriusxm-everest-eco1/prod/live-adapter/* handlers.(*Server).HandleSiriusXMLiveAdapterSubpath-fm
TRACE /core02/svc-bmx-adapter-siriusxm-everest-eco1/prod/live-adapter handlers.(*Server).HandleSiriusXMLiveAdapter-fm
TRACE /core02/svc-bmx-adapter-siriusxm-everest-eco1/prod/live-adapter/* handlers.(*Server).HandleSiriusXMLiveAdapterSubpath-fm
+156
View File
@@ -0,0 +1,156 @@
package main
import (
"testing"
"time"
"github.com/gesellix/bose-soundtouch/pkg/service/updatecheck"
)
func TestShouldCheckImmediately(t *testing.T) {
now := time.Date(2026, 8, 9, 12, 0, 0, 0, time.UTC)
interval := 24 * time.Hour
cases := []struct {
name string
lastCheckedAt time.Time
want bool
}{
{"never checked", time.Time{}, true},
{"stale (older than interval)", now.Add(-25 * time.Hour), true},
{"exactly one interval ago", now.Add(-interval), true},
{"recent (within interval)", now.Add(-1 * time.Hour), false},
}
for _, tc := range cases {
if got := shouldCheckImmediately(tc.lastCheckedAt, interval, now); got != tc.want {
t.Errorf("%s: shouldCheckImmediately() = %v, want %v", tc.name, got, tc.want)
}
}
}
func TestShouldSkipDueToBackoff(t *testing.T) {
now := time.Date(2026, 8, 9, 12, 0, 0, 0, time.UTC)
cases := []struct {
name string
lastErrorAt time.Time
want bool
}{
{"no recent failure", time.Time{}, false},
{"failed 30 minutes ago", now.Add(-30 * time.Minute), true},
{"failed exactly 1 hour ago", now.Add(-time.Hour), false},
{"failed 2 hours ago", now.Add(-2 * time.Hour), false},
}
for _, tc := range cases {
if got := shouldSkipDueToBackoff(tc.lastErrorAt, now); got != tc.want {
t.Errorf("%s: shouldSkipDueToBackoff() = %v, want %v", tc.name, got, tc.want)
}
}
}
func TestLogUpdateIfNewlyAvailable(t *testing.T) {
cases := []struct {
name string
result updatecheck.Result
lastLoggedVersion string
want string
}{
{
name: "nothing available",
result: updatecheck.Result{Available: false},
lastLoggedVersion: "",
want: "",
},
{
name: "newly available",
result: updatecheck.Result{Available: true, LatestVersion: "v1.1.0"},
lastLoggedVersion: "",
want: "v1.1.0",
},
{
name: "already logged this version",
result: updatecheck.Result{Available: true, LatestVersion: "v1.1.0"},
lastLoggedVersion: "v1.1.0",
want: "v1.1.0",
},
{
name: "a newer version than what was logged",
result: updatecheck.Result{Available: true, LatestVersion: "v1.2.0"},
lastLoggedVersion: "v1.1.0",
want: "v1.2.0",
},
}
for _, tc := range cases {
if got := logUpdateIfNewlyAvailable(tc.result, tc.lastLoggedVersion); got != tc.want {
t.Errorf("%s: logUpdateIfNewlyAvailable() = %q, want %q", tc.name, got, tc.want)
}
}
}
func TestRandomJitter(t *testing.T) {
if got := randomJitter(0); got != 0 {
t.Errorf("randomJitter(0) = %v, want 0", got)
}
upperBound := 5 * time.Minute
for i := 0; i < 20; i++ {
got := randomJitter(upperBound)
if got < 0 || got >= upperBound {
t.Fatalf("randomJitter(%v) = %v, want in [0, %v)", upperBound, got, upperBound)
}
}
}
// There is deliberately no test for startUpdateCheck itself, matching
// startDeviceDiscovery (its equally untested sibling): both are thin,
// forever-looping goroutine wrappers whose only decisions live in pure
// helpers, which is what the tests above and below cover. The former
// TestStartUpdateCheck_DisabledIsANoOp asserted a contract that no longer
// exists — the goroutine now always starts, precisely so that enabling the
// check from the Settings page takes effect without a restart, and an
// early return for "disabled" would defeat that.
func TestShouldRunUpdateCheckNow(t *testing.T) {
now := time.Date(2026, 8, 10, 12, 0, 0, 0, time.UTC)
interval := 24 * time.Hour
stale := now.Add(-25 * time.Hour)
fresh := now.Add(-1 * time.Hour)
cases := []struct {
name string
enabled bool
lastCheckedAt time.Time
interval time.Duration
lastErrorAt time.Time
want bool
}{
{"disabled, never checked", false, time.Time{}, interval, time.Time{}, false},
{"disabled, due", false, stale, interval, time.Time{}, false},
{"enabled, never checked", true, time.Time{}, interval, time.Time{}, true},
{"enabled, due", true, stale, interval, time.Time{}, true},
{"enabled, not due yet", true, fresh, interval, time.Time{}, false},
{"enabled and due, but in error backoff", true, stale, interval, now.Add(-30 * time.Minute), false},
{"enabled and due, backoff expired", true, stale, interval, now.Add(-2 * time.Hour), true},
// A zero interval must not turn every poll tick into a GitHub request.
{"enabled with a zero interval", true, stale, 0, time.Time{}, false},
}
for _, tc := range cases {
got := shouldRunUpdateCheckNow(tc.enabled, tc.lastCheckedAt, tc.interval, tc.lastErrorAt, now)
if got != tc.want {
t.Errorf("%s: shouldRunUpdateCheckNow() = %v, want %v", tc.name, got, tc.want)
}
}
}
// TestUpdateCheckPollTickIsShorterThanTheDefaultInterval guards the property
// that makes the Settings-page toggle feel live: the goroutine must re-read
// the settings far more often than the check interval itself, otherwise
// switching the check on would appear to do nothing for up to a day.
func TestUpdateCheckPollTickIsShorterThanTheDefaultInterval(t *testing.T) {
if updateCheckPollTick >= 24*time.Hour {
t.Errorf("updateCheckPollTick = %v, want well below the 24h default interval", updateCheckPollTick)
}
}
+2
View File
@@ -5,5 +5,7 @@ default/
dns/
interactions/
parity_mismatches/
stats/
patterns.json
settings.json
update-check.json
+3 -3
View File
@@ -35,7 +35,7 @@ services:
start_period: 3s
spotify-mock:
image: golang:1.26.4-alpine
image: golang:1.27.1-alpine
container_name: spotify-mock
working_dir: /app
volumes:
@@ -53,7 +53,7 @@ services:
start_period: 3s
amazon-mock:
image: golang:1.26.4-alpine
image: golang:1.27.1-alpine
container_name: amazon-mock
working_dir: /app
volumes:
@@ -71,7 +71,7 @@ services:
start_period: 3s
tunein-mock:
image: golang:1.26.4-alpine
image: golang:1.27.1-alpine
container_name: tunein-mock
working_dir: /app
volumes:
@@ -0,0 +1,131 @@
---
title: "AfterTouch: From Rescue to Something Better, and the Road to 1.0"
date: 2026-06-28
description: "Since v0.93.1, AfterTouch grew from a cloud-shutdown rescue into a platform of its own: local music, voice prompts, sturdier internals, a growing community, and a 1.0 on the horizon."
tags:
- discovery
- health
- migration
- fixes
sidebar:
exclude: true
---
The launch post went out under the wire. Bose pulled the plug on the SoundTouch cloud on
May 6, and **v0.93.1** was very much a rescue: get accounts migrated, keep radio and
presets alive, stop perfectly good speakers from turning into bricks. The weeks since,
up through **v0.117.0**, have been about a quieter shift: turning that rescue into
something that stands on its own, and in a few places, something better than what Bose
offered. And almost none of that direction came from me. I use my own speakers with a
pretty narrow set of features; nearly everything below exists because someone in the
community described a use case I'd never have thought to build.
## Local music, back under your control, and a speaker that talks
The clearest sign of that shift is local music. Your speakers always had a native
local-music source for playing your own library off the network, but browsing it used to
run through the Bose app. AfterTouch brings that back on its own terms: it discovers
DLNA / UPnP media servers on your network and drives the speaker's native source
directly. Browse folders in the **Library** tab or from the command line, queue a whole
folder, and next/previous and auto-advance behave like a real playlist.
Then there's something genuinely new: speakers can now *talk*. A text-to-speech feature
announces arbitrary text out loud, with Google Cloud TTS as a pluggable provider you
configure from the settings UI. It's built on the speaker's notification capability, but
turning that into spoken prompts is the kind of thing that happens when the platform is
open and nobody has to wait for a vendor to approve it.
There is more in the same spirit, smaller but useful: service-side search across TuneIn
and Radio Browser, a "Play URL" view for arbitrary streams, save-as-preset straight from
Now Playing, and a step toward needing no extra hardware at all, an on-device SSH unlock
flow that opens the door to running AfterTouch directly on the speaker.
## The unglamorous half: earning trust
Features are the easy part to write about. The work that actually mattered most was
making AfterTouch dependable enough that you stop thinking about it. Speaker data is now
written to disk durably, so a power cut mid-write no longer wipes your presets and
accounts, and corrupt or empty files fall back to sane defaults instead of failing.
Recent tracks stopped vanishing and duplicating. Internet radio got steadier: Radio
Browser plays through its proper native source, TuneIn fails over across stream
candidates, and a stray trailing slash in a server URL no longer breaks playback.
Multi-room grouping handles member removal correctly.
Under the surface, a sustained pass closed several request-forgery paths, swept the code
for log-injection, validated identifiers on management endpoints, and removed a
credential-logging shortcut. And the health checks grew teeth: server-URL reachability,
CA-bundle integrity, a speaker-clock check with a one-click fix, and a DNS-path probe for
the internet-radio escape problem, all now labelled with the device name and IP so you
know exactly which speaker a warning is about.
## A community, not a product
The best thing to happen since launch isn't in the changelog. It's the people.
It's worth saying plainly: this project is driven by its users. I personally use
SoundTouch in a fairly simple way, and most of what shipped over these weeks (features
and bug fixes alike) is the result of friendly, constructive feedback from people who use
their speakers very differently than I do. The DLNA library, the voice prompts, the radio
and grouping fixes, the migration edge cases: each one started as someone taking the time
to explain a real-world setup and point at what was missing. That feedback is the
roadmap. Keep it coming.
A standout is **[Sander ten Brinke](https://x.com/sandertenbrinke)**, who is building
**[soundtouch-maui](https://github.com/sander1095/soundtouch-maui)**, a cross-platform
SoundTouch app designed to work hand in hand with AfterTouch. That's exactly the shape
this project should take: not one tool trying to do everything, but independent pieces
that fit together because they share an open, community-owned foundation. Go build a
player, a remote, a home-automation bridge, whatever you need, and have it talk to a
service you control.
An honest admission: there has been more activity in issues and discussions than one
maintainer can keep up with, and not every thread got the reply it deserved. But the
encouraging part is that it increasingly doesn't have to. People are answering each
other, sharing setups (the FRITZ!Box and AdGuard DNS notes came straight from a user's
own working configuration), and debugging together. That's the project moving in the
right direction. AfterTouch works best as a community, not a support desk.
And a heartfelt thank you to everyone who sponsors AfterTouch. The project is free and
maintained in spare time, so every contribution, recurring or one-off, directly funds the
hosting, the test hardware, and the hours that keep these speakers alive. It genuinely
makes a difference, and it's deeply appreciated. If you'd like to chip in, the
[sponsor page](../sponsor.md) has the details.
## The road to 1.0
So what does **v1.0.0** mean? Mostly: stability. A version number that signals a proper,
dependable base you can build on, with a management API that won't shift under you and a
service that runs unprivileged and installs cleanly by default.
A few things are on the list to get there. The admin and account-management UI works,
but it feels rough at the edges, and that's the part you actually touch, so it deserves
some polish. I also want to keep a publicly deployed, cloud-hosted service in mind:
the moment AfterTouch is reachable from the open internet, it needs proper authentication
and authorization, so a passing script kiddie can't read your recently played songs (or
worse). And the docs need some love and a clearer structure. One feature is likely to land
in this stretch too: making
[presets propagate cleanly across the speakers in one account](https://github.com/gesellix/Bose-SoundTouch/issues/495),
without the manual "refresh sources" dance. There's probably more before it's truly
"1.0", but none of it is blocking: there's nothing preventing us from getting there *now*.
It's also a natural moment for a clean slate. If your migration has accumulated quirks,
1.0 is a good excuse to reset and re-migrate your speakers onto a known-good footing.
And then the interesting part begins. With the rescue done and a stable base in place, the
focus shifts to delivering value the old Bose cloud never could. Some of that is already
taking shape in the issue tracker: an
[audiobook mode](https://github.com/gesellix/Bose-SoundTouch/issues/508), and deeper
integration with external music providers such as
[Amazon Music](https://github.com/gesellix/Bose-SoundTouch/issues/188). A service under
community control is a rare chance to actually solve the things people ask for, instead of
waiting on a roadmap that was discontinued. If there's something you wish your speakers
did, the [issue tracker](https://github.com/gesellix/Bose-SoundTouch/issues) and
[Discussions](https://github.com/gesellix/Bose-SoundTouch/discussions) are where it starts.
## Current release
**v0.117.0**, released June 28, 2026
This blog will be updated monthly, or whenever something significant ships.
Subscribe to the [GitHub releases](https://github.com/gesellix/Bose-SoundTouch/releases)
for individual version notes.
+1
View File
@@ -93,5 +93,6 @@ Older planning artefacts ("Enhanced State Management System", "Upstream Service
- **Questions & Discussion**: [GitHub Discussions](https://github.com/gesellix/Bose-SoundTouch/discussions)
- **Documentation**: Check troubleshooting guides first
- **Community**: Share experiences and help others
- **Direct chat (last resort)**: There's a small Discord for the rare case where an email exchange or an issue/discussion thread needs real-time back-and-forth. It's not a primary support channel: please start with Issues or Discussions. If a conversation genuinely needs it, ask in your thread and I'll share an invite.
For a complete list of all documents, browse the sections in the sidebar.
@@ -112,6 +112,18 @@ Factory-reset the same speaker again and run the full state machine — the same
This drives `setup.Manager.ExecuteInitPlan` with `SkipURLRewrite=true`, which runs:
> **Update (#615):** `--mode=full` now preflights via `Manager.PreflightInitPlan`
> before opening the WebSocket — it checks `/supportedURLs` for
> `/setMargeAccount` and requires `/soundTouchConfigurationStatus` to read
> `SOUNDTOUCH_NOT_CONFIGURED`, and no-ops on an already-configured device.
> A freshly factory-reset speaker (as in this experiment) reports
> `SOUNDTOUCH_NOT_CONFIGURED`, so the preflight passes through unchanged;
> see `docs/content/docs/reference/DEVICE-PAIRING-FLOW.md`.
The historical capture below sent language code `2`. Stockholm's language
table identifies that code as German; current English-language setup uses code
`3`. The original wire value is retained here as experiment evidence.
```
SETUP_START
SETUP_IDENTIFY_DEVICE_ENTER
@@ -100,16 +100,16 @@ also visible on the ST 20/300/Wave captures in #221. Different from the
`sys presetkey N p` form (S4) — the `key prefix_N` shape on FW 27 is what
the device's own remote sends.
| Command | Effect | Source |
|---------------------------------|---------------------------------------------------------------------------------------|--------|
| `key prefix_1``key prefix_6` | Triggers preset 16 (same as a remote preset press). | S5 |
| `key play` | Begin / resume playback. | S5 |
| `key pause` | Pause playback. | S5 |
| `key stop` | Stop playback (does **not** terminate the underlying stream). | S5 |
| `key prev` | Restart current song / previous track. | S5 |
| `key next` | Next track. | S5 |
| `key aux` | Toggle Bluetooth / AUX input. | S5 |
| `key power` | Echoes "OK" but no observable effect on FW 27.x — possibly handled at a higher layer. | S5 |
| Command | Effect | Source |
|---------------------------------|----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|--------|
| `key prefix_1``key prefix_6` | Triggers preset 16 (same as a remote preset press). | S5 |
| `key play` | Begin / resume playback. | S5 |
| `key pause` | Pause playback. | S5 |
| `key stop` | Stop playback (does **not** terminate the underlying stream). | S5 |
| `key prev` | Restart current song / previous track. | S5 |
| `key next` | Next track. | S5 |
| `key aux` | Toggle Bluetooth / AUX input. | S5 |
| `key power` | Echoes "OK" but no observable effect on FW 27.x — possibly handled at a higher layer. On Lifestyle/CineMate console devices this is **not** a no-op: it puts the console into standby and, on waking, returns it to the console's own input rather than SoundTouch — see [Lifestyle / Console Device Behavior](../guides/TROUBLESHOOTING.md#lifestyle-console-devices) and #597. | S5 |
The S4 `bose` script's `sys presetkey N p` form still works, but `key prefix_N` is shorter and matches what the remote already does on FW 27.x.
@@ -150,11 +150,15 @@ Each `sys configuration` setter is reported by users to return `OK` on success.
`envswitch` writes to a separate, lower-level persistence store that **wins on next reboot** if the corresponding `sys configuration` value differs. So our migration writes both — see TELNET-MIGRATION-METHOD.md §2.1.
| Command | Purpose | Source |
|---------------------------------------------------|-----------------------------------------------------------------------------------------------|---------|
| `envswitch boseurls set <margeUrl> <swUpdateUrl>` | Persist the marge and update URLs. **Two arguments**, in that order. | S6 |
| `envswitch accountid set <numeric-id>` | Equivalent to the HTTP `/setMargeAccount` POST. Used as fallback in our `PairAccount` helper. | S6 |
| `envswitch accountid get` | Plausible by symmetry but **not yet confirmed** across firmwares; we probe it best-effort. | (probe) |
**It's a commit point, not just a two-field setter.** `envswitch boseurls set` persists whatever is currently in the runtime layer at the moment it runs — not only its own two arguments. Confirmed on five variants (`lisa`, `mojo`, `spotty`, `ginger`, `taigan`; [#515 comment 5231931569](https://github.com/gesellix/Bose-SoundTouch/issues/515#issuecomment-5231931569)): a `sys configuration` write survives a reboot **if and only if** an `envswitch boseurls set` runs after it. The same command sequence in reverse order silently loses the later `sys configuration` values on reboot — every command still answers, nothing looks wrong until the reboot. This is why our migration and SSH-enable sequences always issue all four `sys configuration` writes first and `envswitch boseurls set` last (see `telnetURLs.Commands()` / `EnableSSHViaTelnetFullConfig`).
**It does not acknowledge with `OK`.** Unlike `sys configuration` (which does), `envswitch boseurls set` responds with a different string (observed: `Setting Bose Server URLs to <a> and <b> ->`, no `OK` substring). An implementation that waits for the literal token `OK` will hit its own timeout on this exact command. Our `pkg/telnet.Client.SendCommand` doesn't string-match at all — it reads until the connection goes idle — so this only matters if you're hand-typing the sequence or reimplementing the client elsewhere.
| Command | Purpose | Source |
|-------------------------------------------------------------------|-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|---------|
| `envswitch boseurls set <margeUrl> <swUpdateUrl>` | Persist the marge and update URLs, committing the runtime layer as it stands (see above). **Two arguments**, in that order. | S6 |
| `envswitch accountid set <numeric-id>` | Equivalent to the HTTP `/setMargeAccount` POST. Used as fallback in our `PairAccount` helper. | S6 |
| `envswitch accountid get`, bare `envswitch`, `envswitch boseurls` | **Confirmed unsupported** — all answer `Invalid Command Option` on `lisa`/`mojo`/`spotty` ([#515 comment 5231931569](https://github.com/gesellix/Bose-SoundTouch/issues/515#issuecomment-5231931569)). `envswitch` has no read form on any variant tested; the persisted layer can only be written, then observed indirectly after a reboot (e.g. via `getpdo`, which then reflects the *new* value). | (probe) |
---
@@ -166,6 +170,8 @@ Each `sys configuration` setter is reported by users to return `OK` on success.
|-------------------------------------|-----------------------------------------------------------------------------------------------------------------------------------------------------------------------|--------|
| `getpdo CurrentSystemConfiguration` | Echoes the resolved URL set, including margeServerUrl/bmxRegistryUrl/statsServerUrl/swUpdateUrl. We grep our targetURL out of this to confirm a successful migration. | S6 |
**The two layers are inverted in `getpdo` visibility around a reboot** ([#515 comment 5231931569](https://github.com/gesellix/Bose-SoundTouch/issues/515#issuecomment-5231931569)): *before* a reboot, `getpdo` shows the runtime (`sys configuration`) values immediately, while an `envswitch`-written value isn't visible yet; *after* a reboot, the `sys configuration` values are gone and the `envswitch`-persisted values are what's now applied. So a `getpdo` check run before rebooting confirms the writes were accepted, but it is **not** evidence the configuration will survive the reboot — only the `envswitch` write (in the right order, see above) determines that. This is why our own migration verification (`migrateViaTelnet`) checks `getpdo` before reboot only to confirm the runtime layer accepted the values, and never claims persistence from it.
---
## The `scm` family — service control
@@ -225,7 +231,7 @@ These show up in `getpdo`, `network status`, and SSH-side hostnames. Useful for
- **Firmware 1.x7.x** (S1 era): everything — `help`, `remote_services on`, full `scm`, and an in-shell login prompt. `flarn2006` documents the original Linux insides.
- **Firmware 8.x14.x** (S2 era): `remote_services on` removed; `network`, `sys`, `envswitch`, `getpdo` still present. `local_services on` works on some Wave/SA-5 models.
- **Firmware 27.x** (S5/S6 era — the long-lived "frozen" build that survived through EOS): `help`, `remote_services on`, and `sys ver` removed in some builds; `sys configuration …` and `envswitch …` confirmed working on ST 10, ST 20, ST 300, Wave III, Wave IV. **This is the firmware our migration targets**. The Portable on more recent firmware drops further commands and is the hardest target.
- **Firmware 27.x** (S5/S6 era — the long-lived "frozen" build that survived through EOS): `help`, `remote_services on`, and `sys ver` removed in some builds; `sys configuration …` and `envswitch …` confirmed working on ST 10, ST 20, ST 300, Wave III, Wave IV. **This is the firmware our migration targets**. The Portable on more recent firmware drops further commands and is the hardest target; on the ST Portable (Series I, FW `27.0.6.46330.5043500`) and some CineMate 520 units the SSH-enable injection persists but `sshd` does not start via the default path, which is what `setup enable-ssh --full-config` addresses (see "What we use to enable SSH" above).
S5 enumerated the **top-level command roots** that don't return "Command not found" on a vanilla ST 10 (`rhino`) running `27.0.6.46330.5043500`:
@@ -265,6 +271,46 @@ Reboot is **not** part of these sequences — it stays a user-initiated action v
---
## What we use to enable SSH (`setup enable-ssh`, #471)
To open SSH on a speaker that has never had it (no USB recovery), the CLI abuses the boseurls value as a command-injection vehicle: when the device next parses it, the appended shell snippet touches the `remote_services` marker and starts `sshd`. The injected suffix is:
```
;touch /tmp/remote_services;/etc/init.d/sshd start
```
**Default path** (`soundtouch-cli setup enable-ssh`) writes that injection only via the persistence layer, then waits for `:22`:
```
envswitch boseurls set "<serverURL>;touch /tmp/remote_services;/etc/init.d/sshd start" "<serverURL>/update"
```
This is field-confirmed on the Wireless Link Adapter and on the CineMate 520 `lisa` variant (FW 27.0.6).
**`--full-config` path** (`soundtouch-cli setup enable-ssh --full-config`) is for devices where the default injection is *accepted and persisted* (`getpdo` confirms the value) but `sshd` never comes up, so `:22` stays "Connection refused". It mirrors the manual telnet sequence @Henri-be confirmed by hand on issue #515: it puts the injection on the runtime `sys configuration margeServerUrl` key as well as `envswitch`, writes all four URL keys, then reboots so the device re-parses the config at boot:
```
sys configuration bmxRegistryUrl "<serverURL>/bmx/registry/v1/services"
sys configuration statsServerUrl "<serverURL>"
sys configuration margeServerUrl "<serverURL>;touch /tmp/remote_services;/etc/init.d/sshd start"
sys configuration swUpdateUrl "<serverURL>/updates/soundtouch"
envswitch boseurls set "<serverURL>;touch /tmp/remote_services;/etc/init.d/sshd start" "<serverURL>/updates/soundtouch"
getpdo CurrentSystemConfiguration
sys reboot
```
**Which devices need `--full-config`:** observed on the **SoundTouch Portable (Series I, model 412540, FW `27.0.6.46330.5043500`)** (#515) and on some **CineMate 520** units where the default path leaves `sshd` down. The structural differences from the default path that appear to matter are (1) the injection riding `sys configuration margeServerUrl`, not just `envswitch`, and (2) the explicit `sys reboot`. The `--full-config` automation is **candidate behaviour awaiting reporter confirmation** — the manual sequence is confirmed working on the ST Portable, but the flag that automates it has not yet been re-confirmed on hardware. Not every device responds even to the manual sequence (some ST10 and CineMate 520 units never start `sshd` over telnet at all and need the serial / U-Boot route).
**On the `--command-delay` between steps:** originally added because a reporter's back-to-back run left `sshd` down while a ~7s-gapped run succeeded ([#515 comment 5228449448](https://github.com/gesellix/Bose-SoundTouch/issues/515#issuecomment-5228449448)). That theory was **retracted** by the same reporter after a controlled A/B across three variants showed identical outcomes at 0s and 5s gaps ([comment 5231931569](https://github.com/gesellix/Bose-SoundTouch/issues/515#issuecomment-5231931569)) — the delay itself doesn't appear to matter. The default is kept small and non-zero (`setup.DefaultTelnetCommandDelay`) as a low-cost hedge for untested variants, not because the delay is known to help.
**The account-pairing precondition** (raised by `Henri-be`, [#515 comment 5230785528](https://github.com/gesellix/Bose-SoundTouch/issues/515#issuecomment-5230785528), tracing back to [#471 comment 4903016740](https://github.com/gesellix/Bose-SoundTouch/issues/471#issuecomment-4903016740); confirmed empirically by `bitranox`, [#515 comment 5232241580](https://github.com/gesellix/Bose-SoundTouch/issues/515#issuecomment-5232241580)): a genuinely unpaired (factory-reset, empty `margeAccountUUID`) device does not poll `margeServerUrl` **at all** — confirmed by pointing a reset device's marge URL at a listener and observing zero requests over 10+ minutes. The SSH-enable injection has no read cycle to fire on until the device is paired. `enable-ssh` handles this automatically by default (`EnsureMargeAccountPaired`, `--no-auto-pair` to skip).
**Factory reset does not remove root access, if it was ever persisted.** Confirmed on a genuinely factory-reset `spotty` ([#471 comment 5232232575](https://github.com/gesellix/Bose-SoundTouch/issues/471#issuecomment-5232232575)): after the reset, `margeAccountUUID` was empty, all four service URLs were back to `streaming.bose.com`, and presets were gone — but `/etc/remote_services` and `/mnt/nv/remote_services` **survived**, and SSH (:22) and telnet (:17000) stayed open. So once a device has been through `setup enable-ssh` with persistence (`EnsureRemoteServices`, the default), a later factory reset only wipes configuration, not root access — recovery is re-migrate + re-pair + rename + restore presets, with **no USB stick and no re-running the injection**.
**Readiness after a reboot is per-port, not a single moment.** `JRpersonal` first measured that the firmware needs roughly 60s after a cold boot before `:8090`'s `/info` answers and marge state is ready — a booting device answers a bare `HTTP 400` with an empty body before its services are up, which is easy to misread as a rejection rather than "too early" ([#471 comment 5231997551](https://github.com/gesellix/Bose-SoundTouch/issues/471#issuecomment-5231997551)). `bitranox` refined this across three variants: `:8090` and the diagnostic `:17000` shell (and the config subsystem behind it that `getpdo` reads) do **not** become ready at the same time — waiting for `:8090` and then immediately reading over `:17000` returned an empty response even though the box was otherwise up. Ten observed reboots: down in 2.35.3s, ready (able to answer `getpdo` correctly) in 55.191.8s, median ~69.8s ([#471 comment 5232046477](https://github.com/gesellix/Bose-SoundTouch/issues/471#issuecomment-5232046477)). Anything automated should wait for the specific interface it's about to use, not for a different port to answer first — see the troubleshooting guide's [power-cycle retry note](../guides/TROUBLESHOOTING.md) for the user-facing version of this.
---
## Out of scope here, but worth recording
- **Setup-mode WiFi onboarding via 192.0.2.1.** The community uses this to add a fresh device to a network without the Bose app. Our `soundtouch-service` does not currently automate this, but `network wifi profiles add` is the entry point if we ever do.
@@ -82,6 +82,16 @@ Three important details from the discussion:
silently restored on reboot — i.e. there is a parallel "envswitch" persistence
layer that wins on next boot if you don't also write to it. **We must always
issue both.**
A later, more precise measurement ([#515 comment 5231931569](https://github.com/gesellix/Bose-SoundTouch/issues/515#issuecomment-5231931569), confirmed on
five variants: `lisa`/`mojo`/`spotty`/`ginger`/`taigan`) explains *why*
order matters: `envswitch boseurls set` is not just a two-field setter, it
**commits whatever is currently in the runtime layer at the moment it
runs**. A `sys configuration` write only survives a reboot if `envswitch
boseurls set` runs **after** it; the same commands in reverse order lose
the `sys configuration` values silently on reboot, with every individual
command still answering normally. This is why the sequence above is
ordered all-four-`sys-configuration`-then-`envswitch`, never the reverse.
2. **margeServerUrl path is bare for `soundtouch-service`.** We mount the marge
endpoints at the **root** of port 8000, matching what the existing XML
migration writes (`Manager.migrateViaXML` in `pkg/service/setup/setup.go`
@@ -91,8 +101,15 @@ Three important details from the discussion:
routes marge under that sub-path. **For our service: bare URL. For users
redirecting to soundcork: append `/marge`** to both `margeServerUrl` and
the first argument of `envswitch boseurls set`.
3. **Each command must be sent one at a time, waiting for the device's `OK`
response** before sending the next one (`foob61451`'s explicit warning).
3. **Each command must be sent one at a time, waiting for the device's
response** before sending the next one (`foob61451`'s original warning).
Note the exception: `sys configuration` commands ack with `OK`, but
`envswitch boseurls set` does **not** — it acks with a different string
entirely (observed: `Setting Bose Server URLs to <a> and <b> ->`, no `OK`
substring; [#515 comment 5231931569](https://github.com/gesellix/Bose-SoundTouch/issues/515#issuecomment-5231931569)). An implementation that waits for the
literal token `OK` will time out on exactly that command. Wait for the
shell's prompt (or, as our own `pkg/telnet.Client` does, for the
connection to go idle) rather than string-matching `OK`.
### 2.2 Account pairing fallback
@@ -106,7 +123,11 @@ in-band equivalent to the HTTP `/setMargeAccount` call, useful when the
about.
- Useful read-only verification command: `getpdo CurrentSystemConfiguration`
prints the URLs after the changes have been applied so we can verify before
rebooting.
rebooting. **It only reflects the runtime (`sys configuration`) layer, not
the `envswitch`-persisted layer, so a matching `getpdo` here confirms the
writes were accepted, not that they will survive the reboot** — see the
layer-visibility caveat in
[TELNET-COMMAND-REFERENCE.md](TELNET-COMMAND-REFERENCE.md).
- `sys reboot` is the trigger that re-reads both layers.
### 2.4 What Telnet:17000 cannot do
@@ -145,6 +166,22 @@ The values are not validated by the local service, so any numeric `accountId`
will work — soundcork's runbook (#228) literally calls the token
`soundcorkdoesntcare` to make the point.
> **Booby trap, confirmed on hardware: never send an empty or truncated body
> to this endpoint.** On one firmware, a `POST /setMargeAccount` with an
> empty body returned `HTTP 200` and cleared `margeAccountUUID`, un-pairing
> an already-working speaker
> ([#471 comment 5231977172](https://github.com/gesellix/Bose-SoundTouch/issues/471#issuecomment-5231977172)).
> A later retry on the same device instead returned `400` and changed
> nothing, so the same reporter corrected the finding to
> **state-dependent, not a reliable rule you can rely on either way**
> ([#471 comment 5232232575](https://github.com/gesellix/Bose-SoundTouch/issues/471#issuecomment-5232232575)). A `400` is not proof the
> endpoint rejected a bad request (a booting device also answers a bare
> `400` with an empty body before its services are ready, per
> `JRpersonal`), and a `200` is not proof it did what you wanted. Practical
> takeaway: our own `postSetMargeAccount` always sends a well-formed XML
> body, so this doesn't affect the CLI/service — but don't probe this
> endpoint by hand against a speaker that currently works.
### 3.2 Why it's broken in practice
There are **three independent failure modes** observed:
@@ -189,10 +226,16 @@ control:
recipes).
3. **Randomize.** A "Generate" button that picks a 7-digit number and
re-rolls if it collides with an existing account in the local datastore.
- **Telnet read-back (best-effort).** `envswitch accountid get` is plausible by
symmetry with `envswitch accountid set` (#221) but is not yet confirmed
across firmwares. We will probe it during preflight; if it returns a value
we cross-check it against `:8090/info` and warn on mismatch.
- **Telnet read-back: confirmed unsupported.** `envswitch accountid get` was
originally listed as "plausible by symmetry with `envswitch accountid set`
(#221), not yet confirmed." It's now confirmed the other way: on
`lisa`/`mojo`/`spotty`, `envswitch` has **no read form at all** — both bare
`envswitch` and `envswitch boseurls` answer `Invalid Command Option`
([#515 comment 5231931569](https://github.com/gesellix/Bose-SoundTouch/issues/515#issuecomment-5231931569)).
The persisted layer can only be written, then
observed indirectly after a reboot (e.g. via `getpdo`, mindful of the
layer-visibility caveat in
[TELNET-COMMAND-REFERENCE.md](TELNET-COMMAND-REFERENCE.md)).
This means the user is never *forced* to invent a number — the common path is
"the device already has an ID, reuse it" — and the manual/randomize controls
+1 -1
View File
@@ -1,4 +1,4 @@
---
title: "Analysis & Research"
weight: 4
weight: 5
---
+6 -2
View File
@@ -21,13 +21,17 @@ Most SoundTouch devices run a modified Linux distribution. Accessing these logs
Community research (SoundCork Issue #112) has identified a "backdoor" to enable developer services:
1. **USB Method**:
1. **CLI Method (recommended, no USB needed)**:
- `soundtouch-cli --host <device-ip> setup enable-ssh` drives the port-17000 diagnostic shell to inject the `remote_services` marker and start `sshd`, then waits for `:22`. This is the #471 bootstrap; it needs no prior SSH and no USB stick.
- If the command is accepted (the device persists it, confirmed by `getpdo`) but `sshd` never comes up and `:22` stays "Connection refused", retry with `--full-config`. That variant mirrors the manual telnet sequence confirmed on issue #515: it puts the injection on `sys configuration margeServerUrl` as well as `envswitch`, writes all four URL keys, and reboots.
- **`--full-config` is meant for:** the **SoundTouch Portable (Series I, model 412540, FW `27.0.6.46330.5043500`)** and some **CineMate 520** units, where the default single-`envswitch` path leaves `sshd` down. The default path is sufficient on the Wireless Link Adapter and the CineMate 520 `lisa` variant. Some units (e.g. certain ST10 and CineMate 520 firmwares) do not respond to either path and need the serial / U-Boot console route instead. See [TELNET-COMMAND-REFERENCE.md](../analysis/TELNET-COMMAND-REFERENCE.md#what-we-use-to-enable-ssh-setup-enable-ssh-471) for the exact commands and current confirmation status.
2. **USB Method**:
- Format a USB stick to **FAT32**.
- Create an empty file named `remote_services` (no extension) in the root of the USB stick.
- Insert the stick into the SoundTouch device.
- Reboot the device (power cycle).
- On some models, you may need to hold **4** and **Volume -** on the device while powering on to force a USB check.
2. **TAP Command (Legacy)**:
3. **TAP Command (Legacy)**:
- On older firmware versions, you can connect to port 17000 via Telnet and issue the command: `remote_services on`.
### Making Root Access Persistent
@@ -22,6 +22,8 @@ The encrypted `.age` file decrypts to a `.tar.gz` archive with:
Source, SourceID, location), device product code, firmware version, IP, name
- `datastore/accounts/{id}/devices/{id}/*.xml` — raw XML files verbatim from
the sender's datastore (`Presets.xml`, `Sources.xml`, `Recents.xml`, …)
- `stats/activity/{kind}/*.json` — the local admin-UI activity log (e.g.
announcement-banner dismissals), verbatim, one file per recorded event
Having both the structured JSON and the raw XML lets you compare what the
service serves via HTTP against what is actually stored on disk.
@@ -31,6 +33,24 @@ secrets, Spotify refresh tokens. The raw XML files are included as-is.
---
## Local activity log
AfterTouch records a small local activity log for admin-UI actions —
today, just announcement-banner dismissals (e.g. the admin-area-gate notice
from issue #419) — under `stats/activity/{kind}/` in the data directory.
Each event is its own plain JSON file (id, timestamp, and any detail),
readable with a text editor; there is no encoding or opaque format to
decode.
This follows the same "[all data stays on your
network](SOUNDTOUCH-SERVICE-ANNOUNCEMENT.md)" principle as the rest of
AfterTouch: nothing here is ever transmitted automatically. The only way it
leaves the operator's network is the same as everything else in this
document — an explicitly-triggered diagnostic export, which the operator
has to click a button and choose to send.
---
## Maintainer setup (one-time)
> This section is for the project maintainer only.
@@ -566,7 +566,7 @@ func (m *MockClient) GetNowPlaying() (*models.NowPlaying, error) {
```dockerfile
# test/docker/Dockerfile
FROM golang:1.25-alpine
FROM golang:1.27.0-alpine
WORKDIR /app
COPY . .
@@ -19,14 +19,15 @@ responses and community testing.
> **Reconciliation note (June 2026).** Verified against `pkg/client`. Since the
> last update these are **now implemented** and have been re-marked below:
> `setMusicServiceAccount` / `removeMusicServiceAccount` (`SetMusicServiceAccount`,
> `RemoveMusicServiceAccount`) and the full stereo-pair group set
> `RemoveMusicServiceAccount`), the full stereo-pair group set
> `getGroup` / `addGroup` / `removeGroup` / `updateGroup`
> (`GetGroup`, `AddGroup`, `RemoveGroup`, `UpdateGroup`). The priority-matrix
> counts further down are historical and have not all been recomputed; trust the
> per-endpoint ✅ markers over the section totals. Endpoints still listed as
> candidates (e.g. `/search`, `/standby`, `/powerManagement`, `/bluetoothInfo`,
> `/language`, `/listMediaServers`) were confirmed absent from `pkg/client`
> (some appear only in test fixtures).
> (`GetGroup`, `AddGroup`, `RemoveGroup`, `UpdateGroup`), and
> `listMediaServers` (`ListMediaServers`, with app-side SSDP in `pkg/discovery`).
> The priority-matrix counts further down are historical and have not all been
> recomputed; trust the per-endpoint ✅ markers over the section totals.
> Endpoints still listed as candidates (e.g. `/search`, `/standby`,
> `/powerManagement`, `/bluetoothInfo`, `/language`) were confirmed absent from
> `pkg/client` (some appear only in test fixtures).
---
@@ -294,8 +295,10 @@ Rates currently playing media (Pandora only).
#### GET /listMediaServers 🔥 **CRITICAL**
Returns detected UPnP/DLNA media servers.
#### ~~GET /listMediaServers~~ ✅ **IMPLEMENTED**
~~Returns detected UPnP/DLNA media servers.~~
**Implementation Status:** ✅ Complete - Available in `pkg/client/client.go` as `ListMediaServers()`; response model in `pkg/models/mediaservers.go` as `ListMediaServersResponse`. The CLI exposes this via `soundtouch-cli library servers --via-speaker`. App-side SSDP discovery (without `--via-speaker`) is in `pkg/discovery`.
**Response Example:**
```xml
@@ -1016,7 +1019,7 @@ func TestDeviceCompatibility(t *testing.T) {
1. **Power Management**: `standby`, `powerManagement`, `lowPowerStandby`
2. **Notifications**: `speaker`, `playNotification`
3. **Network Management**: `performWirelessSiteSurvey`, `addWirelessProfile`
4. **System Info**: ~~`serviceAvailability`~~ (✅ implemented), `listMediaServers`, `language`
4. **System Info**: ~~`serviceAvailability`~~ (✅ implemented), ~~`listMediaServers`~~ (✅ implemented), `language`
### Phase 3: Advanced Features (3 weeks)
1. **Bluetooth**: `enterBluetoothPairing`, `clearBluetoothPaired`
@@ -101,13 +101,13 @@ rename and network/firmware info.
---
## 4. Render stereo pairs as a single device
## 4. Stereo-pair presentation and lifecycle (shipped)
Today soundtouch-player shows the two halves of a stereo pair (formed via
`/addGroup` see issue #252) as independent entries in the device list. The
Bose app collapsed a paired ST10 set into one "L+R" entry; restoring that
presentation closes the perception gap BirdyBA flagged at
<https://github.com/gesellix/Bose-SoundTouch/issues/252#issuecomment-4458140305>.
soundtouch-player projects a valid two-speaker stereo pair (formed via
`/addGroup` - see [issue #252](https://github.com/gesellix/Bose-SoundTouch/issues/252))
as one logical control target. This restores the single-entry presentation
expected by users while preserving both physical speakers in the service
registry.
**Device API:**
- `GET /getGroup` on each speaker — returns the current `<group>` with
@@ -118,28 +118,77 @@ presentation closes the perception gap BirdyBA flagged at
side is sufficient to detect the pair
**Backend:**
- During device-list assembly, call `GET /getGroup` for each discovered device
in parallel (matches the propagation pattern already used by
`soundtouch-cli group create` in `cmd/soundtouch-cli/cmd_group.go`)
- Bucket devices by `<masterDeviceId>` — each bucket emits one entry in the
list response. Standalone devices stay as their own bucket-of-one
- Expose pair metadata on the list entry so the UI can render role chips
(`L`/`R`) and resolve role → physical device for actions
- Poll `GET /getGroup` together with the other device status and consume
`groupUpdated` events. A generation check prevents an older poll from
overwriting a newer event.
- Collapse only an exact two-member `LEFT`/`RIGHT` group whose registered
members agree on the group claim. Malformed, conflicting, or ambiguous data
fails open and leaves the physical entries visible.
- Use the master speaker's existing registry key for the logical target, so
controls continue to route through the master without changing the raw
physical-device registry.
- Use the same projection for the REST device list and the global player
WebSocket snapshot.
**Frontend:**
- Device list collapses paired devices into one card titled with both names
(e.g. `"Wohnzimmer L+R"`) and role chips
- Clicking the card opens a device-detail page that exposes both per-role
status and a "Dissolve pair" action (DELETE flow, already wired in
`soundtouch-cli group remove` and in fakespeaker's `/removeGroup` GET)
- Standalone speakers continue to render as today
- Render one card using the shared member name or the group's name.
- Show pair availability as `Stereo pair n/2` and mark the card degraded when
a member is unavailable or the group reports a non-OK state.
- Hide the single-device remove action on a projected pair. Standalone
speakers continue to render as before.
- For standalone stereo-capable SoundTouch 10 speakers, offer pair creation
with an explicit LEFT/master and RIGHT member.
- For an existing pair, offer rename and a separately confirmed dissolve
action. A dissolve changes speaker group state; it does not delete either
physical speaker from the player registry.
**Note:** Pair lifecycle (create / rename / remove) already works
end-to-end — `pkg/client` group endpoints + `cmd/soundtouch-cli/cmd_group.go`,
covered by tests in `cmd/soundtouch-cli/cmd_group_test.go` and exercisable
against the fake speaker's group routes
(`pkg/service/testing/fakespeaker/fakespeaker.go`). This task is purely about
presentation in soundtouch-player's device list — no protocol work required.
**Lifecycle safety:**
- A shared coordinator backs both the CLI and player. It freshly checks both
speakers, their L/R capability, current group, and temporary-zone state
before a mutation. Pair creation also requires one shared Marge account and
backend.
- After both create candidates are freshly verified as physically standalone,
a fail-closed, read-only persistence barrier checks for a stored group before
either speaker is mutated. The embedded service searches every account by
device ID; standalone player and CLI query the speakers' current Marge
backend. Creation stops and reports the exact stale generation when any
record remains; pre-create checks never delete it.
- Create sends the asymmetric master/slave payloads required by the speaker
state machines, then freshly verifies that both members agree. A partial
create is compensated only where the exact group generation returned by
that speaker can be proven.
- Rename and dissolve update both physical speakers and report a degraded
result, including per-member detail, instead of claiming success after a
partial transition.
- Rename and dissolve carry the group ID displayed to the user and reject a
stale request if either speaker now belongs to another generation.
- A degraded dissolve retains the last exact L/R topology for a bounded retry.
The retry freshly verifies both physical identities and states, and stored
persistence must match that full topology before it can be retired.
- Legacy Marge teardown callbacks without a group ID are acknowledged without
deleting persistent state. After a verified physical dissolve, the embedded
player retires the exact generation directly in its datastore; standalone
player and CLI use the generation-aware endpoint derived from fresh speaker
info. Physical verification and exact persistence cleanup share one
coordinator lock, and a cleanup failure is returned as degraded.
- Retired group IDs leave their small XML snapshot in the datastore and
active/retired IDs are reserved across all accounts, so an account move or
stale request cannot match a later physical generation.
- Pair mutations are rejected while either member belongs to a temporary
multi-room zone. The zone must be dissolved first.
!!! warning "Run lifecycle operations site-locally"
Marge hostnames such as `unifi` are resolved from the caller's site, not
from the speaker's site. Create, rename, and dissolve a pair through the
Player/service deployment co-located with both speakers and their Marge
backend; a cross-site registry entry is not a backend-routing mechanism.
!!! warning "Datastore downgrade boundary"
Once this lifecycle has written a `Group_<id>.retired` snapshot, do not run an
older service binary against the same datastore. Older allocators do not
reserve these generation IDs globally and can reuse one. Restore both the
binary and its pre-lifecycle datastore snapshot for a rollback, or upgrade
forward.
---
+1 -1
View File
@@ -1,6 +1,6 @@
---
title: "Architecture"
weight: 5
weight: 6
---
Architecture notes and analyses:
+1 -1
View File
@@ -1,4 +1,4 @@
---
title: "Concepts"
weight: 3
weight: 4
---
@@ -279,6 +279,10 @@ Or set the equivalent environment variables: `AMAZON_CLIENT_ID`, `AMAZON_CLIENT_
### 3. Trigger the OAuth flow
> The commands below use the published default Management API credentials
> (`admin` / `change_me!`); substitute your own if you've changed them (see
> [Configuration Options](../guides/SOUNDTOUCH-SERVICE.md#configuration-options)).
```bash
# Get the LWA authorization URL
curl -u admin:change_me! -X POST http://localhost:8000/mgmt/amazon/init
+1 -1
View File
@@ -118,6 +118,6 @@ sequenceDiagram
## Security
- `/mgmt/spotify/callback` is intentionally outside Basic Auth to allow direct redirects from Spotify's authorization server.
- All other `/mgmt/*` endpoints require Basic Auth as configured by `--mgmt-username` and `--mgmt-password`.
- All other `/mgmt/*` endpoints require Basic Auth as configured by `--mgmt-username` and `--mgmt-password` (defaults documented in [Configuration Options](../guides/SOUNDTOUCH-SERVICE.md#configuration-options)).
- Tokens are persisted to disk as JSON with restricted file permissions (`0600`).
- The `GetAccounts` endpoint strips sensitive tokens from the response.
+128
View File
@@ -0,0 +1,128 @@
---
title: "Downloads"
weight: 1
sidebar:
open: true
---
# Downloads
Everything AfterTouch ships is on the
**[GitHub releases page](https://github.com/gesellix/Bose-SoundTouch/releases/latest)**.
This page helps you pick the right file: choose **which tool** you need,
then **which build** matches your computer.
## 1. Which tool do I need?
AfterTouch is a small set of separate programs. Most people run one or
two of them.
| Tool | What it does | You want this if… |
|----------------------|------------------------------------------------------------------------------------------------|---------------------------------------------------------|
| `soundtouch-service` | The local cloud replacement ("AfterTouch"). Runs always-on and takes over from the Bose cloud. | You are migrating speakers off the Bose cloud. |
| `soundtouch-cli` | Command-line control and setup (status, play, presets, groups, **migration**, …). | You want to script things, or run a migration by hand. |
| `soundtouch-player` | A browser control panel (radio browsing, device control). | You want a web UI to browse radio and control speakers. |
| `soundtouch-backup` | Backs up your Bose cloud account and each speaker's local state. | You are preparing before a shutdown / factory reset. |
Most people only need **`soundtouch-service`** and **`soundtouch-cli`** — the
release notes on each [GitHub release](https://github.com/gesellix/Bose-SoundTouch/releases/latest)
link those two directly, one row per platform, so you don't have to hunt
through the flat Assets list below.
> Running a migration from the command line (for example the telnet
> re-migration in the
> [troubleshooting guide](../guides/TROUBLESHOOTING.md#radio-sources-after-migration))
> uses **`soundtouch-cli`**.
## 2. Which build matches my computer?
Release assets are named:
```
soundtouch-<tool>-v<VERSION>-<os>-<arch>[.exe]
```
Pick the `<os>-<arch>` suffix for your system:
| Your system | `<os>-<arch>` suffix |
|--------------------------------------|----------------------|
| Raspberry Pi (64-bit) / ARM64 Linux | `linux-arm64` |
| Raspberry Pi (32-bit) / ARMv7 | `linux-armv7` |
| Linux (64-bit PC) | `linux-amd64` |
| macOS (Apple Silicon: M1/M2/M3/…) | `darwin-arm64` |
| macOS (Intel) | `darwin-amd64` |
| Windows (64-bit) | `windows-amd64.exe` |
| FreeBSD (64-bit) | `freebsd-amd64` |
**Example.** To control speakers from a Raspberry Pi 4, download the CLI
build `soundtouch-cli-vX.Y.Z-linux-arm64`. On an Apple Silicon Mac you
would take `soundtouch-cli-vX.Y.Z-darwin-arm64` instead.
The download is a single executable, ready to run (no archive to extract).
Each asset ships with `.sha256` and `.sha512` checksum files, and every
release also has combined `checksums.sha256` / `checksums.sha512` if you
want to verify the download.
> **macOS / Windows note:** because these binaries are not code-signed,
> the OS may warn on first launch (Gatekeeper on macOS, SmartScreen on
> Windows). Approve it in the security prompt, or use the Docker or
> install-script routes below.
## 3. Other ways to install
### Install scripts (Linux / Raspberry Pi)
These download the latest release for you and set up a background service.
- **Service** (`soundtouch-service`):
```bash
curl -fsSL -o install.sh \
https://raw.githubusercontent.com/gesellix/Bose-SoundTouch/main/scripts/raspberry-pi/install.sh
sudo bash install.sh
```
- **Player** (`soundtouch-player`):
```bash
curl -fsSL -o install-player.sh \
https://raw.githubusercontent.com/gesellix/Bose-SoundTouch/main/scripts/raspberry-pi/install-player.sh
sudo bash install-player.sh
```
There is also an **on-device** installer that runs AfterTouch directly on
the speaker; see the
[On-Device Install Walkthrough](../guides/ON-DEVICE-INSTALL-WALKTHROUGH.md).
### Docker
```bash
# AfterTouch service
docker pull ghcr.io/gesellix/bose-soundtouch:latest
# Web player
docker pull ghcr.io/gesellix/bose-soundtouch-player:latest
```
Both images are multi-arch (`linux/amd64`, `linux/arm64`, `linux/arm/v7`).
See the [Deployment Guide](../guides/DEPLOYMENT.md) for Docker Compose
examples.
### Go toolchain
If you have Go installed you can build from source:
```bash
go install github.com/gesellix/bose-soundtouch/cmd/soundtouch-cli@latest
go install github.com/gesellix/bose-soundtouch/cmd/soundtouch-service@latest
go install github.com/gesellix/bose-soundtouch/cmd/soundtouch-player@latest
go install github.com/gesellix/bose-soundtouch/cmd/soundtouch-backup@latest
```
## 4. Not sure how to deploy?
The [Deployment Overview](../guides/DEPLOYMENT-OVERVIEW.md) compares
running AfterTouch on a Raspberry Pi / always-on host against running it
directly on the speaker, with step-by-step walkthroughs for each path.
For the full migration story, start with the
[Migration Guide](../guides/MIGRATION-GUIDE.md).
+334
View File
@@ -592,6 +592,9 @@ soundtouch-cli --host <device> account remove-amazon --user <USER>
soundtouch-cli --host <device> account remove-deezer --user <USER>
soundtouch-cli --host <device> account remove-iheart --user <USER>
soundtouch-cli --host <device> account remove-nas --user <GUID/0> [--name <NAME>]
# Unpair the device from its Marge cloud account entirely
soundtouch-cli --host <device> account unpair
```
**Supported Services:**
@@ -648,6 +651,11 @@ soundtouch-cli --host 192.0.2.10 account remove \
- Network music libraries (STORED_MUSIC) don't require passwords, only the UPnP server GUID
- After adding an account, use `source list` to verify it appears as available
- Some services may require additional authentication steps through their mobile apps
- `account unpair` is different from the above: it sends `UnPairDeviceWithAccount`
over the speaker's own local WebSocket to remove its **Marge cloud account**
pairing entirely (`margeAccountUUID`), not a single streaming-service login.
See `setup revert` for the related "undo a migration" operation, which
deliberately does *not* call this — the two are separate steps.
### Bass Control
@@ -847,6 +855,48 @@ soundtouch-cli --host 192.0.2.10 zone remove --member 192.0.2.12
soundtouch-cli --host 192.0.2.10 zone dissolve
```
### Stereo Pair Management
Create and manage a persistent LEFT/RIGHT pair of two SoundTouch 10 speakers.
This is distinct from a temporary multi-room zone. Both speakers must be
online, stereo-capable, standalone, and outside any zone before a lifecycle
operation. Pair creation also requires both speakers to use the same Marge
account and backend. Run lifecycle commands from the site containing both
speakers; site-relative Marge names such as `unifi` do not identify a remote
site when resolved by the CLI host.
```bash
# Inspect a standalone speaker or either member of a pair
soundtouch-cli --host 192.0.2.10 group status
# Create a pair; the LEFT speaker becomes the master
soundtouch-cli group create \
--left 192.0.2.10 \
--right 192.0.2.11 \
--name "Living Room"
# Rename through either member
soundtouch-cli --host 192.0.2.10 group rename --name "Living Room Pair"
# Dissolve the pair without removing either speaker from AfterTouch
soundtouch-cli --host 192.0.2.10 group remove
```
Create, rename, and remove verify fresh state on both speakers. Rename and
remove first inspect the current group and carry its ID as a generation guard;
if the pair changes before mutation, the operation fails without touching the
newer pair. A partial transition is reported as degraded with per-speaker
details rather than as a successful operation. A remove attempt carries the
last exact L/R topology, freshly verifies both speakers, and retires
persistence only if the stored generation still matches it. Before create,
the CLI verifies
both speakers as standalone, queries their current Marge backend for stale
group records, and refuses to mutate either speaker while any record remains.
After verified physical cleanup, the CLI removes the exact group ID through the
Marge URL and account freshly read from the speaker. A backend cleanup failure
is therefore visible as a degraded result instead of leaving an apparently
successful stale generation.
### Browse and Navigation
Browse and navigate content sources on your device.
@@ -1157,6 +1207,290 @@ soundtouch-cli --host 192.0.2.10 events subscribe --filter zone --no-reconnect
- Events are displayed in real-time with emoji indicators
- Verbose mode shows additional technical details
### Update Check
#### `update-check`
Check GitHub Releases for a newer `soundtouch-cli` version. Unlike
`soundtouch-service`'s periodic background check, this doesn't need a
`--host` or any device on the network: it's a single, on-demand GitHub API
request. Running the command is itself the opt-in, so there's no config
flag or persisted state.
**Usage:**
```bash
soundtouch-cli update-check
```
**Example output:**
```
A newer version is available: v1.3.0 (you're on v1.2.0)
https://github.com/gesellix/Bose-SoundTouch/releases/tag/v1.3.0
```
**Notes:**
- `soundtouch-backup` has the same `update-check` command.
- If the running binary isn't a released version (e.g. a dev build),
the command reports that and skips the comparison.
### Setup & Migration
The `setup <subcommand>` group provisions a speaker end-to-end: enabling
SSH, factory-reset + Wi-Fi re-provisioning, pointing it at AfterTouch, CA
trust, account pairing, reverting, and one-shot data sync. Each subcommand
wraps an existing `pkg/service/setup` helper directly — there's no separate
business logic in the CLI layer. Manual provisioning-loop background:
[docs/analysis/SETUP-WEBSOCKET-EXPERIMENT.md](../analysis/SETUP-WEBSOCKET-EXPERIMENT.md)
and [Device Initial Setup](DEVICE-INITIAL-SETUP.md).
#### `setup inspect`
Non-destructive snapshot of the speaker: identity, pairing state, Wi-Fi,
sources, presets, and (with `--telnet`) the runtime URL configuration via
`getpdo`. Good first command to run against an unfamiliar speaker.
```bash
soundtouch-cli --host <device> setup inspect
soundtouch-cli --host <device> setup inspect --telnet # also reads runtime URLs (slower)
```
#### `setup ssh-check`
Probes whether port 22 is reachable. On failure, prints the `enable-ssh`
suggestion and the USB-stick fallback procedure.
```bash
soundtouch-cli --host <device> setup ssh-check [--timeout 3s]
```
#### `setup enable-ssh`
Bootstraps SSH on a speaker with no prior access, via the port-17000
`envswitch` trick (#471) — no USB stick needed. Auto-pairs an unpaired
(factory-reset) device first by default (the injection needs something to
poll), waits for `:22`, and persists the `remote_services` marker so SSH
survives a reboot.
```bash
soundtouch-cli --host <device> setup enable-ssh
soundtouch-cli --host <device> setup enable-ssh --service-url https://192.0.2.10:8443
```
Flags:
- `--service-url` — optional; only the vehicle for the injection, no live
server required. Set the real URL later via `setup migrate`.
- `--wait` (default `90s`) — how long to wait for `:22` after injection.
- `--full-config` — for stubborn devices (ST Portable, CineMate 520) where
the default injection is accepted but `sshd` never starts: writes all
four config URLs (the #515 sequence) and reboots.
- `--command-delay` — only affects `--full-config`; pause between its 6
steps.
- `--no-auto-pair` / `--account` — skip or control the automatic pairing
check.
- `--no-reset-urls` — skip restoring clean `boseurls` after SSH is up.
- `--no-persist` — skip persisting `remote_services` (SSH won't survive a
reboot).
- `--authorized-key` — opt-in hardening: install an SSH public key instead
of relying on the empty-password login.
- `--close-17000` — opt-in hardening: firewall off port 17000 from the LAN
(loopback access kept).
#### `setup remote-services`
Enables (default) or removes the `remote_services` SSH-enablement marker.
```bash
soundtouch-cli --host <device> setup remote-services # ensure it's present
soundtouch-cli --host <device> setup remote-services --remove # disable SSH after next reboot
```
#### `setup factory-reset`
Issues `sys factorydefault` over telnet — wipes account, presets, and
Wi-Fi, and reboots the speaker into its own setup-mode AP. Prints the next
steps (`wait-ap`, then `wifi-push`).
```bash
soundtouch-cli --host <device> setup factory-reset
```
> **Heads-up:** just before resetting, the speaker sends
> `DELETE /streaming/account/{id}/device/{id}` to whatever `margeURL` is
> *currently* configured. If that still points at `streaming.bose.com`
> (not AfterTouch), AfterTouch keeps a stale datastore entry — migrate
> first if you want a clean record.
#### `setup wait-ap`
Polls the speaker's setup-mode AP (default `192.0.2.1`) until `/info`
responds, after a factory reset.
```bash
soundtouch-cli setup wait-ap [--ap-host 192.0.2.1] [--interval 2s] [--timeout 5m]
```
#### `setup wifi-push`
POSTs `AddWirelessProfile` to the speaker's setup-mode endpoint — pushes
your home Wi-Fi credentials while connected to the speaker's AP.
```bash
soundtouch-cli setup wifi-push --ssid="YourHomeSSID" --pass='your-password'
```
Flags: `--security` (default `wpa_or_wpa2`), `--ap-host` (default
`192.0.2.1`), `--request-timeout` (default `30s` — the speaker can be slow
to ACK before tearing down AP mode; 10s often races).
#### `setup wait-online`
Polls mDNS until a speaker matching `--match` comes online on the home
network — run this after switching back from the speaker's AP.
```bash
soundtouch-cli setup wait-online --match=<last-6-hex-of-deviceID>
```
`--match` is empty by default (first speaker seen); `--interval` (`3s`) and
`--timeout` (`5m`) control the poll.
#### `setup install-ca`
Fetches AfterTouch's CA cert from `/api/setup/ca.crt` and injects it into
the speaker's trust store via SSH.
```bash
soundtouch-cli --host <device> setup install-ca --service-url https://192.0.2.10:8443
```
`--auth` (`user:pass`) supplies basic-auth credentials up front; omit it to
be prompted interactively if the endpoint returns 401.
#### `setup migrate`
Applies a migration method to point the speaker at AfterTouch — the CLI
equivalent of the web UI's Migrate tab.
```bash
soundtouch-cli --host <device> setup migrate --service-url http://192.0.2.10:8000 --method telnet
```
`--method` is one of `telnet` (default) | `hosts` | `resolv` | `xml`.
`--proxy-url` sets an optional upstream proxy (only used by `--method=xml`).
`--skip-preflight` skips AfterTouch's settings preflight check (useful when
that endpoint is unreachable).
`--marge-url`/`--stats-url`/`--sw-update-url`/`--bmx-url` override the
corresponding field instead of deriving it from `--service-url` (applies to
both `--method=telnet` and `--method=xml`). Useful beyond soundcork-style
setups: e.g. pointing a speaker back at the **original Bose cloud URLs**
without a full `setup revert` — telnet writes both the runtime and
persisted layers in a single connection, no SSH or `.original` backup
needed:
```bash
soundtouch-cli --host <device> setup migrate --method telnet \
--service-url https://streaming.bose.com \
--marge-url https://streaming.bose.com \
--stats-url https://events.api.bosecm.com \
--sw-update-url https://worldwide.bose.com/updates/soundtouch \
--bmx-url https://content.api.bose.io/bmx/registry/v1/services
```
#### `setup revert`
Undoes a migration — the CLI equivalent of the web UI's "Revert to
Defaults" button. Restores `SoundTouchSdkPrivateCfg.xml`, `/etc/hosts`, and
`/etc/resolv.conf` from their `.original` backups, removes the AfterTouch
DNS-hook artifacts, and strips just the AfterTouch-labeled certificate out
of the trust bundle. No `--service-url` needed — everything it touches
already lives on the speaker.
```bash
soundtouch-cli --host <device> setup revert
```
**Out of scope for this command** (matches the web UI button): SSH /
`remote_services` persistence (use `setup remote-services --remove`) and
account pairing (use `account unpair`) are untouched — revert them
separately if you want a fully clean speaker.
#### `setup reboot`
Reboots the speaker — useful to force the envswitch parallel-persistence
layer to apply after a migration.
```bash
soundtouch-cli --host <device> setup reboot [--method telnet|ssh]
```
`--method` defaults to `telnet`, which works without SSH on modern
firmware.
#### `setup verify`
Read-only status probe across every migration axis (transports, URL
configuration, DNS interception, CA/TLS, pairing) — doubles as a preflight
check before applying changes and a verification step afterward. Exits
non-zero if nothing reports migrated, so it's usable as a CI gate.
```bash
soundtouch-cli --host <device> setup verify --service-url http://192.0.2.10:8000
```
#### `setup plan`
Recommends the next setup/migration steps based on `inspect` + `verify`
state — prints a ready-to-run command for each recommended step.
```bash
soundtouch-cli --host <device> setup plan --service-url http://192.0.2.10:8000
soundtouch-cli --host <device> setup plan --service-url http://192.0.2.10:8000 --reset # plan a full factory-reset → Wi-Fi → migrate → pair flow
```
`--wifi-ssid` overrides the SSID used for the `wifi-push` step in a reset
plan (default: reuse the SSID `inspect` found). `--include-pair` (default
`true`) can be disabled if you'll pair manually.
#### `setup pair`
Pairs the speaker with an account via the WebSocket `SETUP` state machine
(`--mode=full`, matching the Bose app's own flow) or a minimal
`setMargeAccount`-only call (`--mode=bare`, the same underlying call the
Health tab's "empty margeAccountUUID" QuickFix uses).
```bash
soundtouch-cli --host <device> setup pair --mode=full --account=1111111 --service-url http://192.0.2.10:8000
soundtouch-cli --host <device> setup pair --mode=bare --account=1111111 --service-url http://192.0.2.10:8000
```
`--account` empty generates a fresh 7-digit ID. `--name` sets the speaker
name during pairing (empty keeps current). `--language` defaults to `3`
(English). `--token` defaults to a built-in placeholder matching the Bose
app's token shape.
`--mode=full` first reads `/supportedURLs` and `/soundTouchConfigurationStatus`
and only runs the state machine when the device reports
`SOUNDTOUCH_NOT_CONFIGURED` (see [#615](https://github.com/gesellix/Bose-SoundTouch/issues/615):
a speaker can be reachable, named, and already account-paired yet still
report `SOUNDTOUCH_NOT_CONFIGURED`, leaving the "install the Bose app"
prompt on screen — only a full pass through the state machine clears it).
An already-configured device is a no-op; an unsupported route or an
unrecognised status value fails the command instead of guessing.
#### `setup sync`
Pulls presets, recents, and sources from the speaker into AfterTouch's
datastore — the CLI equivalent of the web UI's Devices → Sync Data button.
Read-only towards the speaker: it never writes anything back.
```bash
soundtouch-cli --host <device> setup sync --service-url http://192.0.2.10:8000
```
`--auth` (`user:pass`) supplies basic-auth credentials up front; omit it to
be prompted interactively if the endpoint returns 401.
## Common Usage Patterns
### Quick Device Setup
@@ -37,8 +37,11 @@ internet-facing, those endpoints are reachable by anyone who knows the URL.
Minimum mitigations before going live:
- Enable **HTTP Basic Auth** on the management UI (set via `MGMT_USERNAME` /
`MGMT_PASSWORD` or the `--mgmt-username` / `--mgmt-password` flags).
- **Change the Management API password**HTTP Basic Auth on the management
UI is always on, but ships with a published default
(`admin` / `change_me!`); set your own via `MGMT_USERNAME` /
`MGMT_PASSWORD` (or the `--mgmt-username` / `--mgmt-password` flags — see
[Configuration Options](SOUNDTOUCH-SERVICE.md#configuration-options)).
- Run AfterTouch **behind a reverse proxy** (Nginx, Caddy, Coolify, Traefik)
and consider blocking the `/streaming/*` paths to all but your speaker's
IP address at the proxy level if your server/firewall allows it.
@@ -46,6 +49,48 @@ Minimum mitigations before going live:
---
## Client IP behind a proxy or load balancer
Behind a reverse proxy or load balancer, the connection AfterTouch sees comes
from the proxy, not from the speaker. A few handlers act on the source IP (for
example the Spotify priming triggered by `/marge/streaming/support/power_on`,
and the device IP AfterTouch records), so in a proxied setup you usually want
it to recover the real speaker IP from the `X-Forwarded-For` header.
Enable it in `data/settings.json`:
- Set `"trust_forwarded_headers": true`.
- Set `"trusted_proxy_cidrs"` to your proxy's own source IP range(s) **as
AfterTouch sees them**, for example `["10.0.0.0/8"]`. It defaults to loopback
(`127.0.0.0/8`, `::1/128`), which already covers a proxy on the same host.
When the proxy runs in a separate Docker container, the address AfterTouch
sees is usually the Docker bridge gateway/subnet (e.g. `172.16.0.0/12`), not
the proxy's published address.
Make sure the proxy sets the header (nginx:
`proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;`). Only
`X-Forwarded-For` is consulted (not `X-Real-IP` or `True-Client-IP`).
The trust decision is made on the **immediate TCP connection**: AfterTouch
reads `X-Forwarded-For` only when the connecting socket's own source IP is in
`trusted_proxy_cidrs`. That socket address is the real connection, so an
`X-Forwarded-For` header cannot forge it.
| Deployment | `trust_forwarded_headers` | Client IP AfterTouch uses |
|---------------------------------------------------------------------|---------------------------|----------------------------------------------------------------------------------------|
| Direct LAN / on-device (no proxy) | `false` (default) | the connecting socket's IP; `X-Forwarded-For` is ignored |
| Behind a proxy whose socket IP is in `trusted_proxy_cidrs` | `true` | the rightmost `X-Forwarded-For` entry outside `trusted_proxy_cidrs` (the real speaker) |
| A direct connection whose socket IP is not in `trusted_proxy_cidrs` | `true` | the socket IP; `X-Forwarded-For` is ignored (spoofing protection) |
> **Do not enable `trust_forwarded_headers` on a flat LAN with no proxy.** A
> malicious speaker could then send `X-Forwarded-For` itself and spoof its
> source IP. A missing or unparseable header always falls back to the socket IP.
For terminating TLS at the proxy (serving the certificate on `:443`), see the
[reverse proxy section of the HTTPS guide](HTTPS-SETUP.md#reverse-proxy-optional).
---
## Step 1 — Deploy AfterTouch on your server
### Docker / Docker Compose (any VPS)
@@ -118,7 +163,7 @@ migration must be driven from `soundtouch-cli` **running on your own machine
on the same LAN as the speaker**.
Download `soundtouch-cli` for your OS from the
[Releases page](https://github.com/gesellix/Bose-SoundTouch/releases).
[Downloads page](../downloads/_index.md).
### Check the migration plan first
+84 -84
View File
@@ -115,25 +115,25 @@ type ProductionSoundTouchService struct {
type Config struct {
// Server settings
ListenAddr string `env:"LISTEN_ADDR" default:":8080"`
// SoundTouch settings
DeviceHosts []string `env:"DEVICE_HOSTS" separator:","`
DiscoveryTimeout time.Duration `env:"DISCOVERY_TIMEOUT" default:"30s"`
RequestTimeout time.Duration `env:"REQUEST_TIMEOUT" default:"15s"`
MaxRetries int `env:"MAX_RETRIES" default:"3"`
// Connection pool
MaxConnections int `env:"MAX_CONNECTIONS" default:"10"`
IdleTimeout time.Duration `env:"IDLE_TIMEOUT" default:"5m"`
// Monitoring
MetricsEnabled bool `env:"METRICS_ENABLED" default:"true"`
HealthCheckInterval time.Duration `env:"HEALTH_CHECK_INTERVAL" default:"30s"`
// Logging
LogLevel string `env:"LOG_LEVEL" default:"info"`
LogFormat string `env:"LOG_FORMAT" default:"json"`
// Security
EnableTLS bool `env:"ENABLE_TLS" default:"false"`
TLSCertFile string `env:"TLS_CERT_FILE"`
@@ -145,7 +145,7 @@ func LoadConfig() (*Config, error) {
if err := env.Parse(cfg); err != nil {
return nil, fmt.Errorf("failed to parse config: %w", err)
}
return cfg, cfg.Validate()
}
@@ -153,15 +153,15 @@ func (c *Config) Validate() error {
if len(c.DeviceHosts) == 0 {
return fmt.Errorf("at least one device host must be specified")
}
if c.RequestTimeout < time.Second {
return fmt.Errorf("request timeout must be at least 1 second")
}
if c.EnableTLS && (c.TLSCertFile == "" || c.TLSKeyFile == "") {
return fmt.Errorf("TLS cert and key files required when TLS is enabled")
}
return nil
}
```
@@ -191,7 +191,7 @@ pool:
monitoring:
metrics_enabled: true
health_check_interval: "30s"
logging:
level: "info"
format: "json"
@@ -203,12 +203,12 @@ func LoadConfigFromFile(path string) (*Config, error) {
if err != nil {
return nil, err
}
var cfg Config
if err := yaml.Unmarshal(data, &cfg); err != nil {
return nil, err
}
return &cfg, cfg.Validate()
}
```
@@ -224,14 +224,14 @@ func LoadConfigFromFile(path string) (*Config, error) {
type SecureNetworkConfig struct {
// Allowed source IP ranges
AllowedCIDRs []string
// Rate limiting
RateLimit int
RateLimitWindow time.Duration
// TLS configuration
TLSConfig *tls.Config
// Timeouts for security
ReadTimeout time.Duration
WriteTimeout time.Duration
@@ -240,7 +240,7 @@ type SecureNetworkConfig struct {
func NewSecureServer(config SecureNetworkConfig) *http.Server {
mux := http.NewServeMux()
// Add middleware
handler := applyMiddleware(mux,
corsMiddleware(),
@@ -249,7 +249,7 @@ func NewSecureServer(config SecureNetworkConfig) *http.Server {
loggingMiddleware(),
metricsMiddleware(),
)
return &http.Server{
Handler: handler,
TLSConfig: config.TLSConfig,
@@ -275,12 +275,12 @@ func (r *DeviceControlRequest) Validate() error {
if err := validate.Struct(r); err != nil {
return fmt.Errorf("validation failed: %w", err)
}
// Additional business logic validation
if r.Action == "volume" && r.Volume == nil {
return fmt.Errorf("volume value required for volume action")
}
return nil
}
```
@@ -302,12 +302,12 @@ func loadSecretsFromK8s() (*SecretsConfig, error) {
if err != nil {
return nil, err
}
tlsKey, err := os.ReadFile("/etc/secrets/tls.key")
if err != nil {
return nil, err
}
return &SecretsConfig{
TLSCert: string(tlsCert),
TLSKey: string(tlsKey),
@@ -335,21 +335,21 @@ type Logger struct {
func NewLogger(level, format, component string) (*Logger, error) {
logger := logrus.New()
// Set level
logLevel, err := logrus.ParseLevel(level)
if err != nil {
return nil, err
}
logger.SetLevel(logLevel)
// Set format
if format == "json" {
logger.SetFormatter(&logrus.JSONFormatter{
TimestampFormat: time.RFC3339,
})
}
return &Logger{
Logger: logger,
component: component,
@@ -376,15 +376,15 @@ type Metrics struct {
RequestsTotal prometheus.CounterVec
RequestDuration prometheus.HistogramVec
RequestsInFlight prometheus.GaugeVec
// Device metrics
DevicesConnected prometheus.Gauge
DeviceHealth prometheus.GaugeVec
WebSocketConnections prometheus.Gauge
// Error metrics
ErrorsTotal prometheus.CounterVec
// Business metrics
VolumeChanges prometheus.CounterVec
SourceChanges prometheus.CounterVec
@@ -400,7 +400,7 @@ func NewMetrics() *Metrics {
},
[]string{"method", "endpoint", "status"},
),
RequestDuration: *prometheus.NewHistogramVec(
prometheus.HistogramOpts{
Name: "soundtouch_request_duration_seconds",
@@ -409,14 +409,14 @@ func NewMetrics() *Metrics {
},
[]string{"method", "endpoint"},
),
DevicesConnected: prometheus.NewGauge(
prometheus.GaugeOpts{
Name: "soundtouch_devices_connected",
Help: "Number of connected devices",
},
),
DeviceHealth: *prometheus.NewGaugeVec(
prometheus.GaugeOpts{
Name: "soundtouch_device_health",
@@ -425,7 +425,7 @@ func NewMetrics() *Metrics {
[]string{"device_id", "device_name"},
),
}
// Register metrics
prometheus.MustRegister(
m.RequestsTotal,
@@ -433,7 +433,7 @@ func NewMetrics() *Metrics {
m.DevicesConnected,
m.DeviceHealth,
)
return m
}
@@ -457,7 +457,7 @@ type HealthChecker struct {
func (hc *HealthChecker) Start(ctx context.Context) {
ticker := time.NewTicker(hc.interval)
defer ticker.Stop()
for {
select {
case <-ctx.Done():
@@ -470,7 +470,7 @@ func (hc *HealthChecker) Start(ctx context.Context) {
func (hc *HealthChecker) checkAllDevices() {
var wg sync.WaitGroup
for deviceID, device := range hc.manager.devices {
wg.Add(1)
go func(id string, dev *DeviceInfo) {
@@ -478,18 +478,18 @@ func (hc *HealthChecker) checkAllDevices() {
hc.checkDevice(id, dev)
}(deviceID, device)
}
wg.Wait()
}
func (hc *HealthChecker) checkDevice(deviceID string, device *DeviceInfo) {
ctx, cancel := context.WithTimeout(context.Background(), hc.timeout)
defer cancel()
start := time.Now()
err := device.Client.Ping()
duration := time.Since(start)
if err != nil {
device.Status = DeviceStatusUnhealthy
hc.metrics.DeviceHealth.WithLabelValues(deviceID, device.Name).Set(0)
@@ -507,14 +507,14 @@ func (hc *HealthChecker) HealthHandler() http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
healthy := 0
total := 0
for _, device := range hc.manager.devices {
total++
if device.Status == DeviceStatusHealthy {
healthy++
}
}
status := map[string]interface{}{
"status": "ok",
"devices": map[string]interface{}{
@@ -524,14 +524,14 @@ func (hc *HealthChecker) HealthHandler() http.HandlerFunc {
},
"timestamp": time.Now().UTC(),
}
w.Header().Set("Content-Type", "application/json")
if healthy < total {
w.WriteHeader(http.StatusServiceUnavailable)
status["status"] = "degraded"
}
json.NewEncoder(w).Encode(status)
}
}
@@ -560,16 +560,16 @@ func NewConnectionPool(maxIdle, maxActive int, idleTimeout time.Duration) *Conne
maxActive: maxActive,
idleTimeout: idleTimeout,
}
// Start cleanup goroutine
go cp.cleanup()
return cp
}
func (cp *ConnectionPool) Get(host string, port int) (*client.Client, error) {
key := fmt.Sprintf("%s:%d", host, port)
// Check if connection exists and is valid
if val, ok := cp.clients.Load(key); ok {
conn := val.(*pooledConnection)
@@ -580,35 +580,35 @@ func (cp *ConnectionPool) Get(host string, port int) (*client.Client, error) {
// Connection expired, remove it
cp.clients.Delete(key)
}
// Check active connection limit
if atomic.LoadInt64(&cp.activeCount) >= int64(cp.maxActive) {
return nil, fmt.Errorf("connection pool exhausted")
}
// Create new connection
config := client.ClientConfig{
Host: host,
Port: port,
Timeout: 15 * time.Second,
}
newClient := client.NewClient(config)
// Test connection
if err := newClient.Ping(); err != nil {
return nil, fmt.Errorf("failed to connect to %s:%d: %w", host, port, err)
}
conn := &pooledConnection{
client: newClient,
lastUsed: time.Now(),
created: time.Now(),
}
cp.clients.Store(key, conn)
atomic.AddInt64(&cp.activeCount, 1)
return newClient, nil
}
@@ -621,7 +621,7 @@ type pooledConnection struct {
func (cp *ConnectionPool) cleanup() {
ticker := time.NewTicker(cp.idleTimeout / 2)
defer ticker.Stop()
for range ticker.C {
now := time.Now()
cp.clients.Range(func(key, val interface{}) bool {
@@ -649,10 +649,10 @@ func NewCacheManager() *CacheManager {
return &CacheManager{
// Device info rarely changes, cache for 1 hour
deviceInfoCache: cache.New(1*time.Hour, 2*time.Hour),
// Capabilities never change, cache for 24 hours
capabilitiesCache: cache.New(24*time.Hour, 48*time.Hour),
// Volume changes frequently, cache for 5 seconds
volumeCache: cache.New(5*time.Second, 10*time.Second),
}
@@ -662,12 +662,12 @@ func (cm *CacheManager) GetDeviceInfo(deviceID string, fetcher func() (*models.D
if cached, found := cm.deviceInfoCache.Get(deviceID); found {
return cached.(*models.DeviceInfo), nil
}
info, err := fetcher()
if err != nil {
return nil, err
}
cm.deviceInfoCache.Set(deviceID, info, cache.DefaultExpiration)
return info, nil
}
@@ -702,7 +702,7 @@ func NewResilientSoundTouchService(client *client.Client) *ResilientSoundTouchSe
log.Printf("Circuit breaker '%s' changed from '%s' to '%s'", name, from, to)
},
}
return &ResilientSoundTouchService{
client: client,
cb: gobreaker.NewCircuitBreaker(settings),
@@ -713,12 +713,12 @@ func (r *ResilientSoundTouchService) SetVolume(deviceID string, volume int) erro
result, err := r.cb.Execute(func() (interface{}, error) {
return nil, r.client.SetVolume(volume)
})
if err != nil {
r.metrics.ErrorsTotal.WithLabelValues("circuit_breaker", "volume").Inc()
return err
}
return result.(error)
}
```
@@ -730,16 +730,16 @@ func (app *Application) Run(ctx context.Context) error {
// Setup signal handling
sigChan := make(chan os.Signal, 1)
signal.Notify(sigChan, syscall.SIGINT, syscall.SIGTERM)
// Start services
g, ctx := errgroup.WithContext(ctx)
// HTTP server
server := &http.Server{
Addr: app.config.ListenAddr,
Handler: app.handler,
}
g.Go(func() error {
app.logger.Info("Starting HTTP server", "addr", app.config.ListenAddr)
if err := server.ListenAndServe(); err != http.ErrServerClosed {
@@ -747,38 +747,38 @@ func (app *Application) Run(ctx context.Context) error {
}
return nil
})
// Health checker
g.Go(func() error {
return app.healthChecker.Start(ctx)
})
// WebSocket manager
g.Go(func() error {
return app.wsManager.Start(ctx)
})
// Wait for shutdown signal
go func() {
<-sigChan
app.logger.Info("Shutdown signal received")
// Graceful shutdown with timeout
shutdownCtx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
// Shutdown HTTP server
if err := server.Shutdown(shutdownCtx); err != nil {
app.logger.Error("HTTP server shutdown error", "error", err)
}
// Close WebSocket connections
app.wsManager.Shutdown(shutdownCtx)
// Close connection pool
app.connectionPool.Close()
}()
return g.Wait()
}
```
@@ -791,7 +791,7 @@ func (app *Application) Run(ctx context.Context) error {
```dockerfile
# Dockerfile
FROM golang:1.25-alpine AS builder
FROM golang:1.27.0-alpine AS builder
WORKDIR /app
COPY go.mod go.sum ./
@@ -830,7 +830,7 @@ services:
networks:
- soundtouch-net
restart: unless-stopped
prometheus:
image: prom/prometheus:latest
ports:
@@ -839,7 +839,7 @@ services:
- ./prometheus.yml:/etc/prometheus/prometheus.yml
networks:
- soundtouch-net
grafana:
image: grafana/grafana:latest
ports:
@@ -1011,7 +1011,7 @@ groups:
annotations:
summary: "SoundTouch device {{ $labels.device_name }} is unhealthy"
description: "Device {{ $labels.device_id }} has been unhealthy for more than 2 minutes"
- alert: HighErrorRate
expr: rate(soundtouch_errors_total[5m]) > 0.1
for: 5m
@@ -1020,7 +1020,7 @@ groups:
annotations:
summary: "High error rate detected"
description: "Error rate is {{ $value }} errors/second over the last 5 minutes"
- alert: ServiceDown
expr: up{job="soundtouch"} == 0
for: 1m
@@ -1040,33 +1040,33 @@ func (m *Manager) BackupConfigurations() error {
Timestamp: time.Now(),
Devices: make(map[string]DeviceConfig),
}
for deviceID, device := range m.devices {
config := DeviceConfig{}
// Backup presets
if presets, err := device.Client.GetPresets(); err == nil {
config.Presets = presets
}
// Backup settings
if volume, err := device.Client.GetVolume(); err == nil {
config.Volume = volume.TargetVolume
}
if bass, err := device.Client.GetBass(); err == nil {
config.Bass = bass.TargetBass
}
backup.Devices[deviceID] = config
}
// Save to file
data, err := json.MarshalIndent(backup, "", " ")
if err != nil {
return err
}
filename := fmt.Sprintf("backup_%s.json", time.Now().Format("2006-01-02_15-04-05"))
return os.WriteFile(filepath.Join(m.config.BackupDir, filename), data, 0644)
}
@@ -1083,7 +1083,7 @@ func init() {
runtime.GOMAXPROCS(int(limit))
}
}
// Set GC target percentage
if os.Getenv("GOGC") == "" {
debug.SetGCPerc
@@ -99,18 +99,28 @@ After factory restore the speaker enters setup mode automatically; no power-cycl
## 6. AP Mode Wi-Fi Provisioning via Console
When BLE is unavailable (e.g. when using an Android emulator), use AP mode to push Wi-Fi credentials from the Mac command line.
When BLE is unavailable (e.g. when using an Android emulator), use AP mode to push Wi-Fi credentials from the command line. The HTTP steps below (6.2, 6.3) are OS-agnostic; only the Wi-Fi-network-switching commands (6.1, 6.4) are platform-specific — macOS is shown inline, with Linux and Windows equivalents alongside.
### 6.1 Connect Mac to Speaker AP
### 6.1 Connect your machine to the Speaker AP
After factory reset the speaker broadcasts an SSID like `Bose SoundTouch XXXX`. Connect the Mac to it:
After factory reset the speaker broadcasts an SSID like `Bose SoundTouch XXXX`. Connect to it:
```bash
# List nearby SSIDs — use System Settings → Wi-Fi (the airport command was removed in macOS Sequoia+)
# Connect (replace with actual SSID)
# macOS — list nearby SSIDs via System Settings → Wi-Fi (the `airport`
# command was removed in macOS Sequoia+); connect (replace with actual SSID):
networksetup -setairportnetwork en0 "Bose SoundTouch XXXX"
```
```bash
# Linux (NetworkManager) — one-shot connect, no password (open AP):
nmcli device wifi connect "Bose SoundTouch XXXX"
```
```powershell
# Windows — connect via the built-in Wi-Fi menu, or from PowerShell:
netsh wlan connect name="Bose SoundTouch XXXX"
```
The speaker's web UI gateway is at `192.0.2.1` (verified: ST10 assigns `192.0.2.2` to the client via DHCP).
```bash
@@ -143,20 +153,37 @@ Expected response: `<?xml version="1.0" encoding="UTF-8" ?><AddWirelessProfileRe
The speaker will disconnect from AP mode and join the home network within ~1530 s.
### 6.4 Reconnect Mac to Home Network
### 6.4 Reconnect to your Home Network
```bash
# macOS
networksetup -setairportnetwork en0 "MyHomeNetwork" "MyPassword"
```
```bash
# Linux (NetworkManager) — assumes the connection profile already exists
# (e.g. from a prior manual connect); use `nmcli device wifi connect
# "MyHomeNetwork" password "MyPassword"` instead for a first-time connect.
nmcli connection up "MyHomeNetwork"
```
```powershell
# Windows
netsh wlan connect name="MyHomeNetwork"
```
Wait ~15 s for the speaker to join the home network, then verify:
```bash
# Discover the speaker's new IP via mDNS
dns-sd -B _soundtouch._tcp local &
sleep 5 ; kill %1
# macOS/Linux — discover the speaker's new IP via mDNS.
# macOS: dns-sd ships with the OS. Linux: use avahi-browse (avahi-utils package).
dns-sd -B _soundtouch._tcp local & # macOS
avahi-browse -r _soundtouch._tcp # Linux — Ctrl-C to stop
sleep 5 ; kill %1 2>/dev/null # only needed for the dns-sd form
```
Windows has no equivalent built-in mDNS browser; use `soundtouch-cli discover devices` (this repo's own mDNS/UPnP discovery, cross-platform) or check your router's DHCP client list instead.
---
## Comparison: Initial Setup vs. Migration
@@ -0,0 +1,128 @@
---
title: "FRITZ!Box + AdGuard Home: DNS-based bose Hostname"
---
This guide covers a setup that trips up a lot of people: running AfterTouch
behind a local DNS resolver (AdGuard Home, Pi-hole, or the FRITZ!Box itself)
and addressing it by a short hostname like `bose` instead of a raw IP. When the
pieces don't line up, speakers report `INVALID_SOURCE` for TuneIn / internet
radio, the Health tab warns about missing source types
(`LOCAL_INTERNET_RADIO`, `RADIO_BROWSER`, `TUNEIN`), and pre-flight shows an
HTTP-connection / URL-mismatch failure even though AfterTouch itself is running
correctly.
The root cause is almost always the same: **the speaker cannot resolve the
hostname you configured, or the TLS certificate doesn't cover it.** This is a
real-world setup contributed by a user who hit exactly this and worked out the
fix.
> The IP addresses below use the documentation range `192.0.2.0/24`
> ([RFC 5737](https://datatracker.ietf.org/doc/html/rfc5737)). Substitute your
> own AfterTouch host IP. The hostname `bose` and FQDN `bose.fritz.box` are
> examples; any short name works as long as DNS and TLS agree on it.
## The setup
- AfterTouch runs as a container (here: Proxmox + Docker, `--network host`,
data directory bind-mounted), reachable at `192.0.2.10`.
- The FRITZ!Box forwards all DNS queries to **AdGuard Home** as the LAN resolver.
- AdGuard already had DNS rewrites for the Bose cloud hostnames pointing at
AfterTouch:
| Name | Answer |
|--------------------------------|--------------|
| `productregistration.bose.com` | `192.0.2.10` |
| `streaming.bose.com` | `192.0.2.10` |
| `select.bose.com` | `192.0.2.10` |
| `update.bose.com` | `192.0.2.10` |
That part is the standard "intercept Bose hostnames outside AfterTouch"
approach (see [HTTPS & Custom CA Certificate](HTTPS-SETUP.md)). What was missing
was making the **short hostname** you point speakers at resolvable *and*
TLS-valid.
## The fix
### 1. Add DNS rewrites for the short hostname
In AdGuard Home, add rewrites so the name you plan to use in the service URLs
resolves to AfterTouch:
| Name | Answer |
|------------------|--------------|
| `bose` | `192.0.2.10` |
| `bose.fritz.box` | `192.0.2.10` |
Both forms matter: speakers and clients may append the FRITZ!Box search domain
(`.fritz.box`), so covering the bare label and the FQDN avoids surprises.
### 2. Include the hostname in the TLS certificate
If speakers (or your browser) reach AfterTouch by `bose`, that name must be in
the certificate's SAN list, otherwise the TLS handshake is rejected
(`CURLE_SSL_CACERT (60)`). Start the container with the host added:
```bash
TLS_EXTRA_HOST="192.0.2.10,bose"
```
`TLS_EXTRA_HOST` is a comma-separated (and repeatable) list of extra DNS names
or IPs added to the certificate SAN list. You can also manage it from the web
UI: **Settings → "TLS extra hosts"**, or the one-click **"Add &lt;host&gt; to TLS
hosts"** QuickFix on the Health tab. Either path persists to `settings.json`
(`tls_extra_hosts`) and takes effect after a service restart, which regenerates
the certificate. See
[Adding extra hosts to the TLS certificate](HTTPS-SETUP.md#adding-extra-hosts-to-the-tls-certificate).
### 3. Point the service URLs at the hostname
In AfterTouch, under **System Settings / Target Domain / Service URLs**, switch
from the raw IP to the hostname:
```
http://192.0.2.10:8000 → http://bose:8000
```
After this, the per-device config should read:
```
margeServerUrl = http://bose:8000
statsServerUrl = http://bose:8000
bmxRegistryUrl = http://bose:8000/bmx/registry/v1/services
```
### 4. Re-migrate the speakers
Re-run the migration for each speaker (XML over SSH), then reboot and send a
`sourcesUpdated` notification so the runtime layer reconciles. See the
[Migration Guide](MIGRATION-GUIDE.md).
## Verifying it worked
- `http://bose:8000/health` responds, and `https://bose:8443/admin` loads with a
valid certificate.
- The Health tab no longer warns about URL mismatch or HTTP reachability (a
brief runtime-vs-XML hint right after migration clears on reboot).
- TuneIn / internet radio plays again; `INVALID_SOURCE` is gone.
- `/sources` lists the expected source types and `sources_xml_diff` is green.
## Why this is the stumbling block
Technically AfterTouch was serving correctly the whole time. The failure was
purely in name resolution and certificate coverage: the speaker asked the
nameserver for `bose`, got nothing usable (or reached a host whose certificate
didn't list `bose`), and fell back toward the now-dead Bose cloud. Using a raw
IP avoids the resolution step entirely; using a hostname is cleaner but only
works once **DNS** and the **TLS certificate** both agree on that name.
> Prefer the raw IP if you want the simplest possible path with one fewer moving
> part. Prefer the hostname if you run split-horizon DNS anyway and want a
> stable name that survives an IP change. Either is fine, the key is that DNS,
> the certificate, and the configured service URLs all reference the same
> target.
## Related
- [HTTPS & Custom CA Certificate](HTTPS-SETUP.md): TLS, SAN coverage, `:443` routing
- [Migration Guide](MIGRATION-GUIDE.md): DNS vs. SSH/XML migration methods
- [Troubleshooting](TROUBLESHOOTING.md): `nslookup` / `dig` checks for name resolution
@@ -34,13 +34,13 @@ sudo bash install.sh
```
The installer detects your Pi's architecture (armv7, arm64, or amd64), downloads
the binary, creates a `soundtouch` system user, and registers a systemd unit that
starts on boot.
the latest release binary, creates a `soundtouch` system user, and registers a
systemd unit that starts on boot.
To install a specific version:
To pin a specific version instead of the latest:
```bash
sudo bash install.sh v0.107.0
sudo bash install.sh v0.123.0
```
Check that the service is running:
@@ -55,7 +55,7 @@ installer defaults to port 80, not 8000) — open it in a browser.
### Other Linux hosts (systemd)
Download the binary for your architecture from the
[Releases page](https://github.com/gesellix/Bose-SoundTouch/releases), then
[Downloads page](../downloads/_index.md), then
install it as a systemd service — see [DEPLOYMENT.md](DEPLOYMENT.md) for the
unit file template.
@@ -66,13 +66,28 @@ docker run -d \
--name aftertouch \
--network host \
-e SERVER_URL=http://192.0.2.10:8000 \
-v aftertouch-data:/data \
-v aftertouch-data:/app/data \
ghcr.io/gesellix/bose-soundtouch:latest
```
Replace `192.0.2.10` with the host machine's LAN IP. The `--network host` flag
is required so AfterTouch can reach the speakers and respond to mDNS discovery.
> **Persist the data directory.** The container stores everything stateful under
> `/app/data` (`DATA_DIR`): the datastore, `settings.json`, and the service CA.
> Mount a volume there (`-v <volume>:/app/data`, as above) or this state is lost
> when the container is recreated. Losing the CA forces you to re-migrate every
> speaker and re-trust the new CA, so back this volume up before upgrading.
> **Windows / macOS (Docker Desktop):** `--network host` does not work the same
> way as on Linux, so publish the ports explicitly instead, e.g.
> `-p 8000:8000 -p 8443:8443`. mDNS discovery across the Docker Desktop network
> boundary is unreliable; add speakers by IP in the Devices tab. If you also use
> DNS interception (so the speaker resolves Bose hostnames to AfterTouch), you
> additionally need to publish the DNS port (`-p 53:53/udp -p 53:53/tcp`) and
> make AfterTouch reachable on `:443` (the hardcoded Bose hosts are plain HTTPS),
> e.g. `-p 443:8443`. Keep the same `-v <volume>:/app/data` mount.
---
## Step 2 — Note your host's LAN IP and open the Admin UI
@@ -176,12 +191,12 @@ open **`http://<host-ip>:8080`** in your browser (default port 8080).
### Installing soundtouch-player on a Raspberry Pi
`install.sh` only installs `soundtouch-service`. Use the dedicated
`install-web.sh` script to add soundtouch-player:
`install-player.sh` script to add soundtouch-player:
```bash
curl -fsSL -o install-web.sh \
https://raw.githubusercontent.com/gesellix/Bose-SoundTouch/main/scripts/raspberry-pi/install-web.sh
sudo bash install-web.sh
curl -fsSL -o install-player.sh \
https://raw.githubusercontent.com/gesellix/Bose-SoundTouch/main/scripts/raspberry-pi/install-player.sh
sudo bash install-player.sh
```
For configuration, service management, updates, and removal see the
@@ -190,7 +205,7 @@ For configuration, service management, updates, and removal see the
### Installing soundtouch-player on other hosts
Download the binary for your OS and architecture from the
[Releases page](https://github.com/gesellix/Bose-SoundTouch/releases)
[Downloads page](../downloads/_index.md)
and run it directly:
```bash
@@ -224,7 +239,7 @@ slot.
### Alternatively — storing presets via soundtouch-cli (any machine on the LAN)
Download the CLI for your machine from the
[Releases page](https://github.com/gesellix/Bose-SoundTouch/releases), then:
[Downloads page](../downloads/_index.md), then:
```bash
# Play a custom radio stream on the speaker
@@ -263,7 +278,7 @@ curl -s http://192.0.2.1:8090/presets
```bash
sudo bash install.sh # updates to latest release
sudo bash install.sh v0.107.0 # updates to a specific version
sudo bash install.sh v0.123.0 # updates to a specific version
```
The installer stops the service, downloads the new binary, and restarts
+7 -16
View File
@@ -21,7 +21,7 @@ The service includes a built-in HTTPS listener (default port `8443`) that presen
- Wildcard: `*.api.bose.io`, `*.api.bosecm.com`
- Specific: `streaming.bose.com`, `bmx.bose.com`, `stats.bose.com`, `updates.bose.com`, `worldwide.bose.com`, `bose-prod.apigee.net`, `media.bose.io`, `downloads.bose.com`, `voice.api.bose.io`, and more
> **Note**: The hostname you configure as `HTTPS_SERVER_URL` (e.g. `https://soundtouch.fritz.box:8443`) is also added as a Subject Alternative Name, ensuring valid TLS for direct browser or API access.
> **Note**: The HTTPS endpoint is only needed for certain features (the DNS-based redirect, Spotify/Amazon login, and certificate trust). Its URL is added as a Subject Alternative Name, ensuring valid TLS for direct browser or API access. By default this URL is **derived from the Target Domain** (same host, `https`, on the HTTPS port), so you usually don't configure it separately. If you don't need plain HTTP at all, you can set the Target Domain itself to an `https://` URL — it is then used as the HTTPS endpoint as-is, with no separate override. Settings → **HTTPS URL** shows the effective value; set an override (`HTTPS_SERVER_URL` / `--https-server-url`, or the "advanced" field in Settings) only when a reverse proxy serves HTTPS on a different host or port.
---
@@ -120,25 +120,16 @@ server {
location / {
proxy_pass http://localhost:8000;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
}
}
```
> **Tell the service to honour `X-Real-IP`/`X-Forwarded-For`.** When deploying
> behind a reverse proxy on the same host as above, set
> `"trust_forwarded_headers": true` in `data/settings.json`. With that flag
> on, the service rewrites `r.RemoteAddr` from the proxy-supplied headers,
> so handlers that act on the source IP (e.g. the Spotify priming triggered
> by `/marge/streaming/support/power_on`) see the speaker's real address
> instead of the proxy's loopback peer.
>
> By default only `127.0.0.0/8` and `::1/128` are trusted to set those
> headers. If your reverse proxy lives on a different host, list its CIDR(s)
> in `"trusted_proxy_cidrs"` (e.g. `["10.0.0.0/8"]`). Do **not** enable
> `trust_forwarded_headers` on a flat LAN deployment without a proxy: a
> malicious speaker on the LAN can send the headers itself and spoof its
> source IP.
> **Client IP behind a proxy.** A reverse proxy changes the source IP the
> service sees, which matters for the handlers that act on it. Configuring
> AfterTouch to recover the real speaker IP from `X-Forwarded-For`
> (`trust_forwarded_headers` / `trusted_proxy_cidrs`) is covered under
> [Client IP behind a proxy or load balancer](CLOUD-DEPLOY-WALKTHROUGH.md#client-ip-behind-a-proxy-or-load-balancer).
---
+15 -3
View File
@@ -22,9 +22,9 @@ Choose the option that fits your setup.
### Download a pre-built binary (no Go required)
Download the latest release for your platform from the
[GitHub releases page](https://github.com/gesellix/Bose-SoundTouch/releases).
Unzip, make executable, and run:
Download the `soundtouch-service` build for your platform from the
[Downloads page](../downloads/_index.md) (it explains which file to pick).
Make it executable and run:
```bash
# Linux / macOS example
@@ -107,6 +107,10 @@ Open `http://<server>:8000` and go to the **Settings** tab.
Set the **Target Domain** to the address your speakers can reach — for example `https://soundtouch.fritz.box` or `http://192.0.2.100:8000`. This must be the host's address on your local network, not `localhost`.
> **Changing this later?** Saving Settings only updates AfterTouch's own record of its address — it does **not** reach out to any already-migrated speaker. Each speaker only learns a new address when you (re-)run Migrate for it (Step 5 below), regardless of migration method. If you change Target Domain after some speakers are already migrated, re-migrate each of them too, or they'll keep using whatever address they were originally migrated with. See [Troubleshooting: Changing Target Domain doesn't change what a speaker actually uses](TROUBLESHOOTING.md#settings-vs-migrate).
> **On-device install:** this "not `localhost`" rule is for the local-network-host and cloud/VPS scenarios above, where the service runs on a *different* machine than the speaker. If you're running AfterTouch directly on the speaker itself (see the [On-Device Install Walkthrough](ON-DEVICE-INSTALL-WALKTHROUGH.md)), the speaker and the service are the same machine — `http://localhost:8000` is exactly right there, and is the recommended value: it needs no DNS/mDNS to resolve and survives DHCP address changes since it never depends on the LAN address at all. Installs built after issue #546's fix set this automatically (via `DEPLOYMENT_MODE=on-device`); on older installs, or if the field still shows the speaker's own unresolvable Linux hostname (e.g. `http://spotty:8000`), set it here by hand.
If you plan to use DNS/DHCP redirect, enable the **DNS Discovery Server** and set the **DNS Bind Address** to `:53`. The upstream DNS should be your router's IP, not the service's own address.
> **Tip**: If you change settings and they don't seem to take effect, check `data/settings.json` — settings saved in the UI take precedence over environment variables.
@@ -127,6 +131,14 @@ The XML migration writes updated configuration to the speaker's filesystem, whic
4. Power-cycle the speaker (unplug the power cable, wait 10 seconds, reconnect).
5. After boot, root SSH is available with no password: `ssh -oHostKeyAlgorithms=+ssh-rsa root@<SPEAKER-IP>`
**Or, without a USB stick:** `soundtouch-cli setup enable-ssh` (#471) bootstraps SSH purely over the network, using the speaker's telnet:17000 diagnostic shell (open by default on most firmware) to inject the SSH-enable command:
```shell
soundtouch-cli --host <SPEAKER-IP> setup enable-ssh
```
It waits for `:22` to come up and persists the change (survives a reboot) by default. Falls back to the USB-stick method above if telnet:17000 is closed or the injection doesn't take on your model.
You only need to do this once per speaker. SSH can remain enabled for future maintenance or be disabled after migration — your choice.
**To disable SSH after migration:**
@@ -11,6 +11,16 @@ This guide explains how to link your Spotify or Amazon Music account to AfterTou
---
> **The Local Account tab requires a login.** Authorizing a Spotify or
> Amazon account (Step 3 below) happens on the **Local Account** tab, which
> is protected by AfterTouch's Management API login (HTTP Basic Auth).
> Unless you've changed it, the default is username `admin`, password
> `change_me!` — see
> [Configuration Options](SOUNDTOUCH-SERVICE.md#configuration-options) for
> how to set your own (`MGMT_USERNAME` / `MGMT_PASSWORD`). Your browser will
> prompt for this the first time you open a protected page or click a
> management action — if nothing happens, try reloading the page.
## How it works
Connecting a music service happens in three separate steps, each done once:
@@ -14,7 +14,9 @@ documenting a successful fresh installation on a SoundTouch 20 Series I.
## Prerequisites
- SSH enabled on the speaker (the usual "Stick with remote_services" procedure).
- SSH enabled on the speaker — either the usual "USB stick with
`remote_services`" procedure, or `soundtouch-cli setup enable-ssh`
(no stick needed, see Step 1).
- Your machine can reach the speaker on the LAN.
- The speaker's LAN IP address — replace `192.0.2.1` throughout with the
actual address shown in your router or `arp -a`.
@@ -29,6 +31,23 @@ documenting a successful fresh installation on a SoundTouch 20 Series I.
## Step 1 — Connect to the speaker via SSH
If SSH isn't enabled yet, you don't need a USB stick: `soundtouch-cli` can
bootstrap it purely over the network (#471), using the speaker's
telnet:17000 diagnostic shell (open by default on most firmware) to inject
the SSH-enable command:
```bash
soundtouch-cli --host 192.0.2.1 setup enable-ssh
```
This waits for `:22` to come up and persists it (survives a reboot) by
default. The USB-stick method (format FAT32, create an empty
`remote_services` file in its root, insert, power-cycle) still works as a
fallback if telnet:17000 is closed or the injection doesn't take on your
model.
Either way, connect the same way:
```bash
ssh -oHostKeyAlgorithms=+ssh-rsa root@192.0.2.1
```
@@ -65,7 +84,7 @@ rm -f /mnt/nv/aftertouch/soundtouch-cli
df -h /mnt/nv # confirm space recovered
```
> **From v0.89.0 onwards the installer prunes stale artefacts automatically**
> **From v0.93.0 onwards the installer prunes stale artefacts automatically**
> during every upgrade — manual cleanup should no longer be necessary on
> fresh installs.
@@ -81,14 +100,18 @@ currently running binary, and starts the service:
rw && curl -sSL https://raw.githubusercontent.com/gesellix/Bose-SoundTouch/main/scripts/on-device-install/install.sh | sh
```
To target a specific version instead of the default:
By default this installs the **latest release** — the script resolves it from
GitHub's `releases/latest` redirect. To target a specific version instead:
```bash
# Via environment variable (works with pipe-to-sh)
VERSION=0.107.0 rw && curl -sSL https://raw.githubusercontent.com/gesellix/Bose-SoundTouch/main/scripts/on-device-install/install.sh | sh
# Via environment variable — note it goes on `sh`, not `curl`: shell
# variable-assignment prefixes only apply to the one command they're
# attached to, and in a pipe each command is a separate process.
# `VERSION=0.123.0 curl ... | sh` silently does NOT set it for `sh`.
rw && curl -sSL https://raw.githubusercontent.com/gesellix/Bose-SoundTouch/main/scripts/on-device-install/install.sh | VERSION=0.123.0 sh
# Via command-line flag (pass args after sh -s --)
curl -sSL https://raw.githubusercontent.com/gesellix/Bose-SoundTouch/main/scripts/on-device-install/install.sh | sh -s -- --version 0.107.0
curl -sSL https://raw.githubusercontent.com/gesellix/Bose-SoundTouch/main/scripts/on-device-install/install.sh | sh -s -- --version 0.123.0
```
Verify the installed version:
@@ -97,7 +120,7 @@ Verify the installed version:
wget -qO- http://localhost:8000/health
```
The JSON response should include `"version":"v0.107.0"` (or whichever
The JSON response should include `"version":"v0.123.0"` (or whichever
version you installed).
---
@@ -130,13 +153,69 @@ ssh -oHostKeyAlgorithms=+ssh-rsa -L 8000:localhost:8000 root@192.0.2.1
Keep this terminal open. Navigate to **http://localhost:8000** in your
browser.
> Skip this step if your speaker's firmware exposes port 8000 on the LAN
> directly — you can reach `http://192.0.2.1:8000` without a tunnel in that
> case.
> **You may not need the tunnel at all.** Try `http://192.0.2.1:8000` first.
> If that doesn't load, try **`http://192.0.2.1:17008`**: on speakers whose
> Wi-Fi co-processor refuses to pass `:8000` through (the ST20 and likely
> others), the installer automatically redirects port `17008` to AfterTouch,
> so the Admin UI is reachable from the LAN without any tunnel. Check with
> `/etc/init.d/aftertouch status` on the speaker, which reports the LAN port
> when the redirect is active. Details and per-model status:
> [Model Support Matrix](../reference/MODEL-SUPPORT-MATRIX.md).
>
> Keep the tunnel in mind anyway for **linking music-service accounts**:
> Spotify only accepts `https://` or *loopback* OAuth redirect URIs, so
> `http://localhost:8000` through a tunnel succeeds where a plain LAN
> address is rejected.
---
## Step 6 — Run the Health QuickFix for empty `margeAccountUUID`
## Step 6 — Migrate (point the speaker at itself)
The speaker isn't pointed at the AfterTouch instance you just installed yet
— this step does that. On-device, the speaker and the AfterTouch instance
are the same machine, so **loopback is the correct and recommended Target
Domain value**: `http://localhost:8000`. This is the one case where the
general migration guide's "must not be `localhost`" warning does not
apply — that warning is about the external-host/cloud scenarios, where
`localhost` would resolve on the wrong machine (the service host, not the
speaker). Here there is no wrong machine to resolve on.
> **Note:** as of the fix for issue #546, the on-device init script already
> sets `DEPLOYMENT_MODE=on-device`, so a fresh (or reinstalled/updated)
> on-device install's own Target Domain already defaults to
> `http://localhost:8000` automatically — no manual Settings-tab step
> needed for that part. Older installs still default to the speaker's own
> unresolvable Linux hostname (e.g. `http://spotty:8000`) until reinstalled
> with a build that includes the fix, or until the Target Domain is
> corrected by hand. Either way, you still need to run Migrate below — that
> step tells the *speaker* to use this address, which is separate from what
> the service defaults its own identity to.
**Via the Admin UI:**
1. Go to **Settings**, set **Target Domain** to `http://localhost:8000`.
2. Go to **Devices**, find your speaker (it self-discovers on its own LAN
IP), click **Migrate**.
3. Accept the suggested plan and let it apply.
4. Reboot to apply the change:
```bash
sync
reboot
```
**Or via the CLI** (equivalent, no browser needed — grab `soundtouch-cli`
from Step 9 below first if you want this path):
```bash
/mnt/nv/aftertouch/soundtouch-cli --host 127.0.0.1 setup migrate \
--service-url http://localhost:8000 --method telnet
sync
reboot
```
---
## Step 7 — Run the Health QuickFix for empty `margeAccountUUID`
In the AfterTouch UI:
@@ -147,6 +226,14 @@ In the AfterTouch UI:
4. Click the **QuickFix** button (labelled "Fix", "Pair account", or
"Apply QuickFix" depending on the version) and confirm.
Or via the CLI (same underlying pairing call, `--mode=bare` matches what
the QuickFix does — see Step 9 to grab `soundtouch-cli` first):
```bash
/mnt/nv/aftertouch/soundtouch-cli --host 127.0.0.1 setup pair \
--mode=bare --account=1111111 --service-url http://localhost:8000
```
Then reboot again to let the pairing take effect:
```bash
@@ -156,7 +243,7 @@ reboot
---
## Step 7 — Verify pairing and sources
## Step 8 — Verify pairing and sources
After the reboot reconnect via SSH and check:
@@ -170,32 +257,37 @@ wget -qO- http://localhost:8090/info | grep margeAccountUUID
wget -qO- http://localhost:8090/sources
```
If `margeAccountUUID` is still empty, re-run the Health QuickFix (Step 6)
If `margeAccountUUID` is still empty, re-run the Health QuickFix (Step 7)
and reboot again.
---
## Step 8 — Download soundtouch-cli (optional, for preset setup)
## Step 9 — Download soundtouch-cli (optional, for preset setup)
If you want to program preset buttons from the command line, download the
CLI binary to the speaker's `/tmp` (tmpfs, so it survives only until the
next reboot — which is fine for a one-time setup run):
CLI binary to `/mnt/nv/aftertouch` (the same persistent partition
AfterTouch itself lives on) rather than `/tmp`: `/tmp` is tmpfs and gets
wiped on every reboot, and if you used the CLI alternatives in Steps 6/7
above, it needs to survive those steps' reboots too, not just the final
one:
```bash
cd /tmp
cd /mnt/nv/aftertouch
curl -L --fail -o soundtouch-cli \
https://github.com/gesellix/Bose-SoundTouch/releases/download/v0.107.0/soundtouch-cli-v0.107.0-linux-armv7
https://github.com/gesellix/Bose-SoundTouch/releases/download/v0.123.0/soundtouch-cli-v0.123.0-linux-armv7
chmod +x soundtouch-cli
/tmp/soundtouch-cli --version
/mnt/nv/aftertouch/soundtouch-cli --version
```
Replace `v0.107.0` with the version you installed.
Replace `v0.123.0` with the version you installed. If you want the CLI
alternatives in Steps 6/7, download it here first, before doing those
steps — it'll be in place and already persistent either way.
---
## Step 9 — Store custom radio streams to preset buttons
## Step 10 — Store custom radio streams to preset buttons
Each station must be playing before it can be saved. The `sleep 5` gives
the speaker time to buffer and confirm the stream before storing.
@@ -205,52 +297,52 @@ the speaker time to buffer and confirm the stream before storing.
```bash
# Preset 1 — Hitradio OE3
/tmp/soundtouch-cli --host 127.0.0.1 source custom-radio \
/mnt/nv/aftertouch/soundtouch-cli --host 127.0.0.1 source custom-radio \
--url "http://orf-live.ors-shoutcast.at/oe3-q2a" \
--name "Hitradio OE3" \
--service-url "http://localhost:8000"
sleep 5
/tmp/soundtouch-cli --host 127.0.0.1 preset store-current --slot 1
/mnt/nv/aftertouch/soundtouch-cli --host 127.0.0.1 preset store-current --slot 1
# Preset 2 — Lounge FM
/tmp/soundtouch-cli --host 127.0.0.1 source custom-radio \
/mnt/nv/aftertouch/soundtouch-cli --host 127.0.0.1 source custom-radio \
--url "http://188.138.9.183/digital.mp3" \
--name "Lounge FM" \
--service-url "http://localhost:8000"
sleep 5
/tmp/soundtouch-cli --host 127.0.0.1 preset store-current --slot 2
/mnt/nv/aftertouch/soundtouch-cli --host 127.0.0.1 preset store-current --slot 2
# Preset 3 — Country Nonstop
/tmp/soundtouch-cli --host 127.0.0.1 source custom-radio \
/mnt/nv/aftertouch/soundtouch-cli --host 127.0.0.1 source custom-radio \
--url "https://stream.laut.fm/country-nonstop" \
--name "Country Nonstop" \
--service-url "http://localhost:8000"
sleep 5
/tmp/soundtouch-cli --host 127.0.0.1 preset store-current --slot 3
/mnt/nv/aftertouch/soundtouch-cli --host 127.0.0.1 preset store-current --slot 3
# Preset 4 — Radio Piterpan
/tmp/soundtouch-cli --host 127.0.0.1 source custom-radio \
/mnt/nv/aftertouch/soundtouch-cli --host 127.0.0.1 source custom-radio \
--url "https://klasse1.fluidstream.eu/piterpan.mp3?FLID=8" \
--name "Radio Piterpan" \
--service-url "http://localhost:8000"
sleep 5
/tmp/soundtouch-cli --host 127.0.0.1 preset store-current --slot 4
/mnt/nv/aftertouch/soundtouch-cli --host 127.0.0.1 preset store-current --slot 4
# Preset 5 — kronehit
/tmp/soundtouch-cli --host 127.0.0.1 source custom-radio \
/mnt/nv/aftertouch/soundtouch-cli --host 127.0.0.1 source custom-radio \
--url "https://secureonair.krone.at/kronehit-hp.mp3" \
--name "kronehit" \
--service-url "http://localhost:8000"
sleep 5
/tmp/soundtouch-cli --host 127.0.0.1 preset store-current --slot 5
/mnt/nv/aftertouch/soundtouch-cli --host 127.0.0.1 preset store-current --slot 5
# Preset 6 — Radio Niederösterreich
/tmp/soundtouch-cli --host 127.0.0.1 source custom-radio \
/mnt/nv/aftertouch/soundtouch-cli --host 127.0.0.1 source custom-radio \
--url "http://orf-live.ors-shoutcast.at/noe-q2a" \
--name "Radio Niederoesterreich" \
--service-url "http://localhost:8000"
sleep 5
/tmp/soundtouch-cli --host 127.0.0.1 preset store-current --slot 6
/mnt/nv/aftertouch/soundtouch-cli --host 127.0.0.1 preset store-current --slot 6
```
These are the stations from weissigera's setup (Austrian public and
@@ -259,7 +351,7 @@ pattern is the same regardless of station.
---
## Step 10 — Verify presets and final reboot
## Step 11 — Verify presets and final reboot
```bash
wget -qO- http://localhost:8090/presets
@@ -285,7 +377,7 @@ should start playing the corresponding stream.
| SSH "no matching host key type" | Add `-oHostKeyAlgorithms=+ssh-rsa` |
| Port 8000 not reachable from LAN | Use the SSH tunnel (Step 5) |
| `margeAccountUUID` still empty after reboot | Re-run Health QuickFix, reboot again |
| Radio source error 1005 | `margeAccountUUID` is empty — complete Step 6 first |
| Radio source error 1005 | `margeAccountUUID` is empty — complete Step 7 first |
| `http://localhost:8000` not responding after install | `logread \| grep aftertouch \| tail -20` |
| No space left on device during install | Run the cleanup in Step 2; check `df -h /mnt/nv` |
@@ -304,14 +396,21 @@ older artefacts to keep `/mnt/nv` free:
rw && curl -sSL https://raw.githubusercontent.com/gesellix/Bose-SoundTouch/main/scripts/on-device-install/install.sh | sh
# Update to a specific version — three equivalent forms
VERSION=0.107.0 rw && curl -sSL https://raw.githubusercontent.com/gesellix/Bose-SoundTouch/main/scripts/on-device-install/install.sh | sh
rw && curl -sSL https://raw.githubusercontent.com/gesellix/Bose-SoundTouch/main/scripts/on-device-install/install.sh | VERSION=0.123.0 sh
rw && curl -sSL https://raw.githubusercontent.com/gesellix/Bose-SoundTouch/main/scripts/on-device-install/install.sh | sh -s -- --version 0.107.0
rw && curl -sSL https://raw.githubusercontent.com/gesellix/Bose-SoundTouch/main/scripts/on-device-install/install.sh | sh -s -- --version 0.123.0
curl -sSLo install.sh https://raw.githubusercontent.com/gesellix/Bose-SoundTouch/main/scripts/on-device-install/install.sh
sh install.sh --version 0.107.0
sh install.sh --version 0.123.0
```
The script's own final output already confirms the new version came up and
is answering on `:8000`. If you separately check the version yourself
(`wget -qO- http://localhost:8000/health`, or the Admin UI), **reboot the
speaker first**: an Admin UI tab left open from before the update, or a
browser cache of the previous page load, can otherwise still show the old
version even though the new binary is already running.
**Rollback:** the installer keeps a `.backup` file alongside the binary:
```bash
@@ -321,6 +420,39 @@ cp /mnt/nv/aftertouch/aftertouch-service.<old-version>.backup \
/etc/init.d/aftertouch restart
```
**Testing a pre-release build (from `main`, not yet tagged):** `install.sh`
only ever downloads from GitHub Releases, so there's no one-line installer
for an unreleased commit. Cross-compile and swap the binary manually
instead — this is a direct extension of the rollback procedure above:
```bash
# On your own machine, from a checkout of the branch/commit you want:
make build-linux-armv7 # builds build/soundtouch-service-linux-armv7,
# build/soundtouch-cli-linux-armv7, and
# build/soundtouch-backup-linux-armv7
scp build/soundtouch-service-linux-armv7 root@192.0.2.1:/mnt/nv/aftertouch/aftertouch-service.new
ssh -oHostKeyAlgorithms=+ssh-rsa root@192.0.2.1
rw
/etc/init.d/aftertouch stop
cp /mnt/nv/aftertouch/aftertouch-service /mnt/nv/aftertouch/aftertouch-service.pre-test.backup
mv /mnt/nv/aftertouch/aftertouch-service.new /mnt/nv/aftertouch/aftertouch-service
chmod +x /mnt/nv/aftertouch/aftertouch-service
/etc/init.d/aftertouch start
```
If you're testing an unreleased `soundtouch-cli` change (not just the
service), swap that binary too — same idea, and it lands in the same
`/mnt/nv/aftertouch` directory Step 9 above uses:
```bash
scp build/soundtouch-cli-linux-armv7 root@192.0.2.1:/mnt/nv/aftertouch/soundtouch-cli
ssh -oHostKeyAlgorithms=+ssh-rsa root@192.0.2.1 chmod +x /mnt/nv/aftertouch/soundtouch-cli
```
Roll back the same way as above, using the `.pre-test.backup` file.
---
## Service management
+53 -17
View File
@@ -6,13 +6,19 @@ host) using the provided installer scripts.
Two scripts are available, one per binary:
| Script | Binary | Role | Default port |
|------------------|----------------------|-------------------------------------|--------------|
| `install.sh` | `soundtouch-service` | Cloud-replacement relay — always-on | 80 / 443 |
| `install-web.sh` | `soundtouch-player` | Browser control panel | 8080 |
| Script | Binary | Role | Default port |
|---------------------|----------------------|-------------------------------------|--------------|
| `install.sh` | `soundtouch-service` | Cloud-replacement relay — always-on | 80 / 443 |
| `install-player.sh` | `soundtouch-player` | Browser control panel | 8080 |
Both auto-detect CPU architecture (armv7 / arm64 / amd64), create a `soundtouch`
system user, and install a systemd unit. They are safe to re-run for updates.
Run without a version argument, they install the **latest release** (resolved
from GitHub's `releases/latest` redirect); pass a tag to pin a specific version.
Each installer has a matching uninstaller (`uninstall.sh`, `uninstall-player.sh`).
Prefer to grab a binary by hand, or need `soundtouch-cli` / `soundtouch-backup`
too? See the [Downloads page](../downloads/_index.md).
For a complete install-through-migration walkthrough see
[EXTERNAL-HOST-WALKTHROUGH.md](EXTERNAL-HOST-WALKTHROUGH.md).
@@ -34,14 +40,14 @@ sudo bash install.sh
Install a specific version:
```bash
sudo bash install.sh v0.107.0
sudo bash install.sh v0.123.0
```
Override defaults at install time:
```bash
sudo \
VERSION=v0.107.0 \
VERSION=v0.123.0 \
HOSTNAME_FQDN=soundtouch.local \
HTTP_PORT=80 \
HTTPS_PORT=443 \
@@ -99,7 +105,7 @@ journalctl -u soundtouch-service -b # this boot only
```bash
sudo bash install.sh # update to latest release
sudo bash install.sh v0.107.0 # update to a specific version
sudo bash install.sh v0.123.0 # update to a specific version
```
The script stops the service, downloads the new binary (backs up the old one to
@@ -107,13 +113,30 @@ The script stops the service, downloads the new binary (backs up the old one to
### Removal
Use the uninstaller, which stops and disables the service and removes the unit,
binary, and config. Your data directory is **preserved** by default:
```bash
curl -fsSL -o uninstall.sh \
https://raw.githubusercontent.com/gesellix/Bose-SoundTouch/main/scripts/raspberry-pi/uninstall.sh
sudo bash uninstall.sh # keep /var/lib/soundtouch-service
sudo bash uninstall.sh --purge # also delete the data directory
```
The `soundtouch:soundtouch` user/group is removed only once no other
`soundtouch-*` install remains on the host.
Prefer to do it by hand? The equivalent manual steps are:
```bash
sudo systemctl disable --now soundtouch-service
sudo rm /etc/systemd/system/soundtouch-service.service
sudo rm -rf /etc/soundtouch-service
sudo rm -rf /var/lib/soundtouch-service
sudo rm /usr/local/bin/soundtouch-service
sudo systemctl daemon-reload
# Datastore (presets, device registrations, certs) — delete only if you are
# sure you no longer need it:
sudo rm -rf /var/lib/soundtouch-service
```
---
@@ -126,24 +149,24 @@ data and can be stopped or restarted at any time without data loss.
### Installation
```bash
curl -fsSL -o install-web.sh \
https://raw.githubusercontent.com/gesellix/Bose-SoundTouch/main/scripts/raspberry-pi/install-web.sh
sudo bash install-web.sh
curl -fsSL -o install-player.sh \
https://raw.githubusercontent.com/gesellix/Bose-SoundTouch/main/scripts/raspberry-pi/install-player.sh
sudo bash install-player.sh
```
Install a specific version:
```bash
sudo bash install-web.sh v0.107.0
sudo bash install-player.sh v0.123.0
```
Override defaults at install time:
```bash
sudo \
VERSION=v0.107.0 \
VERSION=v0.123.0 \
HTTP_PORT=8081 \
bash install-web.sh
bash install-player.sh
```
Once running, open **`http://<pi-ip>:8080`** in a browser.
@@ -228,12 +251,25 @@ journalctl -u soundtouch-player -f
### Updates
```bash
sudo bash install-web.sh # update to latest release
sudo bash install-web.sh v0.107.0 # update to a specific version
sudo bash install-player.sh # update to latest release
sudo bash install-player.sh v0.123.0 # update to a specific version
```
### Removal
Use the uninstaller:
```bash
curl -fsSL -o uninstall-player.sh \
https://raw.githubusercontent.com/gesellix/Bose-SoundTouch/main/scripts/raspberry-pi/uninstall-player.sh
sudo bash uninstall-player.sh
```
The `soundtouch:soundtouch` user/group is removed only once no other
`soundtouch-*` install remains on the host.
Prefer to do it by hand? The equivalent manual steps are:
```bash
sudo systemctl disable --now soundtouch-player
sudo rm /etc/systemd/system/soundtouch-player.service
@@ -258,7 +294,7 @@ Override if needed:
```bash
sudo ARCH_ASSET=linux-arm64 bash install.sh
sudo ARCH_ASSET=linux-arm64 bash install-web.sh
sudo ARCH_ASSET=linux-arm64 bash install-player.sh
```
---
+11 -11
View File
@@ -21,18 +21,18 @@ Good choices: a Raspberry Pi, a NAS (like Synology or QNAP), an always-on PC or
## Step 1: Get the software
Go to the [AfterTouch releases page](https://github.com/gesellix/Bose-SoundTouch/releases) and download the latest release for your operating system:
See the **[Downloads page](../downloads/_index.md)** for the full list of builds and how to pick the right one for your system. You want the `soundtouch-service` tool; download the build whose suffix matches your computer:
| Your system | File to download |
|-----------------------|------------------------------------------|
| Raspberry Pi (64-bit) | `soundtouch-service_linux_arm64.tar.gz` |
| Raspberry Pi (32-bit) | `soundtouch-service_linux_arm.tar.gz` |
| Linux (64-bit PC) | `soundtouch-service_linux_amd64.tar.gz` |
| macOS (Apple Silicon) | `soundtouch-service_darwin_arm64.tar.gz` |
| macOS (Intel) | `soundtouch-service_darwin_amd64.tar.gz` |
| Windows | `soundtouch-service_windows_amd64.zip` |
| Raspberry Pi (64-bit) | `soundtouch-service-vX.Y.Z-linux-arm64` |
| Raspberry Pi (32-bit) | `soundtouch-service-vX.Y.Z-linux-armv7` |
| Linux (64-bit PC) | `soundtouch-service-vX.Y.Z-linux-amd64` |
| macOS (Apple Silicon) | `soundtouch-service-vX.Y.Z-darwin-arm64` |
| macOS (Intel) | `soundtouch-service-vX.Y.Z-darwin-amd64` |
| Windows | `soundtouch-service-vX.Y.Z-windows-amd64.exe` |
Extract the archive. You will find a single file called `soundtouch-service` (or `soundtouch-service.exe` on Windows).
(`X.Y.Z` is the current release version.) The download is a single ready-to-run executable called `soundtouch-service` (or `soundtouch-service.exe` on Windows) — no archive to extract.
### Alternative: Docker
@@ -122,12 +122,12 @@ The easiest solution is to assign a **static (fixed) IP address** to the compute
## Security note
AfterTouch's web interface and management API have no login by default. On a typical home network this is fine, since only devices on your local network can reach it.
The main web interface has no login by default — on a typical home network this is fine, since only devices on your local network can reach it.
If you want to restrict access — for example, on a shared network — start the service with a username and password:
The Management API (Spotify/Amazon account linking, the Local Accounts page) is a separate area that's *always* protected by HTTP Basic Auth, but ships with a published default (`admin` / `change_me!`) — anyone who has read the docs can use it. If you want real protection — for example, on a shared network — set your own:
```
./soundtouch-service --mgmt-username admin --mgmt-password yourpassword
```
This protects the Settings tab (where your Spotify and Amazon credentials are stored) from being read or changed by others on the network.
See [Configuration Options](SOUNDTOUCH-SERVICE.md#configuration-options) for the full list of settings and env-var equivalents. Note that this does *not* cover the Settings tab, where your Spotify/Amazon Client ID and Secret are stored — that tab has no separate protection today.
+29 -22
View File
@@ -156,28 +156,35 @@ The service supports multiple ways to configure its behavior. When multiple sour
### Configuration Options
| Variable | Flag | Description | Default |
|------------------------------------|----------------------------|---------------------------------------------------------------------------------------------------------------------------------------------------------------------|---------------------------|
| `PORT` | `--port`, `-p` | HTTP port to bind the service to | `8000` |
| `BIND_ADDR` | `--bind` | Network interface to bind to | all (ipv4 and ipv6) |
| `DATA_DIR` | `--data-dir` | Directory for persistent data | `./data` |
| `SERVER_URL` | `--server-url`, `-s` | External URL of this service | `http://<hostname>:8000` |
| `HTTPS_PORT` | `--https-port` | HTTPS port to bind the service to | `8443` |
| `HTTPS_SERVER_URL` | `--https-server-url`, `-S` | External HTTPS URL | `https://<hostname>:8443` |
| `PYTHON_BACKEND_URL`, `TARGET_URL` | `--target-url` | URL for Python-based service components (legacy) | `http://localhost:8001` |
| `REDACT_PROXY_LOGS` | `--redact-logs` | Redact sensitive data in proxy logs | `true` |
| `LOG_PROXY_BODY` | `--log-bodies` | Log full request/response bodies | `false` |
| `RECORD_INTERACTIONS` | `--record-interactions` | Record HTTP interactions to disk | `true` |
| `DISCOVERY_INTERVAL` | `--discovery-interval` | Device discovery interval | `5m` |
| `ENABLE_DNS_DISCOVERY` | `--dns-discovery` | Enable DNS discovery server | `false` |
| `DNS_UPSTREAM` | `--dns-upstream` | Upstream DNS server for non-Bose queries | `8.8.8.8` |
| `DNS_BIND_ADDR` | `--dns-bind` | Bind address for the DNS discovery server (standard port `:53` is required for DNS/DHCP migration) | `:53` |
| `INTERNAL_PATHS` | `--internal-paths` | Paths for internal requests to exclude from recording (e.g., `/setup/*`, `/web/*`) | `[]` |
| `DISCOVERY_DISABLED` | | Disable automated device discovery | `false` |
| `STOCKHOLM_DIR` | `--stockholm-dir` | Path to extracted Stockholm frontend directory — enables the Stockholm UI when set | *(disabled)* |
| `MARGE_URL` | | Streaming/marge base URL used when rewriting `stockholm/json/config.json`. Defaults to `SERVER_URL`. Set to `SERVER_URL/marge` only when using a soundcork backend. | *(same as `SERVER_URL`)* |
| `MARGE_AUTH_TOKEN` | | Pre-seeds the Stockholm `margeAuthToken` state (skips the login step for the first session) | *(empty)* |
| `MARGE_ACCOUNT_ID` | | Pre-seeds the Stockholm `margeAccountID` state (used to filter device-discovery results by account) | *(empty)* |
| Variable | Flag | Description | Default |
|------------------------------------|--------------------------------|------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|---------------------------------------------|
| `PORT` | `--port`, `-p` | HTTP port to bind the service to | `8000` |
| `BIND_ADDR` | `--bind` | Network interface to bind to | all (ipv4 and ipv6) |
| `DATA_DIR` | `--data-dir` | Directory for persistent data | `./data` |
| `SERVER_URL` | `--server-url`, `-s` | External URL of this service | `http://<hostname>:8000` |
| `DEPLOYMENT_MODE` | `--deployment-mode` | Where this service runs: `on-device`, `private-network`, or `public-network`. Only changes behavior when `SERVER_URL` is *not* set: `on-device` defaults to `http://localhost:<port>` instead of guessing a hostname (the speaker's own Linux hostname is never resolvable — see issue #546); `public-network` refuses to start rather than guess a publicly reachable address; unset/`private-network` keeps the previous hostname-guessing behavior, now with a startup warning. The on-device install script sets this automatically. | unset (legacy hostname guess, with warning) |
| `HTTPS_PORT` | `--https-port` | HTTPS port to bind the service to | `8443` |
| `HTTPS_SERVER_URL` | `--https-server-url`, `-S` | External HTTPS URL. An override: when empty it is derived from `SERVER_URL` (same host, `https`, on `HTTPS_PORT`), and can also be viewed/overridden in Settings. | derived from `SERVER_URL` |
| `PYTHON_BACKEND_URL`, `TARGET_URL` | `--target-url` | URL for Python-based service components (legacy) | `http://localhost:8001` |
| `REDACT_PROXY_LOGS` | `--redact-logs` | Redact sensitive data in proxy logs | `true` |
| `LOG_PROXY_BODY` | `--log-bodies` | Log full request/response bodies | `false` |
| `RECORD_INTERACTIONS` | `--record-interactions` | Record HTTP interactions to disk | `true` |
| `DISCOVERY_ENABLED` | `--discovery-enabled` | Enable periodic device discovery | `true` |
| `DISCOVERY_INTERVAL` | `--discovery-interval` | Device discovery interval | `5m` |
| `DEVICE_SEED_RETRY_INTERVAL` | `--device-seed-retry-interval` | Interval between embedded-player startup retries for persisted devices that failed their first probe (e.g. LAN not yet routable on a cold boot) | `30s` |
| `DEVICE_SEED_RETRY_WINDOW` | `--device-seed-retry-window` | Bounded window during which the embedded player retries those unreachable persisted devices at startup | `10m` |
| `ENABLE_DNS_DISCOVERY` | `--dns-discovery` | Enable DNS discovery server | `false` |
| `DNS_UPSTREAM` | `--dns-upstream` | Upstream DNS server for non-Bose queries | `8.8.8.8` |
| `DNS_BIND_ADDR` | `--dns-bind` | Bind address for the DNS discovery server (standard port `:53` is required for DNS/DHCP migration) | `:53` |
| `INTERNAL_PATHS` | `--internal-paths` | Paths for internal requests to exclude from recording (e.g., `/setup/*`, `/web/*`) | `[]` |
| `UPDATE_CHECK_ENABLED` | `--update-check-enabled` | Periodically check GitHub Releases for a newer version and show a dismissible notice in the admin UI and Player when one is found. **Opt-in**: this is the only network call AfterTouch makes beyond speaker/provider traffic when enabled, so it defaults off. One unauthenticated `GET` per interval to `api.github.com`, nothing else leaves the box. Also available as an "Update Check" toggle on the admin Settings page, which applies without a restart; the env var/flag is the seed value for a fresh install with no `settings.json` yet. | `false` |
| `UPDATE_CHECK_INTERVAL` | `--update-check-interval` | Update check interval. Also editable on the admin Settings page (applies without a restart). | `24h` |
| `MGMT_USERNAME` | `--mgmt-username` | Username for HTTP Basic Auth on the Management API (`/api/mgmt/*`, `/mgmt/*`) — Spotify/Amazon account linking, Local Accounts | `admin` |
| `MGMT_PASSWORD` | `--mgmt-password` | Password for the same Management API Basic Auth. **Change this if AfterTouch is reachable beyond a trusted LAN** — the default is published in this doc. | `change_me!` |
| `STOCKHOLM_DIR` | `--stockholm-dir` | Path to extracted Stockholm frontend directory — enables the Stockholm UI when set | *(disabled)* |
| `MARGE_URL` | | Streaming/marge base URL used when rewriting `stockholm/json/config.json`. Defaults to `SERVER_URL`. Set to `SERVER_URL/marge` only when using a soundcork backend. | *(same as `SERVER_URL`)* |
| `MARGE_AUTH_TOKEN` | | Pre-seeds the Stockholm `margeAuthToken` state (skips the login step for the first session) | *(empty)* |
| `MARGE_ACCOUNT_ID` | | Pre-seeds the Stockholm `margeAccountID` state (used to filter device-discovery results by account) | *(empty)* |
### Configuration Examples
+296
View File
@@ -154,6 +154,46 @@ logread -f | grep -v '127.0.0.1:'
> **Note on the firmware-internal placeholder sources.** The `<sourceItem source="SPOTIFY" sourceAccount="SpotifyConnectUserName" ...>`, `SpotifyAlexaUserName`, `UPNP/UPnPUserName`, `STORED_MUSIC_MEDIA_RENDERER/StoredMusicUserName`, and `QPLAY/QPlay{1,2}UserName` entries that appear in `/sources` even on a broken or unpaired speaker are *firmware-synthesized*. They show up regardless of AfterTouch's source list — their `status="UNAVAILABLE"` does not indicate an AfterTouch problem. Use the three checks above to diagnose the actual cause.
### ❌ On-device install fails with `curl: (60) ... certificate is not yet valid`
**Symptoms:**
- Running the on-device installer over SSH (`curl -sSL .../install.sh | sh`)
fails immediately, before anything is downloaded:
```
curl: (60) SSL certificate problem: certificate is not yet valid
```
- The same error appears for *any* HTTPS fetch from the speaker (GitHub,
`raw.githubusercontent.com`, …).
**Cause:**
The speaker's clock is set in the past. SoundTouch speakers have no
battery-backed clock and rely on NTP, which is no longer reliable after the Bose
cloud shutdown, so the clock can fall back to a date years ago. TLS validation
then rejects the (recently issued) server certificate as "not yet valid" — its
validity period starts *after* the speaker's notion of "now". This is the same
stuck-clock condition behind several TuneIn / TLS failures (see issue #345).
**Fix:**
Set the speaker's clock to roughly the current time over SSH, then re-run the
installer:
```bash
# On the speaker, over SSH. Replace with the current UTC date/time —
# it only needs to be close enough to fall inside the certificate's validity
# window, not exact.
date -u -s "2026-06-27 12:00:00"
```
Then re-run the on-device install one-liner. Once AfterTouch is installed and
running, its **`speaker_clock` health check** (with a `set_clock` quick-fix)
keeps the speaker's clock corrected, so this is a one-time hurdle to get the
installer through.
### ❌ Speaker logs `Curl 7, http 0` and AfterTouch sees no HTTP requests
**Symptoms:**
@@ -302,6 +342,38 @@ avahi-resolve -n soundtouch.local
---
### ⚠️ Health tab: "HTTPS endpoint TLS configuration" warns about the wrong port / not reachable {#https-endpoint-tls-config}
**Symptoms:**
- The Health tab's **HTTPS endpoint TLS configuration** check shows a warning like
*"Configured HTTPS URL … uses port 443, but the service is listening on port 8443"*,
or *"Configured HTTPS endpoint … isn't reachable from inside the service."*
- You run AfterTouch on non-default ports (for example HTTP `8080`, HTTPS `8443`).
**Cause:**
AfterTouch advertises an HTTPS URL (used for the DNS-based redirect, Spotify/Amazon
login, and certificate trust) separately from the HTTP one. If that URL's port
doesn't match the port the HTTPS listener is actually bound to, the check dials the
wrong place. This most often happened when the HTTPS URL had been set without a port
(so it defaulted to `443`) while the listener was on `8443`.
**Fix:**
- Open **Settings → Service URLs**. The **HTTPS URL** line shows the effective value.
By default it now *derives* from the Target Domain (same host, on the HTTPS port),
so simply saving a correct Target Domain fixes it. Expand the ⓘ next to **HTTPS URL**
to set an **override** only if a reverse proxy serves HTTPS on a different host/port.
- Equivalent CLI/env: set `--https-server-url` / `HTTPS_SERVER_URL` to include the
right port, e.g. `https://<host>:8443`, then restart.
**Not always a problem:** if a reverse proxy intentionally terminates TLS on one port
(e.g. `443`) and forwards to AfterTouch on another (e.g. `8443`), the warning is
expected and can be ignored — the check can't see your proxy from inside the service.
---
## 🎵 **Playback Control Issues**
### ❌ "Play/Pause not working"
@@ -384,6 +456,63 @@ client.SelectAux()
---
## 🎛️ **Lifestyle / Console Device Behavior** {#lifestyle-console-devices}
### ❌ "Console-style device (Lifestyle, CineMate) plays the first test station but every later one reports INVALID_SOURCE"
On a Bose Lifestyle or CineMate console, the SoundTouch module is one input
among several (TV, AUX, Bluetooth, ...). As already established in #160,
the console's active input cannot be switched from the SoundTouch side —
there is no API call that forces it back onto SoundTouch.
**Symptoms:**
- `/now_playing` reports `source="LOCAL"` with an empty `ContentItem`:
```xml
<nowPlaying deviceID="..." source="LOCAL">
<ContentItem source="LOCAL" isPresetable="true" />
</nowPlaying>
```
- `LOCAL` does not appear in `/sources` at all.
- `POST /select` and `POST /key` (e.g. `PRESET_1`) are accepted
(`<status>/select</status>`) but have no observable effect.
This means the console is sitting on its own (non-SoundTouch) input, not
that the content/station itself is invalid. The input has to be selected
on the console's own remote or front panel; there is no way to do it via
the SoundTouch API.
**The trap:** `POST /key POWER` does not behave like it does on a plain
speaker. On a speaker, `POWER` is a harmless way to stop playback between
test runs. On a console, it puts the whole unit into standby — and on
waking, the console returns to **its own** input, not back to SoundTouch.
A test loop that stops playback with `POWER` between trials silently
switches the device off SoundTouch after the *first* trial, so every
station from the second one onward reports `INVALID_SOURCE` — including
stations that would otherwise play perfectly fine. This is easy to
misread as a per-station problem (e.g. "this console can't handle TLS/
https streams") when it is actually a test-methodology artifact: whichever
station happens to run first in the loop is the only one actually tested
against SoundTouch input.
**Solutions:**
1. Before testing anything, select the SoundTouch input on the console
itself (remote or front panel), not via the API.
2. Do not use `POST /key POWER` to stop playback between trials on these
devices. If you need to interrupt playback, use a different key
(e.g. `PAUSE`/`STOP`) or simply move directly to selecting the next
station.
3. If `/now_playing` shows `source="LOCAL"` with `LOCAL` absent from
`/sources`, treat that as "console is on a different input" — re-select
SoundTouch on the console and retest before concluding anything about
the station or migration itself.
See #597 for the original report, including a packet capture confirming a
station that appeared to fail actually completed a full TLS handshake and
streamed normally once the console was back on the SoundTouch input.
---
## 🎶 **Music Service & Preset Issues**
### ❌ Spotify preset fails with "Current content cannot be saved as preset"
@@ -465,6 +594,173 @@ Once the source plays once, it gets persisted to `/mnt/nv/BoseApp-Persistence/1/
If `soundtouch-cli source content --source TUNEIN ...` returns `1005` on a reset device that has never had TuneIn, the speaker is refusing because the source isn't registered yet — chicken-and-egg. The SoundTouch app is then the only practical path to register it; we can't write `Sources.xml` directly over telnet on most models.
### ❌ Presets get wiped after a reboot, on a speaker sharing its Marge account with other devices {#preset-wipe-shared-account}
**Symptoms:**
- Presets are programmed and confirmed correct (e.g. via the Admin UI or `soundtouch-cli`), but after a plain reboot of the speaker, its own preset list comes back empty (`<presets />`) — even though the service's own `Presets.xml` for that device is untouched and still shows the correct presets.
- The affected speaker is one of several devices under the **same** Marge account — for example a separate on-device AfterTouch instance per speaker, or several physical speakers migrated to one shared account.
- Clicking **Sync** in the Admin UI can also lose presets, but since v0.129.0 that path shows a confirmation warning before it overwrites anything destructively — that's a different, already-fixed issue (a stale-snapshot overwrite guard), not the reboot behavior described here.
**Cause:**
Not fully root-caused — this is firmware-internal. A byte-exact capture of the speaker's own `/full` request confirmed AfterTouch serves the correct preset data at the exact moment of the reboot-triggered resync; the wipe happens *after* that, entirely inside the speaker's own firmware callback chain, with no further network exchange to intercept from the service side. The trigger correlates with the **number of devices** listed under the account, not the account ID itself: removing the other devices from the account fixed it for one reporter, while changing only the account ID (with the other devices still present) did not. This isn't a universal shared-account problem either — a setup using a distinct account ID per speaker, with discovery left enabled, has not reproduced it — so treat this as an observed correlation, not a proven mechanism. See [issue #614](https://github.com/gesellix/Bose-SoundTouch/issues/614) for the full debugging history.
**Workaround (confirmed working, root cause still open):**
1. Admin UI → **Settings** → disable **"Enable Periodic Discovery"** first. Order matters — leaving it on lets a background sweep re-add a device you just removed, mid-cleanup.
2. Admin UI → **Devices** tab → click **✕** to remove every other device from the account, leaving only the speaker you're troubleshooting.
3. Reboot the speaker and confirm the presets survive.
This is fully reversible: re-enabling discovery brings the other devices back as harmless entries, and it doesn't touch their own presets/recents.
If you'd rather not change device-list membership, the Health tab's **"Restore presets to speaker"** QuickFix pushes the service's stored presets back onto the speaker without a reboot — a workaround for the symptom rather than the trigger, but useful if you hit this again before removing devices.
### ❌ Changing Target Domain in Settings doesn't change what a speaker actually uses {#settings-vs-migrate}
**Symptoms:**
- You update **Settings → Target Domain / Server URL** (via the Admin UI, `SERVER_URL`, or `--deployment-mode`), and the Admin UI confirms the new value with no warning.
- An already-migrated speaker's own behavior is unchanged: playback/BMX requests still go to the *old* address, and `soundtouch-cli setup inspect --telnet` still shows the old `margeServerUrl`/`statsServerUrl`/`bmxRegistryUrl`/`swUpdateUrl`.
**Cause:** Settings only updates the *service's own* record of its address (`s.serverURL`, persisted to `settings.json`) — the save handler never contacts any device. A speaker only learns a new address at migrate time: the telnet method writes it via `sys configuration ...` plus a closing `envswitch boseurls set ...` for the reboot-persisted layer; the XML/SSH method uploads a fresh `SoundTouchSdkPrivateCfg.xml`. Both write **once**, with no mechanism for a speaker to later re-fetch its own config from the service — this is equally true for either migration method. A "Sync" or `sourcesUpdated` notification only refreshes the speaker's source *list*, not its server URL configuration.
**Fix:** Any Target Domain change that needs to reach an already-migrated speaker requires a fresh Migrate afterward — Settings alone is never enough for a speaker that's been migrated before:
```bash
soundtouch-cli --host <speaker-ip> setup migrate --method telnet --service-url <new-target-domain>
```
Confirm it took:
```bash
soundtouch-cli --host <speaker-ip> setup inspect --telnet
```
`margeServerUrl`/`statsServerUrl`/`bmxRegistryUrl`/`swUpdateUrl` should all match the new value. Repeat per speaker — Settings is one service-wide value, but each speaker keeps its own independently-migrated copy, so a multi-speaker household needs a re-migrate for each one.
This also applies to a freshly-fixed on-device default (see `DEPLOYMENT_MODE`, #546): the installer now gets the *default* right for new installs automatically, but an install that was already migrated before you updated still needs the explicit re-migrate above — the fix only stops a *new* bad value from being written, it doesn't retroactively correct an already-migrated speaker.
### ❌ Radio sources never activate after an in-place migration {#radio-sources-after-migration}
**Symptoms:**
- The speaker was migrated **in place** (not factory-reset first) and is reachable; account-bound sources (for example a music-streaming login) work and presets for them play.
- **Every** radio-type source fails: selecting any `LOCAL_INTERNET_RADIO`, `TUNEIN`, or `RADIO_BROWSER` content returns `1005`, including the Health tab's "Play ding" test.
- `curl http://<speaker-ip>:8090/sources` lists no radio source types at all.
- The Health check warns that the speaker is "missing N source type(s) the service advertises".
- The entries are present on disk in **both** the service-side `Sources.xml` **and** the speaker's own `/mnt/nv/BoseApp-Persistence/1/Sources.xml`, yet a reboot and a `sourcesUpdated` notification do not make them activate.
**Cause:**
After some in-place migrations the speaker's **runtime** `bmxRegistryUrl` (and often `statsServerUrl`) are still pointing at the dead Bose cloud (`content.api.bose.io` / `events.api.bosecm.com`), even though the persisted config and `Sources.xml` look correct. Radio sources (TUNEIN, RADIO_BROWSER, LOCAL_INTERNET_RADIO, …) are published through the **BMX registry**, so while `bmxRegistryUrl` points at the dead cloud the speaker can't fetch them and they never mount. On some models a full reboot reconciles all four service URLs from the stored config; on others it does not. (If you hit this, an encrypted diagnostic report taken **before** you reset the speaker is very helpful, and now includes the speaker's on-device `Sources.xml`. See the "Getting More Help" section below.)
**Workaround (preferred — non-destructive):**
Re-run the migration with the **telnet** method, which writes all four service URLs directly onto the speaker's runtime. No factory reset, no DNS, no SSH:
```bash
soundtouch-cli --host <speaker-ip> setup migrate --method telnet --service-url http://<aftertouch-host>:8000
```
Then reboot the speaker (or use "Refresh sources"). Afterwards the Migration tab's cross-check should show `bmxRegistryUrl` / `statsServerUrl` on AfterTouch, and the radio sources activate.
Notes:
- This needs the speaker's telnet diagnostic port (`17000`) to be reachable. Most SoundTouch models expose it; some hardened firmware builds do not, in which case use the factory-reset fallback below.
- It writes AfterTouch's address (`http://<aftertouch-host>:8000`) **straight onto the speaker**, so there is no `bose:8000` hostname for the speaker to resolve. That is why pointing the service at `http://bose:8000` and adding a `bose` entry to your server's `/etc/hosts` does **not** help: the speaker is a separate device and never reads that file. If you prefer to redirect in the network instead of writing on the device, enable AfterTouch's built-in DNS (Settings) and have the speaker use AfterTouch as its resolver — see the FRITZ!Box + AdGuard guide.
- Get `soundtouch-cli` from the [Downloads page](../downloads/_index.md) if you don't already have it.
**Workaround (fallback — factory reset):**
If the telnet method isn't available for your model, factory reset the speaker, then re-migrate it:
1. Factory reset (on most models: hold `1` + `` for ~10 seconds — confirmed
identical on the SoundTouch 30 Series III, not just the original ST30).
2. Reconnect the speaker to your network.
3. Re-migrate it in AfterTouch.
After this the radio sources activate normally. Note the factory reset rewrites the speaker's `Sources.xml` to defaults, so any **account-bound** source (for example a music-streaming login) has to be re-added afterwards; your presets for it come back once the source is present again.
### ❌ `setup enable-ssh` (or a telnet command) fails right after a power-cycle, but works if you wait
**Symptoms:**
- You power-cycled the speaker — as our own retry guidance suggests after a `setup enable-ssh` timeout — and immediately re-ran the command (or a telnet migration/pairing step).
- You get `telnet dial <ip>:17000: connection refused` or the command otherwise fails as if the port were closed.
- Running the exact same command again a minute or two later works fine, on the same device.
**Cause:**
Confirmed on hardware across five device variants (2026-08-09): different ports on the same speaker become ready at very different times after a cold boot. HTTP `:8090` typically answers first, but the diagnostic telnet shell on `:17000` — and the config subsystem behind it that `getpdo` reads — takes longer: 5592 seconds observed, median ~70s. "The box answers on one port" is a weaker signal than "the box can answer on the specific port you need." See [TELNET-COMMAND-REFERENCE.md](../analysis/TELNET-COMMAND-REFERENCE.md) for the underlying mechanism.
**Fix:** After a power-cycle, wait at least 90 seconds before retrying any telnet-based command. If it still fails after that, wait a full 2 minutes before assuming the port is genuinely closed on that firmware rather than just slow to come up.
### ❌ Speaker gets slower/less responsive over time after `setup enable-ssh` with no `--service-url`
**Symptoms:**
- You ran `soundtouch-cli setup enable-ssh` without `--service-url` (or via the Admin UI's equivalent) to bootstrap SSH, and never followed up with a real `setup migrate`.
- Over time (hours to days), the speaker becomes progressively less responsive — slow to answer `:8090`, SSH connections time out, the Admin UI shows it as flaky or offline.
**Cause:**
`enable-ssh` without `--service-url` writes a deliberately-invalid placeholder (`https://aftertouch.invalid`) into `margeServerUrl`/`swUpdateUrl`/etc — by design, since the SSH-enable injection only needs *a* URL to round-trip through, not a working one. But unless you run `setup migrate` (or the Admin UI's Migrate step) afterward, that placeholder **stays persisted** — the command's own success message says so explicitly. The firmware then retries a failing DNS/curl lookup against it on a background loop (same class of failure as the `mojo`/`taigan` unresolvable-hostname case, #546) — an ongoing resource drain that isn't dramatic on its own, but confirmed on real hardware (2026-08-16) to compound badly if anything else (e.g. a burst of SSH connections — see the `setup revert` entry below) puts the speaker under load at the same time.
**Fix:** Always follow `enable-ssh` (when run without `--service-url`) with a real `setup migrate` before walking away. If you're recovering a speaker that's already stuck like this: power-cycle it, confirm it's reachable (`ping`, `curl :8090/info`, a single plain `ssh ... echo ok`) before doing anything else, then run `setup migrate` with the real URLs. If you want to point it back at the **original Bose cloud** URLs instead of AfterTouch (e.g. to fully decommission it), use the per-field overrides on `--method=telnet` — see the `setup migrate` section of [CLI-REFERENCE.md](CLI-REFERENCE.md) — which writes over a single telnet connection, no SSH required:
```bash
soundtouch-cli --host <SPEAKER-IP> setup migrate --method telnet \
--service-url https://streaming.bose.com \
--marge-url https://streaming.bose.com \
--stats-url https://events.api.bosecm.com \
--sw-update-url https://worldwide.bose.com/updates/soundtouch \
--bmx-url https://content.api.bose.io/bmx/registry/v1/services
```
### ❌ `setup revert` (or the Admin UI's "Revert to Defaults") fails with "backup .original not found" even though the file exists
**Status: fixed** (branch `docs-ondevice-install-gaps`, not yet in a numbered release as of this writing) — kept below for anyone hitting this on an older build, and because the underlying "don't hammer a struggling speaker" advice is still good practice generally.
**Symptoms:**
- You confirm via a separate SSH session that `/opt/Bose/etc/SoundTouchSdkPrivateCfg.xml.original` genuinely exists.
- `setup revert` (or clicking "Revert to Defaults") still reports `backup .../SoundTouchSdkPrivateCfg.xml.original not found, cannot revert`.
- A follow-up plain SSH command to the same speaker fails with `Operation timed out` at the TCP level — not an auth or shell error.
**Cause:** `RevertMigration`'s full call graph opened **17 separate SSH connections** in rapid succession (`pkg/ssh.Client.Run()` dialed fresh every call, with no connection reuse across `revertXMLConfig`/`revertHosts`/`revertResolvConf`/`revertAftertouchHook`/`removeRcLocalHooks`/`revertCACert`). Hitting a resource-constrained embedded speaker with that many rapid reconnects could overwhelm it — confirmed on real hardware (2026-08-16), where the speaker became unreachable shortly after. On top of that, `revertXMLConfig`'s error handling collapses *any* non-nil error from its file-existence check into "not found," so a dial failure got misreported as a missing backup — the message didn't mean what it said.
**Fix:** `pkg/ssh.Client` now supports an opt-in persistent connection (`Connect()`/`Close()`) that `RevertMigration` uses to collapse those 17 connections into 1 — confirmed on the same real hardware (2026-08-16): a subsequent `setup revert` completed quickly, and the restored config file diffed byte-identical against `.original`. If you're on a build that predates this fix, don't retry `setup revert` back-to-back — if it fails, wait a minute and confirm the speaker is reachable again (`ping`, a single plain `ssh ... echo ok`) before retrying. If all you actually need is to point the speaker's URLs somewhere else (back to AfterTouch, or back to the original Bose cloud), the lighter-weight `setup migrate --method telnet` with explicit URL overrides (previous entry) uses one telnet connection instead of SSH entirely.
### ❌ On-device install: AfterTouch answers on the speaker but not from other machines on the LAN
**Symptoms:**
- On the speaker itself, `curl http://localhost:8000/health` works and `/etc/init.d/aftertouch status` is green.
- From any other machine, `http://<speaker-ip>:8000` fails immediately (connection refused/reset, not a timeout).
- SSH to the same speaker works fine, so it is clearly reachable in general.
**Cause:**
Some SoundTouch chassis carry a BCO ("SMSC") Wi-Fi/Bluetooth co-processor, and inbound LAN traffic reaches the main Linux SoC only for a fixed set of Bose's *own* service ports, a list that appears to be compiled into the co-processor's firmware. AfterTouch's `:8000` was never part of that original design, so the connection never arrives at the SoC at all. Confirmed on an ST20 (`spotty`, FW 27.0.6) in 2026-08: `tcpdump -i eth0` on the speaker saw **zero packets** for `:8000` while Bose's `:8090`/`:8091`/`:17000` answered normally from the same client. This is not a firewall (the speaker's `iptables` is empty) and not a binding problem (the service does listen on `0.0.0.0:8000`).
**Fix:**
The on-device installer handles this automatically: on an affected speaker it redirects a relayed Bose port to AfterTouch, so use:
```
http://<speaker-ip>:17008
```
To check or change it, on the speaker:
```bash
/etc/init.d/aftertouch status # reports the LAN port when active
iptables -t nat -S PREROUTING # shows the redirect rule
```
Set `AFTERTOUCH_LAN_PORT` in `/opt/aftertouch/aftertouch.conf` to a different port, or to `none` to disable the redirect and use an SSH tunnel instead; then `/etc/init.d/aftertouch restart`. Note that **linking music-service accounts still works best through the tunnel** (`http://localhost:8000`), because Spotify only accepts `https://` or loopback OAuth redirect URIs. If you also run the `streborn` project on the same speaker, note it defaults to the same port, so change one of them. Which models are affected is tracked in [MODEL-SUPPORT-MATRIX.md](../reference/MODEL-SUPPORT-MATRIX.md).
## 🔊 **Volume & Audio Issues**
### ❌ "Volume control not working"
+1 -1
View File
@@ -1,6 +1,6 @@
---
title: "User Guides"
weight: 1
weight: 2
sidebar:
open: true
---
@@ -0,0 +1,198 @@
---
title: "Playing Music from a DLNA / NAS Library"
---
This guide explains how to browse a DLNA/UPnP media server on your LAN (NAS,
FRITZ!Box, minidlna, Plex, etc.) and play its tracks on a SoundTouch speaker
using the speaker's native `STORED_MUSIC` source.
---
## How it works
The **speaker is the DLNA control point**, not AfterTouch. The flow is:
1. AfterTouch (or the CLI) discovers DLNA servers on the LAN via SSDP.
2. You register a server on the speaker with `setMusicServiceAccount`. This
creates a `STORED_MUSIC` source entry on the speaker.
3. You browse the library through the speaker's `/navigate` endpoint. The
speaker contacts the media server's ContentDirectory service and returns its
own browse tokens (e.g. `4:cont2:150:0:0:` for folders, or a track token
ending in `TRACK`).
4. You select a track or folder using `/select` with a `STORED_MUSIC`
`ContentItem`. The speaker fetches the audio directly from the media server.
AfterTouch does not proxy the audio stream.
> **Why use the speaker's browse tokens instead of raw DLNA object IDs?**
> The speaker's `/select` endpoint only accepts the tokens it produces via
> `/navigate`. Raw DLNA ContentDirectory object IDs (like `"64"`) are not
> accepted for playback.
---
## Prerequisites
- A DLNA/UPnP media server running on the same LAN as the speaker (e.g. NAS
with minidlna, FRITZ!Box media server, Plex with DLNA enabled).
- The speaker and the media server must be on the same network segment so the
speaker can reach the server directly for streaming.
- `soundtouch-cli` installed and able to reach the speaker (test with
`soundtouch-cli --host 192.0.2.10 info`).
---
## Step 1: Discover servers on the LAN
Run an SSDP sweep from the machine where the CLI runs:
```bash
soundtouch-cli library servers
```
Sample output:
```
Found 1 DLNA media server(s):
Name: My Music Library
Vendor: minidlna / MiniDLNA 1.3.3
UDN: uuid:00000000-0000-0000-0000-000000000000
CDS: http://192.0.2.20:8200/ctl/ContentDir
```
Note the **UDN** (the `uuid:...` string). You will need it in the next steps.
Alternatively, ask a specific speaker for its own DLNA list (the speaker runs
its own independent UPnP sweep):
```bash
soundtouch-cli --host 192.0.2.10 library servers --via-speaker
```
> The speaker's list and the app-side list may differ. The speaker reports only
> servers it has seen on its UPnP sweep, which can lag behind or miss servers
> that appear after the speaker boots.
---
## Step 2: Register the server on the speaker
The `STORED_MUSIC` source account is the bare UUID from the UDN with a `/0`
suffix appended. If the UDN from discovery is
`uuid:00000000-0000-0000-0000-000000000000`, the account string is
`00000000-0000-0000-0000-000000000000/0` (drop the `uuid:` prefix).
```bash
soundtouch-cli --host 192.0.2.10 account add-nas \
--user 00000000-0000-0000-0000-000000000000/0 \
--name "My Music Library"
```
Verify the source is visible:
```bash
soundtouch-cli --host 192.0.2.10 source list
```
The output should include a `STORED_MUSIC` entry with your display name and
status `READY`. If the status is `UNAVAILABLE`, wait 10-20 seconds and check
again; the speaker needs a moment to connect to the media server.
---
## Step 3: Browse the library
Get the top-level containers:
```bash
soundtouch-cli --host 192.0.2.10 browse stored-music \
--source-account 00000000-0000-0000-0000-000000000000/0
```
Drill into a folder using a location token returned from the previous step:
```bash
soundtouch-cli --host 192.0.2.10 browse container \
--source STORED_MUSIC \
--source-account 00000000-0000-0000-0000-000000000000/0 \
--location "4:cont2:150:0:0:" \
--type dir
```
Repeat with `--type dir` for sub-folders, or `--type track` for track
containers. The `Location` values shown in browse output are the tokens to
pass to the next `browse container` or `library play` call.
---
## Step 4: Play a track
Pass the location token from a browse result to `library play`:
```bash
soundtouch-cli --host 192.0.2.10 library play \
--source-account 00000000-0000-0000-0000-000000000000/0 \
--location "5:audio5:part13:3171:5 TRACK" \
--name "Track Title"
```
The `--name` flag sets the display name shown on the speaker's display and in
the web UI. It is optional but recommended for clarity.
The CLI checks that the `STORED_MUSIC` source is in `READY` state before
sending the play command. If it is not ready, it prints the `account add-nas`
command you need to run first.
---
## Player UI (BETA)
The soundtouch-player "Library" tab provides a browser-based interface for the
same workflow:
1. Open the player at `http://<aftertouch-host>:8000`.
2. Go to the **Library** tab.
3. Select the target speaker from the device list.
4. Click **Find servers** to run an SSDP sweep.
5. Click **Add** next to a server to register it on the speaker.
6. Open the server to browse folders and tracks.
7. Click a track to play it on the speaker.
> **BETA notice:** DLNA behavior varies across server implementations. Some
> servers expose non-standard browse trees or restrict access by IP. If a
> server appears in discovery but does not load in the library browser, check
> that the media server allows UPnP browsing from the speaker's IP address.
---
## Removing a server
```bash
soundtouch-cli --host 192.0.2.10 account remove-nas \
--user 00000000-0000-0000-0000-000000000000/0
```
---
## Gotchas and limitations
**No password needed for STORED_MUSIC.** The registration only requires the
server UDN. The `--user` flag takes the bare UUID plus `/0`; no `--password`
flag is accepted or needed.
**Source becomes UNAVAILABLE after a speaker reboot.** The speaker re-runs its
UPnP sweep on startup. Until it rediscovers the media server (usually within
30 seconds), the `STORED_MUSIC` source shows as `UNAVAILABLE`. Wait for it to
return to `READY` before browsing or playing.
**`uuid:` prefix in UDN.** Discovery output may show the full UDN as
`uuid:00000000-0000-0000-0000-000000000000`. Drop the `uuid:` prefix when
passing it to `--user`; the account string is just the UUID plus `/0`.
**Format support.** The SoundTouch firmware decodes MP3 and AAC streams. HLS
(`.m3u8`) playlists and formats the firmware cannot decode (e.g. FLAC, ALAC,
OGG) will not play. If a track starts and immediately stops, the audio format
is likely unsupported.
**Do not re-register a READY source.** Calling `account add-nas` on a source
that is already `READY` can flip it to `UNAVAILABLE`. Check `source list`
first.
@@ -338,6 +338,19 @@ Configures the clock display.
### POST /speaker ✅ **Implemented**
Plays TTS messages or URL content for notifications (ST-10 Series only).
> **Requires DNS interception.** Before playing a `play_info` notification the
> speaker validates the `app_key` by calling `GET /v1/auth` against a hardcoded
> Bose host (`audionotification.api.bosecm.com`, on some firmware the
> `...dev...` variant). After the cloud shutdown that host no longer exists, so
> unless the speaker resolves Bose hostnames through AfterTouch (DNS server +
> the `/etc/resolv.conf` hook, so `*.api.bosecm.com` points at AfterTouch, which
> answers `/v1/auth`), the request hangs and returns
> `ALLEGROWEBSERVER_TIMEOUT` (error `1046`) after ~60s. If you cannot use DNS
> interception, play the clip via the `LOCAL_INTERNET_RADIO` path instead (the
> "radio" method used by the web player's TTS): it needs no `app_key` and no DNS
> redirection, but it replaces the current source rather than ducking and
> resuming it.
**TTS Request XML:**
```xml
<play_info>
@@ -373,6 +386,47 @@ Plays TTS messages or URL content for notifications (ST-10 Series only).
- Custom metadata for NowPlaying display
- Pauses current content, plays notification, then resumes
#### Alternative: play a URL via UPnP / AVTransport (no app key, no DNS)
If the `play_info` DNS requirement above is a problem (for example a home
automation hub that just wants to push a TTS or notification clip), the speaker's
UPnP `AVTransport` service can play a URL directly with no app key and no DNS
interception. POST a SOAP `SetAVTransportURI` to the MediaRenderer control
endpoint on port **8091** (not 8090), then `Play`:
```
POST http://<speaker-ip>:8091/AVTransport/Control
Content-Type: text/xml; charset="utf-8"
SOAPAction: "urn:schemas-upnp-org:service:AVTransport:1#SetAVTransportURI"
<?xml version="1.0" encoding="utf-8"?>
<s:Envelope xmlns:s="http://schemas.xmlsoap.org/soap/envelope/" s:encodingStyle="http://schemas.xmlsoap.org/soap/encoding/">
<s:Body>
<u:SetAVTransportURI xmlns:u="urn:schemas-upnp-org:service:AVTransport:1">
<InstanceID>0</InstanceID>
<CurrentURI>http://&lt;host&gt;/clip.mp3</CurrentURI>
<CurrentURIMetaData></CurrentURIMetaData>
</u:SetAVTransportURI>
</s:Body>
</s:Envelope>
```
The URL must be plain **`http://`**: the speaker's AVTransport rejects `https://`
("URI must start with http://, qplay:// or Stored Music XML") and then reports a
misleading `402 "No URI supplied"`. For an `https`-only source, host the clip
over HTTP or use a method that proxies it (the service TTS / `LOCAL_INTERNET_RADIO`
path).
Trade-offs versus `play_info`: this switches the speaker to the `UPNP` source and
**replaces** the current playback (it does not duck and resume), and the speaker
itself must be able to reach the URL. The CLI wraps both steps:
```bash
soundtouch-cli --host <speaker-ip> speaker url-upnp --url http://<host>/clip.mp3
```
(Thanks to @dagrider in #517 for surfacing this approach.)
### GET /playNotification ✅ **Implemented**
Plays a notification beep sound (ST-10 Series only).
@@ -100,6 +100,20 @@ All subsequent messages (except `selectLastWiFiSource`, see below) use this enve
## Phase 2 — Pairing a New Speaker
> **Preflight (AfterTouch's `setup pair --mode=full`).** Before opening the
> WebSocket, AfterTouch reads `GET /supportedURLs` (must list
> `/setMargeAccount`) and `GET /soundTouchConfigurationStatus`, and only
> runs the state machine below when the status is exactly
> `SOUNDTOUCH_NOT_CONFIGURED`. This matters because a speaker can be
> reachable, named, and already have a `margeAccountUUID` set, yet still
> report `SOUNDTOUCH_NOT_CONFIGURED` — the firmware keeps prompting to
> install the Bose app until a full acknowledged pass through this state
> machine runs, not just `setMargeAccount` on its own. Already-configured
> devices are a no-op; an unsupported route or an unrecognised status value
> aborts without writing anything. See
> [#615](https://github.com/gesellix/Bose-SoundTouch/issues/615) and
> `Manager.PreflightInitPlan` (`pkg/service/setup/marge_pairing.go`).
### 2.1 Setup State Machine
The pairing flow uses a setup state machine on the device. States must be sent in order.
@@ -122,7 +136,7 @@ The pairing flow uses a setup state machine on the device. States must be sent i
</soundTouchConfigurationUpdated>
</updates>
<!-- 3. Set language (3 = German; adjust as needed) -->
<!-- 3. Set language (3 = English; adjust as needed) -->
<msg><header deviceID="{device_id}" url="language" method="POST">
<request requestID="23"></request>
</header><body><sysLanguage>3</sysLanguage></body></msg>
@@ -0,0 +1,119 @@
---
title: "Model Support Matrix"
---
A living record of how individual SoundTouch models behave with AfterTouch,
built up from things actually observed on hardware.
**This table only claims what someone has verified.** Anything not tested is
marked `?` rather than inferred from a similar-looking model. Bose used
several different chassis designs across the SoundTouch line, and at least
one behaviour (LAN reachability, below) differs between them in a way that is
invisible from the outside. If you have a model that isn't filled in yet,
[the commands below](#how-to-fill-in-a-row) produce everything a row needs.
## What the columns mean
- **variant / moduleType**: the speaker's own identifiers, straight out of
`/info`. `variant` is Bose's internal codename for the product; `moduleType`
distinguishes chassis generations (`scm` and `sm2` are the two seen so far).
- **BCO**: whether the board carries a BCO co-processor (Bose's internal name
for the SMSC Wi-Fi/Bluetooth combo chip that also handles AirPlay). Bose's
own `has-bco` helper on the device is simply
`[ "$(cat /proc/module_type)" = scm ]`.
- **`:8000` from LAN**: whether AfterTouch's own port is reachable from
another machine on the network *without* any workaround.
- **Entry port**: when `:8000` isn't reachable, the port AfterTouch redirects
to itself so the admin UI still works. See
[LAN access on co-processor chassis](#lan-access-on-co-processor-chassis).
## Matrix
| Model | variant | moduleType | BCO | On-device install | `:8000` from LAN | Entry port | Evidence |
|---------------------|----------|------------|-----|-------------------|------------------|------------|-----------------------------------------------------------------------|
| SoundTouch 20 | `spotty` | `scm` | yes | works | ✗ blocked | `17008` | verified on hardware 2026-08-16 (FW 27.0.6), redirect survives reboot |
| SoundTouch 10 | ? | ? | ? | reported working | ? | ? | not tested for LAN reachability |
| SoundTouch 30 | ? | ? | ? | reported working | ? | ? | not tested for LAN reachability |
| SoundTouch Portable | ? | ? | ? | ? | ? | ? | not tested |
| Wave / SA-4 | ? | ? | ? | ? | ? | ? | not tested |
Not every SoundTouch shares one firmware image, so treat a `?` as genuinely
unknown. In particular, do not assume a model is unaffected just because it is
newer or older than a model that is.
## LAN access on co-processor chassis
On chassis with a BCO co-processor, inbound LAN traffic reaches the speaker's
main Linux SoC only for a fixed set of Bose's *own* service ports. That list
appears to be compiled into the co-processor's firmware, and AfterTouch's
`:8000` is not on it, so a connection attempt never arrives at the SoC at
all. On a verified ST20, `tcpdump -i eth0` on the speaker recorded **zero
packets** for `:8000` while Bose's `:8090`, `:8091`, `:8200`, `:82`, `:8080`
and `:17000` all answered normally from the same client.
This is not a firewall, and not something AfterTouch can fix by binding
differently: the service already listens on `0.0.0.0:8000`, and the speaker's
`iptables` is empty (there is no `nft` or `ebtables` at all).
The on-device installer works around it by redirecting one of the relayed
ports to AfterTouch. **Credit for this technique goes to the
[STR / SoundTouch Reborn](https://github.com/JRpersonal/streborn) project**,
which documented and shipped it first (their agent uses the same entry port
for the same reason); finding their prior art is what turned this from an
apparent hardware dead end into a one-line fix:
```
iptables -t nat -I PREROUTING 1 ! -i lo -p tcp --dport 17008 -j REDIRECT --to-ports 8000
```
`17008` is Bose's `SoftwareUpdate` listener. Its cloud service no longer
exists, so taking over its inbound traffic costs nothing in practice. Only
external traffic is matched (`! -i lo`), so anything running on the speaker
still reaches AfterTouch on `:8000` exactly as before.
The rule is re-applied by the init script on every start, so it survives
reboots (confirmed on the ST20) without any background watchdog. It is
removed again on `stop` and on uninstall.
The redirect is applied automatically on chassis that need it, and configured
via `AFTERTOUCH_LAN_PORT` in `/opt/aftertouch/aftertouch.conf`:
| Value | Effect |
|------------|-----------------------------------------------------------------|
| `auto` | *(default)* redirect only where the co-processor blocks `:8000` |
| `none` | never redirect; use an SSH tunnel instead |
| *(a port)* | always redirect that inbound port to AfterTouch |
Two caveats worth knowing:
- **Account linking still prefers the SSH tunnel.** Spotify only accepts
`https://` or *loopback* OAuth redirect URIs, so `http://localhost:8000`
through a tunnel works for linking where a plain LAN address does not.
- **The `streborn` project defaults to the same port** for the same reason. If
you run both on one speaker, change `AFTERTOUCH_LAN_PORT`.
## How to fill in a row
Run these from a machine on the same network (replace the address), then open
an issue or PR with the output:
```bash
# variant, moduleType, and whether an SCM/SMSC component is listed
curl -s http://<speaker-ip>:8090/info
# is AfterTouch's own port reachable directly? (only meaningful once
# AfterTouch is installed on the device)
curl -v --max-time 5 http://<speaker-ip>:8000/health
# which Bose ports the chassis relays at all
for p in 82 8080 8090 8091 8200 17000 17008; do
printf '%s: ' "$p"
curl -s -o /dev/null -w '%{http_code}\n' --max-time 3 "http://<speaker-ip>:$p/" || echo unreachable
done
```
And on the speaker itself, if you have SSH access:
```bash
has-bco; echo "has-bco exit status: $?" # 0 = BCO co-processor present
cat /proc/module_type /proc/variant
```
@@ -0,0 +1,171 @@
---
title: "Player: Sources and Selection State"
---
How the embedded web player (`soundtouch-player`, and the same UI served by
`soundtouch-service`) decides what a source button does, and how it decides
whether a selection worked.
Both questions have non-obvious answers, learned from real hardware. This page
records what the speaker actually does, so the behaviour is not re-derived or
accidentally undone.
## Not every advertised source can be selected
A speaker's `/sources` lists what it knows about, each with a `status`. The
player renders every `status="READY"` entry as a button. That set is not
uniform: some entries are **inputs**, some are **providers**.
An **input** can be selected on its own. `AUX`, `BLUETOOTH`, a `SPOTIFY`
entry with a real `sourceAccount`: `POST /select` with just the source and
account is meaningful, and the speaker resumes that input.
A **provider** cannot. `RADIO_BROWSER`, `TUNEIN` and `LOCAL_INTERNET_RADIO`
need a station **ContentItem carrying a `Location`** (see
`stations.ResolveContentItem`, which sets `type="stationurl"`). There is
nothing for the speaker to resume from the source name alone.
`STORED_MUSIC` is a third case: one entry per media server, its
`sourceAccount` being a server UDN. Selecting it identifies no track or
container.
## What a bare select does to a provider
The speaker does not refuse. It answers `200`, and parks on a stub
now-playing, while whatever was playing before **carries on**:
```xml
<nowPlaying deviceID="..." source="RADIO_BROWSER" sourceAccount="">
<ContentItem source="RADIO_BROWSER" type="" location="" isPresetable="false">
<itemName>RADIO_BROWSER</itemName>
</ContentItem>
</nowPlaying>
```
Four things identify the stub: no `playStatus`, empty `type`, empty
`location`, and an `itemName` that just echoes the source name.
The speaker then reports that stub indefinitely. Observed on hardware: the
player showed RadioBrowser while Spotify was audible, and a naive readback
"confirmed" the selection because the reported source did match the one
requested. `LOCAL_INTERNET_RADIO` produces a byte-identical stub.
This is speaker behaviour, not something the player or the service can fix
after the fact. The only remedy is not to issue such a select.
## What the player does instead
| Source | Click behaviour | Resumes from Recents |
|------------------------|-----------------------------------------------|----------------------|
| `RADIO_BROWSER` | resume newest station, else open RadioBrowser | yes |
| `TUNEIN` | resume newest station, else open TuneIn | yes |
| `LOCAL_INTERNET_RADIO` | open Play URL | no |
| `STORED_MUSIC` | open Library | no |
| anything else | `POST /select` as before | n/a |
Resuming replays the newest Recents entry for that source, using that entry's
own ContentItem: the real item the speaker was given, `Location` included.
**`LOCAL_INTERNET_RADIO` deliberately does not resume.** AfterTouch plays its
own one-shot audio through that source: TTS and the notification ding both go
out over `/custom/v1/playback/`. Its Recents therefore mix notifications with
stations, and on a test speaker the *only* entry was "AfterTouch ding", so
resuming played the ding. The announcement path is distinguishable from Play
URL's `bmx.BuildOrionLocation`, but it also carries CLI URL playback, and any
future audio-injecting feature would have to remember to stay clear of it.
Opening Play URL does not depend on classifying what is in Recents.
`ALEXA` is advertised `READY` too and is left alone, because whether a bare
select resumes anything for it has not been verified.
### The backstop
The table above only covers sources known to need it, and a source list is
whatever the speaker chooses to advertise. So the readback additionally
refuses to *confirm* the stub itself, wherever it comes from: a now-playing
naming the requested source but with no `Location`, no `PlayStatus`, and an
`ItemName` equal to the source is reported as a failure.
All three conditions are required together. A physical input reports no
location and no item name of its own yet is genuinely playing, so any single
condition alone would reject real selections.
## How a selection is confirmed
`POST /select` returning `200` proves nothing: the speaker can reject a source
seconds later, surfacing as a transition to an error source
(`INVALID_SOURCE`, `*_ERROR`). So the player posts once, then watches.
- The **event stream** is the primary watcher. A `nowPlayingUpdated` event
reports a late rejection as it happens, and the player turns it into a
failure.
- **Bounded readbacks** at 2s, 5s and 10s are the fallback for a speaker whose
events are not arriving. They stop as soon as a confirmation arrives *and*
the readback reports a live event stream, so a confirmed selection normally
costs one request rather than three.
- Readbacks use `GET /devices/{id}/now-playing`, which refreshes only
`/now_playing`. The full device fetch runs a complete status poll: six
sequential speaker calls plus `/getGroup` on a stereo-capable model, to
answer one question, against a device that may be slow precisely because
something is wrong.
Outcomes are `pending`, `provisional-confirmed`, `final-confirmed`,
`unverified` and `failed`, shown in a live region under the source list. A
confirmation from a push event is never retracted by a later failed readback.
### Definitive versus uncertain failures
A rejected write is not always proof the command never landed:
- **4xx** is produced before the service contacts the speaker (unknown device,
unparseable body, empty source). The command provably never went out, so the
failure is reported immediately.
- **5xx and transport errors** are ambiguous. `handleSourceControl` reports a
failed `Client.SelectSource` through `sendControlResponse`, which maps any
speaker-call error to 500, and a request that timed out *after* the speaker
already switched looks identical to one it never received. The readbacks
keep running and the reason is carried into whatever outcome they reach.
## Ordering: revisions and epochs
Status reaches the browser three ways — a full `devices` snapshot, a
`status_update` delta, and REST refreshes — with no inherent ordering. Two
fields fix that:
- `revision` advances on every projection, so a frame no newer than what the
browser holds is dropped.
- `nowPlayingRevision` is the now-playing field's own generation. `revision`
alone cannot answer "did now-playing actually change?", because any other
field's merge advances it; a selection waiting for confirmation needs
exactly that distinction.
Revisions are per-connection and restart at 0, so they are only comparable
within one **`epoch`**, which identifies the connection that produced the
status. Without it, a device backed by a fresh connection would publish
revisions the browser rejects forever, freezing that device's display until
reload. Epochs are seeded from the wall clock and forced strictly increasing,
so they keep rising across a service restart, and are in milliseconds because
the browser compares them as JSON numbers.
## Source inventory staleness
`sourcesStale` marks an inventory the speaker has stopped confirming; the
player keeps showing it but disables the buttons.
It is set after **two consecutive** failed `/sources` reads, not one. A single
dropped read is not evidence the list is wrong, and marking it stale
immediately disabled every source button on a transient hiccup. This mirrors
`offlineFailureThreshold`, which debounces connectivity the same way. A
successful read clears the marker and resets the count.
Successful reads remain ordered by generation, so an older one cannot
overwrite a newer one. Failures are not ordered: a failure carries no
inventory, so spending the generation on it would let a failed read discard a
concurrent successful one.
## Related
- [Source Selection Guide](SOURCE-SELECTION.md) — the `/select` endpoint and
the client library
- [WebSocket Events](WEBSOCKET-EVENTS.md) — the event stream the confirmation
relies on
- [Radio Browser](radio-browser.md) — the RadioBrowser provider
+43 -23
View File
@@ -29,17 +29,34 @@ The Bose SoundTouch Go client provides comprehensive source selection functional
**Response**: HTTP 200 OK (no body) on success
**Supported Sources:**
- `SPOTIFY` - Spotify streaming service
**Sources selectable this way:**
- `SPOTIFY` - Spotify streaming service (with a real `sourceAccount`)
- `BLUETOOTH` - Bluetooth audio input
- `AUX` - Auxiliary input (3.5mm jack)
- `TUNEIN` - TuneIn internet radio
- `PANDORA` - Pandora streaming service
- `AMAZON` - Amazon Music
- `IHEARTRADIO` - iHeartRadio streaming
- `STORED_MUSIC` - Local/network stored music
- `AIRPLAY` - Apple AirPlay (device dependent)
- `PANDORA`, `AMAZON`, `IHEARTRADIO` - streaming services (with an account)
### Sources that need a ContentItem instead
A bare `/select` carrying only a source and account is **not** enough for
every source a speaker advertises. These need a full ContentItem with a
`Location`, built by `stations.ResolveContentItem` with `type="stationurl"`:
- `TUNEIN` - TuneIn internet radio
- `RADIO_BROWSER` - [RadioBrowser](radio-browser.md) internet radio directory
- `LOCAL_INTERNET_RADIO` - stream URLs, including Play URL and TTS output
- `STORED_MUSIC` - one entry per media server; selecting it names no track
Given a bare select for one of these the speaker does **not** report an
error. It answers `200`, parks on a stub now-playing with no `playStatus`,
empty `type` and `location`, and an `itemName` echoing the source name, and
carries on playing whatever it was playing. It then reports that stub
indefinitely, so callers that check `/now_playing` see the source they asked
for while the audio is something else.
Use `SelectContentItem` with a real `Location` for these, and see
[Player: Sources and Selection State](PLAYER-SOURCE-BEHAVIOUR.md) for the
stub's full signature and how the web player avoids it.
## Client Library Usage
@@ -158,20 +175,20 @@ soundtouch-cli -host 192.0.2.100 -aux
### CLI Flags
| Flag | Description | Example |
|------|-------------|---------|
| `-select-source <source>` | Select audio source | `-select-source SPOTIFY` |
| `-source-account <account>` | Account for streaming services | `-source-account "user123"` |
| `-spotify` | Select Spotify source | `-spotify -source-account "user"` |
| `-bluetooth` | Select Bluetooth source | `-bluetooth` |
| `-aux` | Select AUX input | `-aux` |
| Flag | Description | Example |
|-----------------------------|--------------------------------|-----------------------------------|
| `-select-source <source>` | Select audio source | `-select-source SPOTIFY` |
| `-source-account <account>` | Account for streaming services | `-source-account "user123"` |
| `-spotify` | Select Spotify source | `-spotify -source-account "user"` |
| `-bluetooth` | Select Bluetooth source | `-bluetooth` |
| `-aux` | Select AUX input | `-aux` |
## Source Account Information
### When Source Accounts are Required
- **Spotify**: Required for multi-account setups
- **Pandora**: Required for account-based access
- **Pandora**: Required for account-based access
- **TuneIn**: Optional, may improve personalization
- **Amazon Music**: Required for account access
- **Bluetooth/AUX**: Not required (leave empty)
@@ -259,11 +276,11 @@ func selectWithConfig(client *client.Client, config SourceConfig) error {
### Common Error Codes
| Code | Name | Description | Solution |
|------|------|-------------|----------|
| 1005 | UNKNOWN_SOURCE_ERROR | Invalid or unavailable source | Check available sources first |
| 1006 | SOURCE_UNAVAILABLE | Source temporarily unavailable | Try again later |
| 1007 | ACCOUNT_ERROR | Invalid account for source | Check account name format |
| Code | Name | Description | Solution |
|------|----------------------|--------------------------------|-------------------------------|
| 1005 | UNKNOWN_SOURCE_ERROR | Invalid or unavailable source | Check available sources first |
| 1006 | SOURCE_UNAVAILABLE | Source temporarily unavailable | Try again later |
| 1007 | ACCOUNT_ERROR | Invalid account for source | Check account name format |
### Troubleshooting Tips
@@ -347,13 +364,16 @@ The implementation follows the official SoundTouch API:
## Related Documentation
- [Player: Sources and Selection State](PLAYER-SOURCE-BEHAVIOUR.md) - which
sources accept a bare select, and how a selection is confirmed
- **[API Endpoints Overview](API-ENDPOINTS.md)** - Complete API reference
- **[Sources](https://github.com/gesellix/Bose-SoundTouch/blob/main/pkg/models/sources.go)** - Source model implementation
- **[Sources](https://github.com/gesellix/Bose-SoundTouch/blob/main/pkg/models/sources.go)** - Source model implementation
- **[Now Playing](https://github.com/gesellix/Bose-SoundTouch/blob/main/pkg/models/nowplaying.go)** - ContentItem model
- **[Client Usage Examples](https://github.com/gesellix/Bose-SoundTouch/blob/main/cmd/soundtouch-cli/main.go)** - CLI implementation reference
---
**Implementation Date**: 2026-01-09
**Status**: ✅ Complete and tested
**Implementation Date**: 2026-01-09
**Status**: ✅ Complete and tested
**Real Device Validation**: SoundTouch 10, SoundTouch 20
+1 -1
View File
@@ -1,4 +1,4 @@
---
title: "Technical Reference"
weight: 2
weight: 3
---
@@ -43,6 +43,8 @@ curl -v -X POST http://<speaker-ip>:8090/notification \
Replace `<speaker-ip>` with your speaker's IP address and `<deviceID>` with its device ID (visible in `/info`). After this call the speaker re-fetches its sources from the service. Step 2 (source-type registration) still requires a reboot.
> **If a reboot doesn't help after an in-place migration:** on some speakers the radio source types (`LOCAL_INTERNET_RADIO`, `TUNEIN`, `RADIO_BROWSER`) never activate after an in-place migration, even though the entries are present in the device-local `Sources.xml` and you have rebooted and sent `sourcesUpdated`. The cause isn't fully understood; the confirmed remedy is a factory reset + re-migrate. See [Troubleshooting: Radio sources never activate after an in-place migration](../guides/TROUBLESHOOTING.md#radio-sources-after-migration).
### Search for stations
- Go to https://www.radio-browser.info and find a station you like.
+2 -2
View File
@@ -1,8 +1,8 @@
module navigation-station-demo
go 1.26.4
go 1.27.1
require github.com/gesellix/bose-soundtouch v0.107.0
require github.com/gesellix/bose-soundtouch v0.128.0
require github.com/gorilla/websocket v1.5.3 // indirect
+2 -2
View File
@@ -1,8 +1,8 @@
module preset-management-example
go 1.26.4
go 1.27.1
require github.com/gesellix/bose-soundtouch v0.107.0
require github.com/gesellix/bose-soundtouch v0.128.0
require github.com/gorilla/websocket v1.5.3 // indirect
+15 -17
View File
@@ -1,40 +1,38 @@
module github.com/gesellix/bose-soundtouch
go 1.26.4
go 1.27.1
require (
filippo.io/age v1.3.1
github.com/chromedp/chromedp v0.15.1
github.com/go-chi/chi/v5 v5.2.5
filippo.io/age v1.3.2
github.com/chromedp/chromedp v0.16.0
github.com/go-chi/chi/v5 v5.3.2
github.com/google/gopacket v1.1.19
github.com/gorilla/websocket v1.5.3
github.com/hashicorp/mdns v1.0.6
github.com/miekg/dns v1.1.72
github.com/hashicorp/mdns v1.0.7
github.com/miekg/dns v1.1.73
github.com/russross/blackfriday/v2 v2.1.0
github.com/sergi/go-diff v1.4.0
github.com/srwiley/oksvg v0.0.0-20221011165216-be6e8873101c
github.com/srwiley/rasterx v0.0.0-20220730225603-2ab79fcdd4ef
github.com/urfave/cli/v2 v2.27.7
golang.org/x/crypto v0.52.0
golang.org/x/net v0.55.0
golang.org/x/term v0.43.0
golang.org/x/crypto v0.56.0
golang.org/x/mod v0.40.0
golang.org/x/net v0.58.0
golang.org/x/term v0.45.0
)
require (
filippo.io/edwards25519 v1.2.0 // indirect
filippo.io/hpke v0.4.0 // indirect
github.com/chromedp/cdproto v0.0.0-20260427013145-5737772c319b // indirect
github.com/chromedp/cdproto v0.0.0-20260714215040-dc233986426f // indirect
github.com/chromedp/sysutil v1.1.0 // indirect
github.com/cpuguy83/go-md2man/v2 v2.0.7 // indirect
github.com/go-json-experiment/json v0.0.0-20260505212615-e40f80bf6836 // indirect
github.com/go-json-experiment/json v0.0.0-20260623181947-01eb4420fa68 // indirect
github.com/gobwas/httphead v0.1.0 // indirect
github.com/gobwas/pool v0.2.1 // indirect
github.com/gobwas/ws v1.4.0 // indirect
github.com/xrash/smetrics v0.0.0-20250705151800-55b8f293f342 // indirect
golang.org/x/image v0.41.0 // indirect
golang.org/x/mod v0.36.0 // indirect
golang.org/x/sync v0.20.0 // indirect
golang.org/x/sys v0.45.0 // indirect
golang.org/x/text v0.37.0 // indirect
golang.org/x/tools v0.45.0 // indirect
golang.org/x/image v0.45.0 // indirect
golang.org/x/sys v0.47.0 // indirect
golang.org/x/text v0.41.0 // indirect
)
+32 -102
View File
@@ -1,15 +1,15 @@
c2sp.org/CCTV/age v0.0.0-20251208015420-e9274a7bdbfd h1:ZLsPO6WdZ5zatV4UfVpr7oAwLGRZ+sebTUruuM4Ra3M=
c2sp.org/CCTV/age v0.0.0-20251208015420-e9274a7bdbfd/go.mod h1:SrHC2C7r5GkDk8R+NFVzYy/sdj0Ypg9htaPXQq5Cqeo=
filippo.io/age v1.3.1 h1:hbzdQOJkuaMEpRCLSN1/C5DX74RPcNCk6oqhKMXmZi0=
filippo.io/age v1.3.1/go.mod h1:EZorDTYUxt836i3zdori5IJX/v2Lj6kWFU0cfh6C0D4=
c2sp.org/CCTV/age v0.0.0-20260829155415-4448f2097b2d h1:Blprhc2SbChNZtWcU+BLTM4YdoqYAS9V7cJgOwJKyAs=
c2sp.org/CCTV/age v0.0.0-20260829155415-4448f2097b2d/go.mod h1:SrHC2C7r5GkDk8R+NFVzYy/sdj0Ypg9htaPXQq5Cqeo=
filippo.io/age v1.3.2 h1:r6RSZLFSMm6rzKepZ7ZAYkKCu14f3/Me8c7uKYh7C8c=
filippo.io/age v1.3.2/go.mod h1:TH/Yr2sSRhCKbaH4XPxpUV0Us8Gv6txYUpiZQWz8Evk=
filippo.io/edwards25519 v1.2.0 h1:crnVqOiS4jqYleHd9vaKZ+HKtHfllngJIiOpNpoJsjo=
filippo.io/edwards25519 v1.2.0/go.mod h1:xzAOLCNug/yB62zG1bQ8uziwrIqIuxhctzJT18Q77mc=
filippo.io/hpke v0.4.0 h1:p575VVQ6ted4pL+it6M00V/f2qTZITO0zgmdKCkd5+A=
filippo.io/hpke v0.4.0/go.mod h1:EmAN849/P3qdeK+PCMkDpDm83vRHM5cDipBJ8xbQLVY=
github.com/chromedp/cdproto v0.0.0-20260427013145-5737772c319b h1:fpvdcCAe2z3H8OvVY00iKOp3Wapbs/Gy375Fn6l/XM4=
github.com/chromedp/cdproto v0.0.0-20260427013145-5737772c319b/go.mod h1:cbyjALe67vDvlvdiG9369P8w5U2w6IshwtyD2f2Tvag=
github.com/chromedp/chromedp v0.15.1 h1:EJWiPm7BNqDqjYy6U0lTSL5wNH+iNt9GjC3a4gfjNyQ=
github.com/chromedp/chromedp v0.15.1/go.mod h1:CdTHtUqD/dqaFw/cvFWtTydoEQS44wLBuwbMR9EkOY4=
github.com/chromedp/cdproto v0.0.0-20260714215040-dc233986426f h1:0Z1zcSLEmnj2c2CmJYBqewtS6pxhB39bNWUSEUAWjgk=
github.com/chromedp/cdproto v0.0.0-20260714215040-dc233986426f/go.mod h1:RwFsSODCtFExll+GhHM6R92SARHR3Z3oipaxLHj46C0=
github.com/chromedp/chromedp v0.16.0 h1:rOO4deOm4CbZgBCa8mD9g2rDyIoNs0BkgvNrlbp5ouk=
github.com/chromedp/chromedp v0.16.0/go.mod h1:rbuGKFT1vMcFcFqKfPIO1GpX/N+2s8onm2qMxZLbU5U=
github.com/chromedp/sysutil v1.1.0 h1:PUFNv5EcprjqXZD9nJb9b/c9ibAbxiYo4exNWZyipwM=
github.com/chromedp/sysutil v1.1.0/go.mod h1:WiThHUdltqCNKGc4gaU50XgYjwjYIhKWoHGPTUfWTJ8=
github.com/cpuguy83/go-md2man/v2 v2.0.7 h1:zbFlGlXEAKlwXpmvle3d8Oe3YnkKIK4xSRTd3sHPnBo=
@@ -17,32 +17,29 @@ github.com/cpuguy83/go-md2man/v2 v2.0.7/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6N
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/go-chi/chi/v5 v5.2.5 h1:Eg4myHZBjyvJmAFjFvWgrqDTXFyOzjj7YIm3L3mu6Ug=
github.com/go-chi/chi/v5 v5.2.5/go.mod h1:X7Gx4mteadT3eDOMTsXzmI4/rwUpOwBHLpAfupzFJP0=
github.com/go-json-experiment/json v0.0.0-20260505212615-e40f80bf6836 h1:5KGUhXZFTN1PrCY4zUZLe1J8n7uBNmPDbCLCn78EbPQ=
github.com/go-json-experiment/json v0.0.0-20260505212615-e40f80bf6836/go.mod h1:tphK2c80bpPhMOI4v6bIc2xWywPfbqi1Z06+RcrMkDg=
github.com/go-chi/chi/v5 v5.3.2 h1:5YQkICvTCSZ25hoRsyJazN0scjzKGiu4VAUc7H1o1nY=
github.com/go-chi/chi/v5 v5.3.2/go.mod h1:R+tYY2hNuVUUjxoPtqUdgBqevM9s9njzkTLutVsOCto=
github.com/go-json-experiment/json v0.0.0-20260623181947-01eb4420fa68 h1:KZaTBSyshWX3MP5jukJcNSuXDQTO+rNpt0J564dX/eg=
github.com/go-json-experiment/json v0.0.0-20260623181947-01eb4420fa68/go.mod h1:tphK2c80bpPhMOI4v6bIc2xWywPfbqi1Z06+RcrMkDg=
github.com/gobwas/httphead v0.1.0 h1:exrUm0f4YX0L7EBwZHuCF4GDp8aJfVeBrlLQrs6NqWU=
github.com/gobwas/httphead v0.1.0/go.mod h1:O/RXo79gxV8G+RqlR/otEwx4Q36zl9rqC5u12GKvMCM=
github.com/gobwas/pool v0.2.1 h1:xfeeEhW7pwmX8nuLVlqbzVc7udMDrwetjEv+TZIz1og=
github.com/gobwas/pool v0.2.1/go.mod h1:q8bcK0KcYlCgd9e7WYLm9LpyS+YeLd8JVDW6WezmKEw=
github.com/gobwas/ws v1.4.0 h1:CTaoG1tojrh4ucGPcoJFiAQUAsEWekEWvLy7GsVNqGs=
github.com/gobwas/ws v1.4.0/go.mod h1:G3gNqMNtPppf5XUz7O4shetPpcZ1VJ7zt18dlUeakrc=
github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
github.com/google/gopacket v1.1.19 h1:ves8RnFZPGiFnTS0uPQStjwru6uO6h+nlr9j6fL7kF8=
github.com/google/gopacket v1.1.19/go.mod h1:iJ8V8n6KS+z2U1A8pUwu8bW5SyEMkXJB8Yo/Vo+TKTo=
github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=
github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
github.com/hashicorp/mdns v1.0.6 h1:SV8UcjnQ/+C7KeJ/QeVD/mdN2EmzYfcGfufcuzxfCLQ=
github.com/hashicorp/mdns v1.0.6/go.mod h1:X4+yWh+upFECLOki1doUPaKpgNQII9gy4bUdCYKNhmM=
github.com/hashicorp/mdns v1.0.7 h1:yWoQVMW5JOiDxQnIUcm3IDt0kCjf3TuXHDbdEKPsbAY=
github.com/hashicorp/mdns v1.0.7/go.mod h1:yjuhYhZyPDqXXL48xC7cdpGwGUMwu7OViDmsuT5COvg=
github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
github.com/ledongthuc/pdf v0.0.0-20220302134840-0c2507a12d80 h1:6Yzfa6GP0rIo/kULo2bwGEkFvCePZ3qHDDTC3/J9Swo=
github.com/ledongthuc/pdf v0.0.0-20220302134840-0c2507a12d80/go.mod h1:imJHygn/1yfhB7XSJJKlFZKl/J+dCPAknuiaGOshXAs=
github.com/miekg/dns v1.1.55/go.mod h1:uInx36IzPl7FYnDcMeVWxj9byh7DutNykX4G9Sj60FY=
github.com/miekg/dns v1.1.72 h1:vhmr+TF2A3tuoGNkLDFK9zi36F2LS+hKTRW0Uf8kbzI=
github.com/miekg/dns v1.1.72/go.mod h1:+EuEPhdHOsfk6Wk5TT2CzssZdqkmFhf8r+aVyDEToIs=
github.com/miekg/dns v1.1.73 h1:uhT8nJxmTrPJYClxVxTCX+CVn6qnzSiybRk72Z6DgrE=
github.com/miekg/dns v1.1.73/go.mod h1:RW2Obtfd5NZHvOFe3zYG0W8koWOQtAzyHaLo8vASBuQ=
github.com/orisano/pixelmatch v0.0.0-20220722002657-fb0b55479cde h1:x0TT0RDC7UhAVbbWWBzr41ElhJx5tXPWkIHA2HWPRuw=
github.com/orisano/pixelmatch v0.0.0-20220722002657-fb0b55479cde/go.mod h1:nZgzbfBr3hhjoZnS66nKrHmduYNpc34ny7RK4z5/HM0=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
@@ -62,101 +59,34 @@ github.com/urfave/cli/v2 v2.27.7 h1:bH59vdhbjLv3LAvIu6gd0usJHgoTTPhCFib8qqOwXYU=
github.com/urfave/cli/v2 v2.27.7/go.mod h1:CyNAG/xg+iAOg0N4MPGZqVmv2rCoP267496AOXUZjA4=
github.com/xrash/smetrics v0.0.0-20250705151800-55b8f293f342 h1:FnBeRrxr7OU4VvAzt5X7s6266i6cSVkkFPS0TuXWbIg=
github.com/xrash/smetrics v0.0.0-20250705151800-55b8f293f342/go.mod h1:Ohn+xnUBiLI6FVj/9LpzZWtj1/D6lUovWYBkxHVV3aM=
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
golang.org/x/crypto v0.13.0/go.mod h1:y6Z2r+Rw4iayiXXAIxJIDAJ1zMW4yaTpebo8fPOliYc=
golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU=
golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8=
golang.org/x/crypto v0.32.0/go.mod h1:ZnnJkOaASj8g0AjIduWNlq2NRxL0PlBrbKVyZ6V/Ugc=
golang.org/x/crypto v0.52.0 h1:RMs7fP2rXdep0CftQlK8Uf+kibLm7qkCcradZWYz988=
golang.org/x/crypto v0.52.0/go.mod h1:1QgfPxDqh0T2M/elOJtp9RvuR95kVjir0e6/BvEmGbc=
golang.org/x/image v0.41.0 h1:8wS72eGJMJaBxK6okTzd4WaXumUlTVlb753MlsSvTCo=
golang.org/x/image v0.41.0/go.mod h1:uIc348UZMSvS5Z65CVZ7iDPaNobNFEPeJ4kbqTOszmA=
golang.org/x/crypto v0.56.0 h1:GUh5Ii4J5jtcseSMiRqr1jXCNHoxjeV9Fmekc2oLy6Y=
golang.org/x/crypto v0.56.0/go.mod h1:OMW5y6CY9l38uPLmxU6l6pwcXp1obtLo3e6gT7gQR2I=
golang.org/x/image v0.45.0 h1:FMb1nTbH5H9vF55SriQHgFw5GnNL9Jg6L25BwXKzhB0=
golang.org/x/image v0.45.0/go.mod h1:n62x/7RqlwXDvGsSU4u6IUTUf6KghUZ9Bt7cG/T9Fx4=
golang.org/x/lint v0.0.0-20200302205851-738671d3881b/go.mod h1:3xt1FjdF8hUf6vQPIChWIBhFzV8gjjsPE/fR3IyQdNY=
golang.org/x/mod v0.1.1-0.20191105210325-c90efee705ee/go.mod h1:QqPTAvyqsEbceGzBzNggFXnrqF1CaUcvgkdR5Ot7KZg=
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
golang.org/x/mod v0.7.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
golang.org/x/mod v0.12.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
golang.org/x/mod v0.15.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
golang.org/x/mod v0.17.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
golang.org/x/mod v0.36.0 h1:JJjpVx6myfUsUdAzZuOSTTmRE0PfZeNWzzvKrP7amb4=
golang.org/x/mod v0.36.0/go.mod h1:moc6ELqsWcOw5Ef3xVprK5ul/MvtVvkIXLziUOICjUQ=
golang.org/x/mod v0.40.0 h1:hUv+3cXcdRHz08UmSiOob7sadHig73uo5bkXxQ/tvUs=
golang.org/x/mod v0.40.0/go.mod h1:0/weTWkPWGBikyTWAX3dkjVztMmBA5hM0DH6BElSupE=
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
golang.org/x/net v0.2.0/go.mod h1:KqCZLdyyvdV855qA2rE3GC2aiw5xGR5TEjj8smXukLY=
golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg=
golang.org/x/net v0.15.0/go.mod h1:idbUs1IY1+zTqbi8yxTbhexhEEk5ur9LInksu6HrEpk=
golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44=
golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM=
golang.org/x/net v0.34.0/go.mod h1:di0qlW3YNM5oh6GqDGQr92MyTozJPmybPK4Ev/Gm31k=
golang.org/x/net v0.55.0 h1:bcvxaJn3e1U6InsFWt1JUq1aSjnRxLzT2rtD2KfkDF8=
golang.org/x/net v0.55.0/go.mod h1:L5U2KuzuOe1lY7Z+aWVIKK6qEeJXnXV9yzGA+WCHJww=
golang.org/x/net v0.58.0 h1:ynWG7rqYi4ccpTEuPZ2QGWHktVEM9DMCj9yzDE0Q7To=
golang.org/x/net v0.58.0/go.mod h1:YwCddHnFlT7eLQqVprV19OnhLGtc5xOKgE0RyqgfWAU=
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.3.0/go.mod h1:FU7BRWz2tNW+3quACPkgCx/L+uEAv1htQ0V83Z9Rj+Y=
golang.org/x/sync v0.6.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/sync v0.10.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=
golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
golang.org/x/sync v0.22.0 h1:SZjpbeLmrCk4xhRSZFNZW5gFUeCeFgjekvI/+gfScek=
golang.org/x/sync v0.22.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.2.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.29.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.45.0 h1:dO4czNzziLiiXplLQgBCEpCvXQ3dnkn0SdaZSYdQ+FY=
golang.org/x/sys v0.45.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/telemetry v0.0.0-20240228155512-f48c80bd79b2/go.mod h1:TeRTkGYfJXctD9OcfyVLyj2J3IxLnKwHJR8f4D8a3YE=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
golang.org/x/term v0.2.0/go.mod h1:TVmDHMZPmdnySmBfhjOoOdhjzdE1h4u1VwSiw2l1Nuc=
golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
golang.org/x/term v0.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo=
golang.org/x/term v0.12.0/go.mod h1:owVbMEjm3cBLCHdkQu9b1opXd4ETQWc3BhuQGKgXgvU=
golang.org/x/term v0.17.0/go.mod h1:lLRBjIVuehSbZlaOtGMbcMncT+aqLLLmKrsjNrUguwk=
golang.org/x/term v0.20.0/go.mod h1:8UkIAJTvZgivsXaD6/pH6U9ecQzZ45awqEOzuCvwpFY=
golang.org/x/term v0.28.0/go.mod h1:Sw/lC2IAUZ92udQNf3WodGtn4k/XoLyZoh8v/8uiwek=
golang.org/x/term v0.43.0 h1:S4RLU2sB31O/NCl+zFN9Aru9A/Cq2aqKpTZJ6B+DwT4=
golang.org/x/term v0.43.0/go.mod h1:lrhlHNdQJHO+1qVYiHfFKVuVioJIheAc3fBSMFYEIsk=
golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs=
golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/term v0.45.0 h1:NwWyBmoJCbfTHpxrWoZ9C6/VxOf7ic219I8xZZFdrf0=
golang.org/x/term v0.45.0/go.mod h1:9aqxs0blBcrm/n0L9QW0aRVD+ktan8ssZromtqJC43w=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
golang.org/x/text v0.4.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8=
golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE=
golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ=
golang.org/x/text v0.37.0 h1:Cqjiwd9eSg8e0QAkyCaQTNHFIIzWtidPahFWR83rTrc=
golang.org/x/text v0.37.0/go.mod h1:a5sjxXGs9hsn/AJVwuElvCAo9v8QYLzvavO5z2PiM38=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/text v0.41.0 h1:vz/seA0lnX87Othu2f/0L24RcgrXD9/YFTSuGjj3rH8=
golang.org/x/text v0.41.0/go.mod h1:jvf1O8ajNzZqhSrQBPbutR/EB83Cc0CFrezNQIwbb5M=
golang.org/x/tools v0.0.0-20200130002326-2f3ba24bd6e7/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28=
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
golang.org/x/tools v0.3.0/go.mod h1:/rWhSS2+zyEVwoJf8YAX6L2f0ntZ7Kn/mGgAWcipA5k=
golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
golang.org/x/tools v0.13.0/go.mod h1:HvlwmtVNQAhOuCjW7xxvovg8wbNq7LwfXh/k7wXUl58=
golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxbQROHiO6hDPo2LHcIPhhQsa9DLh0yGk=
golang.org/x/tools v0.45.0 h1:18qN3FAooORvApf5XjCXgsuayZOEtXf6JK18I3+ONa8=
golang.org/x/tools v0.45.0/go.mod h1:LuUGqqaXcXMEFEruIVJVm5mgDD8vww/z/SR1gQ4uE/0=
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
+19 -4
View File
@@ -7,13 +7,20 @@
"name": "@gesellix/bose-soundtouch",
"license": "MIT",
"dependencies": {
"es-module-shims": "2.8.4",
"htm": "3.1.1",
"preact": "10.29.2"
"preact": "10.29.8"
},
"engines": {
"node": ">=24.0.0"
}
},
"node_modules/es-module-shims": {
"version": "2.8.4",
"resolved": "https://registry.npmjs.org/es-module-shims/-/es-module-shims-2.8.4.tgz",
"integrity": "sha512-ea5srn5L89PWVad6Qle6r2kg+HvvLiL/GHqgNx06eFrkERHkrTFWaqo1w8Sd+XI3Xr0iAG5x3ZQ7mQ/hnQzNpA==",
"license": "MIT"
},
"node_modules/htm": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/htm/-/htm-3.1.1.tgz",
@@ -21,13 +28,21 @@
"license": "Apache-2.0"
},
"node_modules/preact": {
"version": "10.29.2",
"resolved": "https://registry.npmjs.org/preact/-/preact-10.29.2.tgz",
"integrity": "sha512-7tNmwg/7mzzAoB/8kSg6Hl37JraAZw3Z3A0JSY7VXlZwo82Xn0G7wKbNNs2qoF4ZEEsQGTwDAroNdqKs1ofJxQ==",
"version": "10.29.8",
"resolved": "https://registry.npmjs.org/preact/-/preact-10.29.8.tgz",
"integrity": "sha512-ej2aVZ+vZ8WO7tvlQWRM9N63A0KzF9q4mWJfDUHgYaIofWY9hu74QdnQrjoPMmZi2/nZ5gN0bJCQF49xQqx09Q==",
"license": "MIT",
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/preact"
},
"peerDependencies": {
"preact-render-to-string": ">=5"
},
"peerDependenciesMeta": {
"preact-render-to-string": {
"optional": true
}
}
}
}
+2 -1
View File
@@ -9,7 +9,8 @@
"node": ">=24.0.0"
},
"dependencies": {
"es-module-shims": "2.8.4",
"htm": "3.1.1",
"preact": "10.29.2"
"preact": "10.29.8"
}
}
+152
View File
@@ -0,0 +1,152 @@
package client
import (
"bytes"
"encoding/xml"
"fmt"
"io"
"net"
"net/http"
"net/url"
"strconv"
"strings"
"github.com/gesellix/bose-soundtouch/pkg/speaker"
)
// avTransportControlPath is the UPnP AVTransport control endpoint on the
// speaker's MediaRenderer (served on speaker.UPnPPort, not HTTPPort).
const avTransportControlPath = "/AVTransport/Control"
// avTransportServiceType is the UPnP service type used in the SOAPAction header
// and the action element namespace.
const avTransportServiceType = "urn:schemas-upnp-org:service:AVTransport:1"
// soapEnvelope wraps a SOAP action body in the standard envelope.
const soapEnvelope = `<?xml version="1.0" encoding="utf-8"?>` +
`<s:Envelope xmlns:s="http://schemas.xmlsoap.org/soap/envelope/"` +
` s:encodingStyle="http://schemas.xmlsoap.org/soap/encoding/">` +
`<s:Body>%s</s:Body></s:Envelope>`
// PlayURLViaUPnP plays an audio URL on the speaker through its UPnP AVTransport
// service: SetAVTransportURI followed by Play.
//
// Unlike the /speaker play_info path (PlayURL/PlayCustom), this needs no app_key
// and no DNS interception, so it works on a plain LAN. The trade-offs: it
// switches the speaker to the UPNP source and replaces the current playback
// (it does not duck and resume like a notification), and the speaker itself must
// be able to reach mediaURL. The speaker auto-plays on SetAVTransportURI on
// current firmware; the explicit Play afterwards makes it robust regardless of
// the speaker's prior transport state.
func (c *Client) PlayURLViaUPnP(mediaURL string) error {
mediaURL = strings.TrimSpace(mediaURL)
if mediaURL == "" {
return fmt.Errorf("media URL cannot be empty")
}
// The speaker's AVTransport rejects https:// outright ("URI must start with
// http://, qplay:// or Stored Music XML") and then reports a misleading
// "No URI supplied" 402. Fail fast with an actionable message instead.
if strings.HasPrefix(strings.ToLower(mediaURL), "https://") {
return fmt.Errorf("the speaker's UPnP AVTransport only accepts plain http:// URLs, not https:// — host the clip over HTTP, or use a method that proxies it (e.g. the service TTS/radio path): %s", mediaURL)
}
if err := c.SetAVTransportURI(mediaURL); err != nil {
return err
}
return c.AVTransportPlay()
}
// SetAVTransportURI points the speaker's AVTransport at mediaURL (UPnP
// SetAVTransportURI action). Metadata is sent empty, which the speaker accepts.
// Note the speaker only accepts http:// (and qplay:// / Stored Music) URIs, not
// https://; PlayURLViaUPnP guards against that.
func (c *Client) SetAVTransportURI(mediaURL string) error {
body := `<u:SetAVTransportURI xmlns:u="` + avTransportServiceType + `">` +
`<InstanceID>0</InstanceID>` +
`<CurrentURI>` + escapeXMLText(mediaURL) + `</CurrentURI>` +
`<CurrentURIMetaData></CurrentURIMetaData>` +
`</u:SetAVTransportURI>`
return c.soapAVTransport("SetAVTransportURI", body)
}
// AVTransportPlay starts playback (UPnP Play action, Speed 1).
func (c *Client) AVTransportPlay() error {
body := `<u:Play xmlns:u="` + avTransportServiceType + `">` +
`<InstanceID>0</InstanceID><Speed>1</Speed>` +
`</u:Play>`
return c.soapAVTransport("Play", body)
}
// soapAVTransport POSTs a SOAP action to the speaker's AVTransport control URL.
func (c *Client) soapAVTransport(action, innerBody string) error {
controlURL, err := c.avTransportControlURL()
if err != nil {
return err
}
payload := fmt.Sprintf(soapEnvelope, innerBody)
req, err := http.NewRequest(http.MethodPost, controlURL, strings.NewReader(payload))
if err != nil {
return fmt.Errorf("create %s request: %w", action, err)
}
req.Header.Set("Content-Type", `text/xml; charset="utf-8"`)
req.Header.Set("User-Agent", c.userAgent)
// UPnP control points send a SOAPAction header. Write it through the map
// directly to preserve the exact casing the UPnP convention uses (Set would
// canonicalise it to "Soapaction"), mirroring how this codebase preserves
// the speaker-facing ETag header casing.
req.Header["SOAPAction"] = []string{`"` + avTransportServiceType + "#" + action + `"`}
resp, err := c.httpClient.Do(req)
if err != nil {
return fmt.Errorf("execute %s: %w", action, err)
}
defer func() { _ = resp.Body.Close() }()
if resp.StatusCode != http.StatusOK {
b, _ := io.ReadAll(io.LimitReader(resp.Body, 1<<12))
return fmt.Errorf("UPnP %s failed with status %d: %s", action, resp.StatusCode, strings.TrimSpace(string(b)))
}
return nil
}
// avTransportControlURL derives the UPnP AVTransport control URL
// (http://<host>:<UPnPPort>/AVTransport/Control) from the client's base URL,
// which targets the :8090 local API. UPnP control lives on a different port.
func (c *Client) avTransportControlURL() (string, error) {
if c.avTransportURLOverride != "" {
return c.avTransportURLOverride, nil
}
u, err := url.Parse(c.baseURL)
if err != nil {
return "", fmt.Errorf("parse base URL %q: %w", c.baseURL, err)
}
host := u.Hostname()
if host == "" {
return "", fmt.Errorf("no host in base URL %q", c.baseURL)
}
hostPort := net.JoinHostPort(host, strconv.Itoa(speaker.UPnPPort))
return "http://" + hostPort + avTransportControlPath, nil
}
// escapeXMLText XML-escapes a string for safe inclusion as element character
// data (e.g. the media URL inside <CurrentURI>).
func escapeXMLText(s string) string {
var b bytes.Buffer
_ = xml.EscapeText(&b, []byte(s))
return b.String()
}
+122
View File
@@ -0,0 +1,122 @@
package client
import (
"io"
"net/http"
"net/http/httptest"
"strings"
"testing"
)
func TestAVTransportControlURL(t *testing.T) {
tests := []struct {
name string
host string
want string
}{
{name: "host only", host: "192.0.2.10", want: "http://192.0.2.10:8091/AVTransport/Control"},
{name: "host with api port", host: "http://192.0.2.10:8090", want: "http://192.0.2.10:8091/AVTransport/Control"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
c := NewClientFromHost(tt.host)
got, err := c.avTransportControlURL()
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if got != tt.want {
t.Errorf("control URL = %q, want %q", got, tt.want)
}
})
}
}
func TestPlayURLViaUPnPRejectsHTTPS(t *testing.T) {
called := false
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
called = true
w.WriteHeader(http.StatusOK)
}))
defer server.Close()
c := NewClientFromHost("192.0.2.10")
c.avTransportURLOverride = server.URL
// The speaker rejects https:// URIs, so we should fail fast without even
// contacting it, with a message that names the constraint.
err := c.PlayURLViaUPnP("https://example.com/clip.mp3")
if err == nil {
t.Fatal("expected an error for an https:// URL")
}
if !strings.Contains(err.Error(), "http://") {
t.Errorf("error should explain the http:// requirement, got: %v", err)
}
if called {
t.Error("no SOAP request should be sent for an https:// URL")
}
}
func TestPlayURLViaUPnP(t *testing.T) {
type capture struct {
path string
soapAction string
contentType string
body string
}
var calls []capture
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
b, _ := io.ReadAll(r.Body)
calls = append(calls, capture{
path: r.URL.Path,
soapAction: r.Header.Get("SOAPAction"),
contentType: r.Header.Get("Content-Type"),
body: string(b),
})
w.WriteHeader(http.StatusOK)
}))
defer server.Close()
c := NewClientFromHost("192.0.2.10")
c.avTransportURLOverride = server.URL // route SOAP at the test server
mediaURL := "http://192.0.2.99/tts/hello.mp3?a=1&b=2"
if err := c.PlayURLViaUPnP(mediaURL); err != nil {
t.Fatalf("PlayURLViaUPnP: %v", err)
}
// Two SOAP actions in order: SetAVTransportURI then Play.
if len(calls) != 2 {
t.Fatalf("expected 2 SOAP calls, got %d", len(calls))
}
set, play := calls[0], calls[1]
if !strings.Contains(set.soapAction, "AVTransport:1#SetAVTransportURI") {
t.Errorf("first SOAPAction = %q, want SetAVTransportURI", set.soapAction)
}
if !strings.Contains(play.soapAction, "AVTransport:1#Play") {
t.Errorf("second SOAPAction = %q, want Play", play.soapAction)
}
if !strings.HasPrefix(set.contentType, "text/xml") {
t.Errorf("Content-Type = %q, want text/xml", set.contentType)
}
// The media URL must be XML-escaped inside <CurrentURI> (the & becomes &amp;).
if !strings.Contains(set.body, "http://192.0.2.99/tts/hello.mp3?a=1&amp;b=2") {
t.Errorf("SetAVTransportURI body missing escaped media URL, got: %s", set.body)
}
if strings.Contains(set.body, "a=1&b=2") {
t.Errorf("media URL was not XML-escaped in the body: %s", set.body)
}
}
+377 -21
View File
@@ -144,6 +144,7 @@ package client
import (
"bytes"
"encoding/xml"
"errors"
"fmt"
"io"
"net"
@@ -162,8 +163,18 @@ type Client struct {
httpClient *http.Client
timeout time.Duration
userAgent string
// avTransportURLOverride, when set, replaces the UPnP AVTransport control
// URL that is otherwise derived from baseURL (host + speaker.UPnPPort). Used
// only by tests to point the SOAP requests at an httptest server.
avTransportURLOverride string
}
// ErrMutationOutcomeUnknown reports that a state-changing GET may have reached
// the speaker, but its authoritative response could not be verified. Callers
// must read device state back rather than retrying the mutation blindly.
var ErrMutationOutcomeUnknown = errors.New("state-changing GET outcome is unknown")
// Config holds configuration for the SoundTouch client
type Config struct {
Host string
@@ -207,9 +218,9 @@ func NewClient(config *Config) *Client {
return &Client{
baseURL: fmt.Sprintf("http://%s:%d", config.Host, port),
httpClient: &http.Client{
httpClient: installSpeakerTrace(&http.Client{
Timeout: config.Timeout,
},
}),
timeout: config.Timeout,
userAgent: config.UserAgent,
}
@@ -237,9 +248,9 @@ func NewClient(config *Config) *Client {
return &Client{
baseURL: u.String(),
httpClient: &http.Client{
httpClient: installSpeakerTrace(&http.Client{
Timeout: config.Timeout,
},
}),
timeout: config.Timeout,
userAgent: config.UserAgent,
}
@@ -301,6 +312,19 @@ func (c *Client) GetServiceAvailability() (*models.ServiceAvailability, error) {
return &serviceAvailability, nil
}
// ListMediaServers returns the DLNA media servers that the speaker itself has
// discovered on the LAN (via its own UPnP sweep). The response may be empty
// when the speaker has not yet discovered any servers; that is not an error.
func (c *Client) ListMediaServers() (*models.ListMediaServersResponse, error) {
var resp models.ListMediaServersResponse
if err := c.get("/listMediaServers", &resp); err != nil {
return nil, fmt.Errorf("failed to list media servers: %w", err)
}
return &resp, nil
}
// GetName retrieves the device name from the /name endpoint
func (c *Client) GetName() (*models.Name, error) {
var name models.Name
@@ -772,7 +796,14 @@ func (c *Client) DecreaseBalance(amount int) (*models.Balance, error) {
return c.GetBalance()
}
// SelectSource selects an audio source using the /select endpoint
// SelectSource selects an audio source using the /select endpoint.
//
// Only sources that are inputs in their own right can be selected this way:
// AUX, BLUETOOTH, or a streaming service with a real sourceAccount. Provider
// sources (TUNEIN, RADIO_BROWSER, LOCAL_INTERNET_RADIO) and STORED_MUSIC need
// SelectContentItem with a Location instead; a bare select leaves such a
// speaker reporting a source it is not playing. See
// docs/content/docs/reference/PLAYER-SOURCE-BEHAVIOUR.md.
func (c *Client) SelectSource(source, sourceAccount string) error {
// Validate source parameter
if source == "" {
@@ -837,7 +868,15 @@ func (c *Client) SelectAux() error {
return c.SelectSource("AUX", "")
}
// SelectTuneIn is a convenience method to select TuneIn source
// SelectTuneIn is a convenience method to select TuneIn source.
//
// This sends a bare select, with no station. A speaker does not report an
// error for that: it answers 200, parks on a stub now-playing (no playStatus,
// empty type and location, itemName echoing the source name) and carries on
// playing whatever it was playing, then reports that stub indefinitely. To
// actually play something use SelectContentItem with a ContentItem carrying a
// Location, as stations.ResolveContentItem builds. See
// docs/content/docs/reference/PLAYER-SOURCE-BEHAVIOUR.md.
func (c *Client) SelectTuneIn(sourceAccount string) error {
return c.SelectSource("TUNEIN", sourceAccount)
}
@@ -1003,6 +1042,152 @@ func (c *Client) SetClockTimeNow() error {
return c.SetClockTime(request)
}
// GetSystemTimeout retrieves the power-saving setting from /systemtimeout.
func (c *Client) GetSystemTimeout() (*models.SystemTimeout, error) {
var setting models.SystemTimeout
if err := c.get("/systemtimeout", &setting); err != nil {
return nil, fmt.Errorf("failed to get system timeout: %w", err)
}
return &setting, nil
}
// SetSystemTimeout updates /systemtimeout. HTTP success only confirms that the
// request was accepted; callers must read the setting back before reporting it.
func (c *Client) SetSystemTimeout(setting *models.SystemTimeout) error {
if err := setting.Validate(); err != nil {
return fmt.Errorf("invalid system timeout request: %w", err)
}
if err := c.post("/systemtimeout", setting); err != nil {
return fmt.Errorf("failed to set system timeout: %w", err)
}
return nil
}
// GetRebroadcastLatencyMode retrieves /rebroadcastlatencymode.
func (c *Client) GetRebroadcastLatencyMode() (*models.RebroadcastLatencyMode, error) {
var setting models.RebroadcastLatencyMode
if err := c.get("/rebroadcastlatencymode", &setting); err != nil {
return nil, fmt.Errorf("failed to get rebroadcast latency mode: %w", err)
}
return &setting, nil
}
// SetRebroadcastLatencyMode updates /rebroadcastlatencymode. HTTP success only
// confirms request acceptance; callers must read the setting back.
func (c *Client) SetRebroadcastLatencyMode(mode models.RebroadcastLatencyModeValue) error {
request := &models.RebroadcastLatencyModeRequest{Mode: mode}
if err := request.Validate(); err != nil {
return fmt.Errorf("invalid rebroadcast latency mode request: %w", err)
}
if err := c.post("/rebroadcastlatencymode", request); err != nil {
return fmt.Errorf("failed to set rebroadcast latency mode: %w", err)
}
return nil
}
// GetLanguage retrieves the current integer system language from /language.
// Unknown codes are returned unchanged for compatibility with newer firmware.
func (c *Client) GetLanguage() (*models.SystemLanguage, error) {
var language models.SystemLanguage
if err := c.get("/language", &language); err != nil {
return nil, fmt.Errorf("failed to get system language: %w", err)
}
return &language, nil
}
// SetLanguage updates /language. HTTP success only confirms request acceptance;
// callers must read the language back before reporting the change.
func (c *Client) SetLanguage(code models.LanguageCode) error {
request := &models.SystemLanguage{Code: code}
if err := request.Validate(); err != nil {
return fmt.Errorf("invalid system language request: %w", err)
}
if err := c.post("/language", request); err != nil {
return fmt.Errorf("failed to set system language: %w", err)
}
return nil
}
// GetBluetoothInfo retrieves the speaker adapter information from /bluetoothInfo.
func (c *Client) GetBluetoothInfo() (*models.BluetoothInfo, error) {
var info models.BluetoothInfo
if err := c.get("/bluetoothInfo", &info); err != nil {
return nil, fmt.Errorf("failed to get Bluetooth info: %w", err)
}
return &info, nil
}
// RenameSource updates the source display name through /nameSource. HTTP
// success only confirms request acceptance; callers must read sources back.
func (c *Client) RenameSource(source, sourceAccount, itemName string) error {
request := &models.SourceRenameRequest{
Source: source,
SourceAccount: sourceAccount,
ItemName: itemName,
}
if err := request.Validate(); err != nil {
return fmt.Errorf("invalid source rename request: %w", err)
}
if err := c.post("/nameSource", request); err != nil {
return fmt.Errorf("failed to rename source: %w", err)
}
return nil
}
// EnterPairingMode requests the firmware's legacy general pairing mode through
// its state-changing GET endpoint.
func (c *Client) EnterPairingMode() error {
if err := c.mutatingGetConfirmStatus("/enterPairingMode"); err != nil {
return fmt.Errorf("failed to enter pairing mode: %w", err)
}
return nil
}
// EnterBluetoothPairing requests Bluetooth discoverable mode through the
// Bluetooth-specific state-changing GET endpoint. Callers must verify
// discoverability through a subsequent now-playing read.
func (c *Client) EnterBluetoothPairing() error {
if err := c.mutatingGetConfirmStatus("/enterBluetoothPairing"); err != nil {
return fmt.Errorf("failed to enter Bluetooth pairing mode: %w", err)
}
return nil
}
// ClearPairedList requests the firmware's legacy general paired-list clearing
// through its state-changing GET endpoint.
func (c *Client) ClearPairedList() error {
if err := c.mutatingGetConfirmStatus("/clearPairedList"); err != nil {
return fmt.Errorf("failed to clear paired list: %w", err)
}
return nil
}
// ClearBluetoothPaired requests removal of Bluetooth pairings through the
// Bluetooth-specific state-changing GET endpoint. The firmware exposes no
// paired-list readback, so HTTP success alone does not verify physical state.
func (c *Client) ClearBluetoothPaired() error {
if err := c.mutatingGetConfirmStatus("/clearBluetoothPaired"); err != nil {
return fmt.Errorf("failed to clear Bluetooth paired devices: %w", err)
}
return nil
}
// GetClockDisplay retrieves clock display settings from the /clockDisplay endpoint
func (c *Client) GetClockDisplay() (*models.ClockDisplay, error) {
var clockDisplay models.ClockDisplay
@@ -1087,6 +1272,10 @@ func (c *Client) Host() string {
// get performs a GET request and unmarshals the XML response
func (c *Client) get(endpoint string, result interface{}) error {
return c.getWithHTTPClient(c.httpClient, endpoint, result)
}
func (c *Client) getWithHTTPClient(httpClient *http.Client, endpoint string, result interface{}) error {
url := c.baseURL + endpoint
req, err := http.NewRequest("GET", url, nil)
@@ -1097,7 +1286,7 @@ func (c *Client) get(endpoint string, result interface{}) error {
req.Header.Set("User-Agent", c.userAgent)
req.Header.Set("Accept", "application/xml")
resp, err := c.httpClient.Do(req)
resp, err := httpClient.Do(req)
if err != nil {
return fmt.Errorf("failed to execute request: %w", err)
}
@@ -1133,6 +1322,148 @@ func (c *Client) get(endpoint string, result interface{}) error {
return nil
}
// newOneShotHTTPClient clones the client's transport with keep-alives
// disabled, so a firmware-required state-changing GET runs exactly once at
// the HTTP transport layer instead of risking an automatic replay by
// net/http after an ambiguous failure on a reused connection. The caller
// owns the returned transport's lifetime and must close its idle
// connections once done (defer oneShotTransport.CloseIdleConnections()).
func (c *Client) newOneShotHTTPClient() (client *http.Client, oneShotTransport *http.Transport, err error) {
baseTransport := c.httpClient.Transport
if baseTransport == nil {
baseTransport = http.DefaultTransport
}
transport, ok := baseTransport.(*http.Transport)
if !ok {
return nil, nil, errors.New("state-changing GET requires a cloneable HTTP transport")
}
oneShotTransport = transport.Clone()
oneShotTransport.DisableKeepAlives = true
return &http.Client{
Transport: oneShotTransport,
Timeout: c.httpClient.Timeout,
CheckRedirect: func(_ *http.Request, _ []*http.Request) error {
return http.ErrUseLastResponse
},
}, oneShotTransport, nil
}
// mutatingGet performs a firmware-required state-changing GET exactly once at
// the HTTP transport layer, unmarshaling the response into result. A fresh
// connection prevents net/http from automatically replaying the request
// after an ambiguous failure on a reused connection.
func (c *Client) mutatingGet(endpoint string, result interface{}) error {
oneShotClient, oneShotTransport, err := c.newOneShotHTTPClient()
if err != nil {
return err
}
defer oneShotTransport.CloseIdleConnections()
return c.getWithHTTPClient(oneShotClient, endpoint, result)
}
// mutatingGetConfirmStatus performs the same one-shot, firmware-required
// state-changing GET as mutatingGet, for endpoints that return no meaningful
// body to unmarshal -- confirmation instead comes from the device echoing
// back <status>{endpoint}</status>.
func (c *Client) mutatingGetConfirmStatus(endpoint string) error {
oneShotClient, oneShotTransport, err := c.newOneShotHTTPClient()
if err != nil {
return err
}
defer oneShotTransport.CloseIdleConnections()
req, err := http.NewRequest(http.MethodGet, c.baseURL+endpoint, nil)
if err != nil {
return fmt.Errorf("failed to create request: %w", err)
}
req.Header.Set("User-Agent", c.userAgent)
req.Header.Set("Accept", "application/xml")
resp, err := oneShotClient.Do(req)
if err != nil {
return fmt.Errorf("%w: failed to execute request: %w", ErrMutationOutcomeUnknown, err)
}
defer func() { _ = resp.Body.Close() }()
body, err := io.ReadAll(resp.Body)
if err != nil {
return fmt.Errorf("%w: failed to read response: %w", ErrMutationOutcomeUnknown, err)
}
if resp.StatusCode != http.StatusOK {
if apiErr := mutationAPIError(body); apiErr != nil {
return apiErr
}
return fmt.Errorf(
"%w: API request failed with status %d: %s",
ErrMutationOutcomeUnknown,
resp.StatusCode,
string(body),
)
}
if apiErr := mutationAPIError(body); apiErr != nil {
return apiErr
}
return validateMutationStatus(body, endpoint)
}
func mutationAPIError(body []byte) error {
switch mutationResponseRoot(body) {
case "errors":
var errs models.ErrorsResponse
if err := xml.Unmarshal(body, &errs); err == nil && len(errs.Errors) > 0 {
return &errs
}
case "error":
var apiError models.APIError
if err := xml.Unmarshal(body, &apiError); err == nil && apiError.Message != "" {
return &apiError
}
}
return nil
}
func mutationResponseRoot(body []byte) string {
var root struct {
XMLName xml.Name
}
if xml.Unmarshal(body, &root) != nil {
return ""
}
return root.XMLName.Local
}
func validateMutationStatus(body []byte, endpoint string) error {
var status struct {
XMLName xml.Name `xml:"status"`
Value string `xml:",chardata"`
}
if err := xml.Unmarshal(body, &status); err != nil {
return fmt.Errorf("%w: malformed XML response: %w", ErrMutationOutcomeUnknown, err)
}
if strings.TrimSpace(status.Value) != endpoint {
return fmt.Errorf(
"%w: expected <status>%s</status>, got %s",
ErrMutationOutcomeUnknown,
endpoint,
strings.TrimSpace(string(body)),
)
}
return nil
}
// post performs a POST request with XML body
func (c *Client) post(endpoint string, payload interface{}) error {
url := c.baseURL + endpoint
@@ -1325,7 +1656,13 @@ func (c *Client) AddToZone(deviceID, ipAddress string) error {
return c.SetZone(zoneRequest)
}
// RemoveFromZone removes a device from the current zone
// RemoveFromZone removes a device from the current zone.
//
// It uses the dedicated /removeZoneSlave endpoint rather than rebuilding the
// zone with /setZone and the remaining members: /setZone does not drop a member
// from a multi-member zone (the speaker only goes standalone when the resulting
// member set is empty), so a setZone rebuild silently fails to remove one of
// several members. See #511.
func (c *Client) RemoveFromZone(deviceID string) error {
// Get current zone configuration
currentZone, err := c.GetZone()
@@ -1333,11 +1670,21 @@ func (c *Client) RemoveFromZone(deviceID string) error {
return fmt.Errorf("failed to get current zone: %w", err)
}
// Convert to zone request and remove member
zoneRequest := currentZone.ToZoneRequest()
zoneRequest.RemoveMember(deviceID)
if currentZone.IsStandalone() {
return nil // nothing to remove
}
return c.SetZone(zoneRequest)
// Carry the member's IP (as the speaker expects) when we know it.
slaveIP := ""
for i := range currentZone.Members {
if currentZone.Members[i].DeviceID == deviceID {
slaveIP = currentZone.Members[i].IP
break
}
}
return c.RemoveZoneSlave(currentZone.Master, deviceID, slaveIP)
}
// DissolveZone dissolves the current zone, making all devices standalone
@@ -1394,10 +1741,18 @@ func (c *Client) GetZoneMembers() ([]string, error) {
// An empty <group/> response is reported as a zero-value Group; callers can
// distinguish with (*Group).IsEmpty().
//
// ST-10 is the only product that supports stereo pairs; on other devices
// the call is harmless but will always return an empty group. The endpoint
// is named /getGroup on the device (mirroring /getZone), even though some
// third-party wikis document it as plain /group.
// ST-10 is the only product that supports stereo pairs. Verified against
// real hardware: a SoundTouch 20 does not reply to /getGroup promptly. The
// device's own firmware ("AllegroWebserver") eventually answers with a
// plain-text "AllegroWebserver timeout: /getGroup" error body after an
// internal delay exceeding several seconds, but well within the client's
// own timeout (30s by default, see DefaultConfig) the request just looks
// like it never replied at all. Callers on a poll cycle must gate this call
// behind a stereo-pair-capable model check (see stereoPairCapable in
// pkg/service/soundtouchweb) instead of relying on a fast, harmless
// response on unsupported models. The endpoint is named /getGroup on the
// device (mirroring /getZone), even though some third-party wikis document
// it as plain /group.
func (c *Client) GetGroup() (*models.Group, error) {
var g models.Group
@@ -1406,10 +1761,11 @@ func (c *Client) GetGroup() (*models.Group, error) {
return &g, err
}
// AddGroup creates a new stereo pair on the device addressed by this client,
// which becomes the master. The supplied group must contain both LEFT and
// RIGHT roles; the device assigns the group ID and echoes the full state
// in the response.
// AddGroup applies one side of stereo-pair creation to the addressed device.
// The supplied group must contain both LEFT and RIGHT roles. A master-bound
// request omits SenderIPAddress; a slave-bound request sets it to the master's
// IP address. Firmware may acknowledge the request without returning the
// assigned group ID, so callers must verify the resulting state with GetGroup.
func (c *Client) AddGroup(group *models.Group) (*models.Group, error) {
var result models.Group
if err := c.postWithResponse("/addGroup", group, &result); err != nil {
@@ -1439,7 +1795,7 @@ func (c *Client) UpdateGroup(group *models.Group) (*models.Group, error) {
func (c *Client) RemoveGroup() error {
var g models.Group
return c.get("/removeGroup", &g)
return c.mutatingGet("/removeGroup", &g)
}
// SetName sets the device name
+3 -2
View File
@@ -59,8 +59,9 @@ func TestClient_Post_ErrorsResponse(t *testing.T) {
t.Errorf("expected message '%s', got '%s'", expectedMsg, errs.Errors[0].Message)
}
if err.Error() != expectedMsg {
t.Errorf("expected Error() to return '%s', got '%s'", expectedMsg, err.Error())
expectedErr := "UNKNOWN_ACTION_ERROR: " + expectedMsg
if err.Error() != expectedErr {
t.Errorf("expected Error() to return '%s', got '%s'", expectedErr, err.Error())
}
}
+72
View File
@@ -6,6 +6,7 @@ import (
"net/http"
"net/http/httptest"
"strings"
"sync/atomic"
"testing"
"github.com/gesellix/bose-soundtouch/pkg/models"
@@ -232,3 +233,74 @@ func TestClient_RemoveGroup(t *testing.T) {
t.Fatalf("RemoveGroup: %v", err)
}
}
func TestClientRemoveGroupDoesNotReplayDroppedResponse(t *testing.T) {
var calls atomic.Int32
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path == "/getGroup" {
_, _ = w.Write([]byte(`<group />`))
return
}
if r.URL.Path != "/removeGroup" {
http.NotFound(w, r)
return
}
calls.Add(1)
connection, _, err := w.(http.Hijacker).Hijack()
if err != nil {
t.Errorf("hijack response: %v", err)
return
}
_ = connection.Close()
}))
defer server.Close()
client := createTestClient(server.URL)
if _, err := client.GetGroup(); err != nil {
t.Fatalf("prime ordinary client connection: %v", err)
}
err := client.RemoveGroup()
if err == nil {
t.Fatal("RemoveGroup succeeded after the response was dropped")
}
if got := calls.Load(); got != 1 {
t.Fatalf("/removeGroup requests = %d, want exactly 1", got)
}
}
func TestClientRemoveGroupDoesNotFollowRedirect(t *testing.T) {
var calls atomic.Int32
var redirectedCalls atomic.Int32
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
calls.Add(1)
switch r.URL.Path {
case "/removeGroup":
http.Redirect(w, r, "/redirected", http.StatusTemporaryRedirect)
case "/redirected":
redirectedCalls.Add(1)
_, _ = w.Write([]byte(`<group />`))
default:
http.NotFound(w, r)
}
}))
defer server.Close()
err := createTestClient(server.URL).RemoveGroup()
if err == nil {
t.Fatal("RemoveGroup followed a redirect")
}
if !strings.Contains(err.Error(), "status 307") {
t.Fatalf("RemoveGroup error = %q, want redirect status", err)
}
if got := calls.Load(); got != 1 {
t.Fatalf("HTTP requests = %d, want exactly 1", got)
}
if got := redirectedCalls.Load(); got != 0 {
t.Fatalf("redirect target requests = %d, want 0", got)
}
}
+368
View File
@@ -0,0 +1,368 @@
package client
import (
"errors"
"io"
"net/http"
"net/http/httptest"
"strings"
"testing"
"github.com/gesellix/bose-soundtouch/pkg/models"
)
func TestClientSystemSettingsGETs(t *testing.T) {
tests := []struct {
name string
path string
response string
call func(*Client) error
}{
{
name: "system timeout",
path: "/systemtimeout",
response: `<systemtimeout><powersaving_enabled>true</powersaving_enabled></systemtimeout>`,
call: func(client *Client) error {
setting, err := client.GetSystemTimeout()
if err == nil && !setting.PowerSavingEnabled {
t.Error("PowerSavingEnabled = false, want true")
}
return err
},
},
{
name: "rebroadcast latency",
path: "/rebroadcastlatencymode",
response: `<rebroadcastlatencymode mode="SYNC_TO_ZONE" controllable="true"/>`,
call: func(client *Client) error {
setting, err := client.GetRebroadcastLatencyMode()
if err == nil && (setting.Mode != models.RebroadcastLatencySyncToZone || !setting.Controllable) {
t.Errorf("setting = %#v", setting)
}
return err
},
},
{
name: "known language",
path: "/language",
response: `<sysLanguage>15</sysLanguage>`,
call: func(client *Client) error {
language, err := client.GetLanguage()
if err == nil && language.Code != models.LanguageCzech {
t.Errorf("Code = %d, want %d", language.Code, models.LanguageCzech)
}
return err
},
},
{
name: "unknown language remains readable",
path: "/language",
response: `<sysLanguage>99</sysLanguage>`,
call: func(client *Client) error {
language, err := client.GetLanguage()
if err == nil && language.Code != 99 {
t.Errorf("Code = %d, want 99", language.Code)
}
return err
},
},
{
name: "Bluetooth info",
path: "/bluetoothInfo",
response: `<BluetoothInfo BluetoothMACAddress="AABBCCDDEEFF"/>`,
call: func(client *Client) error {
info, err := client.GetBluetoothInfo()
if err == nil && info.BluetoothMACAddress != "AABBCCDDEEFF" {
t.Errorf("BluetoothMACAddress = %q", info.BluetoothMACAddress)
}
return err
},
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
t.Errorf("method = %s, want GET", r.Method)
}
if r.URL.Path != test.path {
t.Errorf("path = %s, want %s", r.URL.Path, test.path)
}
if got := r.Header.Get("Accept"); got != "application/xml" {
t.Errorf("Accept = %q", got)
}
if got := r.Header.Get("User-Agent"); got != "Bose-SoundTouch-Go-Client/1.0" {
t.Errorf("User-Agent = %q", got)
}
_, _ = io.WriteString(w, test.response)
}))
defer server.Close()
if err := test.call(createTestClient(server.URL)); err != nil {
t.Fatalf("call(): %v", err)
}
})
}
}
func TestClientSystemSettingsGETErrors(t *testing.T) {
tests := []struct {
name string
status int
body string
call func(*Client) error
}{
{
name: "malformed system timeout XML",
body: `<systemtimeout><powersaving_enabled>`,
call: func(client *Client) error { _, err := client.GetSystemTimeout(); return err },
},
{
name: "incomplete Bluetooth XML",
body: `<BluetoothInfo/>`,
call: func(client *Client) error { _, err := client.GetBluetoothInfo(); return err },
},
{
name: "language non-200",
status: http.StatusNotFound,
body: `unsupported`,
call: func(client *Client) error { _, err := client.GetLanguage(); return err },
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
status := test.status
if status == 0 {
status = http.StatusOK
}
w.WriteHeader(status)
_, _ = io.WriteString(w, test.body)
}))
defer server.Close()
if err := test.call(createTestClient(server.URL)); err == nil {
t.Fatal("call() unexpectedly succeeded")
}
})
}
}
func TestClientSystemSettingsPOSTs(t *testing.T) {
tests := []struct {
name string
path string
body string
call func(*Client) error
}{
{
name: "system timeout",
path: "/systemtimeout",
body: `<systemtimeout><powersaving_enabled>false</powersaving_enabled></systemtimeout>`,
call: func(client *Client) error {
return client.SetSystemTimeout(&models.SystemTimeout{PowerSavingEnabled: false})
},
},
{
name: "rebroadcast latency",
path: "/rebroadcastlatencymode",
body: `<rebroadcastlatencymode mode="SYNC_TO_ROOM"></rebroadcastlatencymode>`,
call: func(client *Client) error {
return client.SetRebroadcastLatencyMode(models.RebroadcastLatencySyncToRoom)
},
},
{
name: "language",
path: "/language",
body: `<sysLanguage>3</sysLanguage>`,
call: func(client *Client) error { return client.SetLanguage(models.LanguageEnglish) },
},
{
name: "source rename with account",
path: "/nameSource",
body: `<ContentItem source="AUX" sourceAccount="AUX1"><itemName>Turntable</itemName></ContentItem>`,
call: func(client *Client) error { return client.RenameSource("AUX", "AUX1", "Turntable") },
},
{
name: "source rename without account",
path: "/nameSource",
body: `<ContentItem source="BLUETOOTH"><itemName>Phone</itemName></ContentItem>`,
call: func(client *Client) error { return client.RenameSource("BLUETOOTH", "", "Phone") },
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
t.Errorf("method = %s, want POST", r.Method)
}
if r.URL.Path != test.path {
t.Errorf("path = %s, want %s", r.URL.Path, test.path)
}
if got := r.Header.Get("Content-Type"); got != "application/xml" {
t.Errorf("Content-Type = %q", got)
}
if got := r.Header.Get("Accept"); got != "application/xml" {
t.Errorf("Accept = %q", got)
}
if got := r.Header.Get("User-Agent"); got != "Bose-SoundTouch-Go-Client/1.0" {
t.Errorf("User-Agent = %q", got)
}
body, err := io.ReadAll(r.Body)
if err != nil {
t.Errorf("ReadAll(): %v", err)
}
if string(body) != test.body {
t.Errorf("body = %q, want %q", body, test.body)
}
}))
defer server.Close()
if err := test.call(createTestClient(server.URL)); err != nil {
t.Fatalf("call(): %v", err)
}
})
}
}
func TestClientSystemSettingsPOSTValidation(t *testing.T) {
client := createTestClient("http://127.0.0.1:1")
for _, test := range []struct {
name string
call func() error
}{
{"nil timeout", func() error { return client.SetSystemTimeout(nil) }},
{"unknown latency", func() error { return client.SetRebroadcastLatencyMode("OTHER") }},
{"unknown language", func() error { return client.SetLanguage(99) }},
{"missing source", func() error { return client.RenameSource("", "", "Name") }},
{"missing item name", func() error { return client.RenameSource("AUX", "AUX1", "") }},
} {
t.Run(test.name, func(t *testing.T) {
if err := test.call(); err == nil {
t.Fatal("call() unexpectedly succeeded")
}
})
}
}
func TestClientSystemSettingsPOSTNon200(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
http.Error(w, "rejected", http.StatusBadRequest)
}))
defer server.Close()
err := createTestClient(server.URL).SetLanguage(models.LanguageEnglish)
if err == nil || !strings.Contains(err.Error(), "400") {
t.Fatalf("SetLanguage() error = %v, want status 400", err)
}
}
func TestClientBluetoothMutatingGETs(t *testing.T) {
for _, test := range []struct {
name string
path string
call func(*Client) error
}{
{"enter pairing mode", "/enterPairingMode", func(client *Client) error { return client.EnterPairingMode() }},
{"clear paired list", "/clearPairedList", func(client *Client) error { return client.ClearPairedList() }},
{"enter Bluetooth pairing", "/enterBluetoothPairing", func(client *Client) error { return client.EnterBluetoothPairing() }},
{"clear Bluetooth paired", "/clearBluetoothPaired", func(client *Client) error { return client.ClearBluetoothPaired() }},
} {
t.Run(test.name, func(t *testing.T) {
requests := 0
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
requests++
if r.Method != http.MethodGet {
t.Errorf("method = %s, want GET", r.Method)
}
if r.URL.Path != test.path {
t.Errorf("path = %s, want %s", r.URL.Path, test.path)
}
if got := r.Header.Get("Accept"); got != "application/xml" {
t.Errorf("Accept = %q", got)
}
if got := r.Header.Get("User-Agent"); got != "Bose-SoundTouch-Go-Client/1.0" {
t.Errorf("User-Agent = %q", got)
}
_, _ = io.WriteString(w, `<status>`+test.path+`</status>`)
}))
defer server.Close()
if err := test.call(createTestClient(server.URL)); err != nil {
t.Fatalf("call(): %v", err)
}
if requests != 1 {
t.Fatalf("requests = %d, want 1", requests)
}
})
}
}
func TestClientMutatingGETDoesNotFollowRedirect(t *testing.T) {
requests := 0
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
requests++
if r.URL.Path == "/enterBluetoothPairing" {
http.Redirect(w, r, "/replayed", http.StatusTemporaryRedirect)
return
}
_, _ = io.WriteString(w, `<status>replayed</status>`)
}))
defer server.Close()
err := createTestClient(server.URL).EnterBluetoothPairing()
if err == nil || !strings.Contains(err.Error(), "307") {
t.Fatalf("EnterBluetoothPairing() error = %v, want status 307", err)
}
if requests != 1 {
t.Fatalf("requests = %d, want 1", requests)
}
}
func TestClientMutatingGETRejectsErrorEnvelopeWithHTTP200(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
_, _ = io.WriteString(w, `<errors deviceID="AABBCCDDEEFF"><error value="1029" name="UNKNOWN_ACTION_ERROR">rejected</error></errors>`)
}))
defer server.Close()
err := createTestClient(server.URL).EnterBluetoothPairing()
var errs *models.ErrorsResponse
if !errors.As(err, &errs) {
t.Fatalf("EnterBluetoothPairing() error = %T %v, want ErrorsResponse", err, err)
}
}
func TestClientMutatingGETMarksUnstructuredHTTPFailureUnknown(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
http.Error(w, "internal failure", http.StatusInternalServerError)
}))
defer server.Close()
err := createTestClient(server.URL).EnterBluetoothPairing()
if !errors.Is(err, ErrMutationOutcomeUnknown) {
t.Fatalf("EnterBluetoothPairing() error = %v, want ErrMutationOutcomeUnknown", err)
}
if !strings.Contains(err.Error(), "status 500") {
t.Fatalf("EnterBluetoothPairing() error = %v, want status 500", err)
}
}
func TestClientMutatingGETMarksLostResponseUnknown(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
connection, _, err := w.(http.Hijacker).Hijack()
if err != nil {
t.Errorf("Hijack(): %v", err)
return
}
_ = connection.Close()
}))
defer server.Close()
err := createTestClient(server.URL).EnterBluetoothPairing()
if !errors.Is(err, ErrMutationOutcomeUnknown) {
t.Fatalf("EnterBluetoothPairing() error = %v, want ErrMutationOutcomeUnknown", err)
}
}
+69
View File
@@ -0,0 +1,69 @@
package client
// TEMPORARY INSTRUMENTATION -- do not merge.
//
// Added to measure the source-selection readback cost described in PR #670
// review finding 2 (three readbacks per source click, each running a full
// UpdateDeviceStatus against the speaker). Delete this file, and the
// traceTransport wiring in NewClient, once the measurement is done.
//
// Off unless AFTERTOUCH_TRACE_SPEAKER=1, so a stray build stays silent.
import (
"fmt"
"log"
"net/http"
"os"
"strings"
"sync/atomic"
"time"
)
var (
traceSpeakerEnabled = os.Getenv("AFTERTOUCH_TRACE_SPEAKER") == "1"
traceSpeakerSeq atomic.Int64
traceStart = time.Now()
)
// traceTransport logs every outgoing speaker request with a sequence number,
// the elapsed time since process start, and the round-trip duration.
type traceTransport struct{ base http.RoundTripper }
func (t *traceTransport) RoundTrip(req *http.Request) (*http.Response, error) {
base := t.base
if base == nil {
base = http.DefaultTransport
}
seq := traceSpeakerSeq.Add(1)
started := time.Now()
resp, err := base.RoundTrip(req)
elapsed := time.Since(started)
status := "ERR"
if resp != nil {
status = fmt.Sprintf("%d", resp.StatusCode)
}
detail := ""
if err != nil {
detail = " err=" + strings.ReplaceAll(err.Error(), "\n", " ")
}
log.Printf("[SPEAKER-TRACE] #%04d t=%8.3fs %-4s %-28s host=%-22s status=%-3s took=%6.1fms%s",
seq, time.Since(traceStart).Seconds(), req.Method, req.URL.Path,
req.URL.Host, status, float64(elapsed.Microseconds())/1000, detail)
return resp, err
}
// installSpeakerTrace wraps an http.Client's transport when tracing is on.
func installSpeakerTrace(c *http.Client) *http.Client {
if !traceSpeakerEnabled || c == nil {
return c
}
c.Transport = &traceTransport{base: c.Transport}
return c
}
+257 -71
View File
@@ -5,6 +5,7 @@ import (
"encoding/xml"
"fmt"
"log"
"net/http"
"net/url"
"strings"
"sync"
@@ -16,17 +17,33 @@ import (
// WebSocketClient handles WebSocket connections to SoundTouch devices
type WebSocketClient struct {
client *Client
conn *websocket.Conn
handlers *models.WebSocketEventHandlers
mu sync.RWMutex
writeMu sync.Mutex // serializes all writes; gorilla/websocket allows one concurrent writer
connected bool
reconnect bool
ctx context.Context
cancel context.CancelFunc
logger Logger
bufferSize int
client *Client
conn *websocket.Conn
connection *webSocketConnection
handlers *models.WebSocketEventHandlers
mu sync.RWMutex
connectMu sync.Mutex // serializes dial attempts without blocking shutdown
writeMu sync.Mutex // serializes all writes; gorilla/websocket allows one concurrent writer
connected bool
reconnect bool
ctx context.Context
cancel context.CancelFunc
logger Logger
bufferSize int
dialContext webSocketDialContext
transportHandler func(connected bool, generation uint64)
transportGeneration uint64
}
type webSocketDialContext func(context.Context, string, http.Header) (*websocket.Conn, *http.Response, error)
// webSocketConnection gives each transport generation its own lifecycle so an
// old read or ping loop cannot start using a replacement connection.
type webSocketConnection struct {
conn *websocket.Conn
ctx context.Context
cancel context.CancelFunc
}
// Logger interface for WebSocket logging
@@ -159,6 +176,23 @@ func (ws *WebSocketClient) OnBassUpdated(handler models.TypedEventHandler[*model
ws.handlers.OnBassUpdated = handler
}
// OnNameUpdated sets a handler for device name update events.
func (ws *WebSocketClient) OnNameUpdated(handler models.TypedEventHandler[*models.NameUpdatedEvent]) {
ws.mu.Lock()
defer ws.mu.Unlock()
ws.handlers.OnNameUpdated = handler
}
// OnTransportState observes authoritative connection transitions. Generation
// numbers let consumers reject callbacks that arrive out of order.
func (ws *WebSocketClient) OnTransportState(handler func(connected bool, generation uint64)) {
ws.mu.Lock()
defer ws.mu.Unlock()
ws.transportHandler = handler
}
// OnUnknownEvent sets a handler for unknown events
func (ws *WebSocketClient) OnUnknownEvent(handler models.EventHandler) {
ws.mu.Lock()
@@ -198,13 +232,24 @@ func (ws *WebSocketClient) ConnectWithConfig(config *WebSocketConfig) error {
}
func (ws *WebSocketClient) connectWithConfig(config *WebSocketConfig) error {
ws.mu.Lock()
defer ws.mu.Unlock()
ws.connectMu.Lock()
defer ws.connectMu.Unlock()
ws.mu.RLock()
if ws.connected {
ws.mu.RUnlock()
return fmt.Errorf("already connected")
}
ctx := ws.ctx
dialContext := ws.dialContext
ws.mu.RUnlock()
if err := ctx.Err(); err != nil {
return fmt.Errorf("WebSocket client is closed: %w", err)
}
// Build WebSocket URL
// Parse the base URL to extract just the hostname
baseURL, err := url.Parse(ws.client.BaseURL())
@@ -220,16 +265,20 @@ func (ws *WebSocketClient) connectWithConfig(config *WebSocketConfig) error {
ws.logger.Printf("Connecting to %s", sanitizeLog(wsURL.String()))
// Create dialer with custom buffer sizes and "gabbo" protocol
dialer := websocket.Dialer{
HandshakeTimeout: 10 * time.Second,
ReadBufferSize: config.ReadBufferSize,
WriteBufferSize: config.WriteBufferSize,
Subprotocols: []string{"gabbo"}, // Required by SoundTouch API
if dialContext == nil {
// Create dialer with custom buffer sizes and "gabbo" protocol.
dialer := websocket.Dialer{
HandshakeTimeout: 10 * time.Second,
ReadBufferSize: config.ReadBufferSize,
WriteBufferSize: config.WriteBufferSize,
Subprotocols: []string{"gabbo"}, // Required by SoundTouch API
}
dialContext = dialer.DialContext
}
// Establish connection
conn, resp, err := dialer.DialContext(ws.ctx, wsURL.String(), nil)
// Dial without holding the state mutex so shutdown can cancel the context
// immediately instead of waiting for the handshake timeout.
conn, resp, err := dialContext(ctx, wsURL.String(), nil)
if resp != nil && resp.Body != nil {
defer func() { _ = resp.Body.Close() }()
}
@@ -238,8 +287,55 @@ func (ws *WebSocketClient) connectWithConfig(config *WebSocketConfig) error {
return fmt.Errorf("failed to connect to WebSocket: %w", err)
}
ws.mu.Lock()
if err := ctx.Err(); err != nil {
ws.mu.Unlock()
_ = conn.Close()
return fmt.Errorf("WebSocket client closed during connect: %w", err)
}
if ws.connected {
ws.mu.Unlock()
_ = conn.Close()
return fmt.Errorf("already connected")
}
connection, transportHandler, transportGeneration := ws.activateConnectionLocked(conn)
ws.mu.Unlock()
notifyTransportState(transportHandler, true, transportGeneration)
go ws.readLoop(config, connection)
go ws.pingLoop(config, connection)
ws.logger.Printf("Connected to %s", sanitizeLog(wsURL.String()))
return nil
}
func (ws *WebSocketClient) activateConnectionLocked(
conn *websocket.Conn,
) (*webSocketConnection, func(bool, uint64), uint64) {
if ws.connection != nil {
ws.connection.cancel()
_ = ws.connection.conn.Close()
}
connectionCtx, connectionCancel := context.WithCancel(ws.ctx)
connection := &webSocketConnection{
conn: conn,
ctx: connectionCtx,
cancel: connectionCancel,
}
ws.conn = conn
ws.connection = connection
ws.connected = true
ws.transportGeneration++
// Extend the read deadline on every pong so the connection survives
// quiet periods between speaker events. Without this, the 60-second
@@ -250,41 +346,100 @@ func (ws *WebSocketClient) connectWithConfig(config *WebSocketConfig) error {
return nil
})
// Start background goroutines for connection management
go ws.readLoop(config)
go ws.pingLoop(config)
ws.logger.Printf("Connected to %s", sanitizeLog(wsURL.String()))
return nil
return connection, ws.transportHandler, ws.transportGeneration
}
// Disconnect closes the WebSocket connection
func (ws *WebSocketClient) Disconnect() error {
ws.mu.Lock()
defer ws.mu.Unlock()
// Cancel first so an in-progress DialContext wakes without waiting for mu.
ws.cancel()
if !ws.connected {
return fmt.Errorf("not connected")
}
ws.mu.Lock()
wasConnected := ws.connected
ws.reconnect = false
ws.cancel() // Cancel context to stop goroutines
if ws.connection != nil {
ws.connection.cancel()
ws.connection = nil
}
conn := ws.conn
ws.conn = nil
ws.connected = false
var (
transportHandler func(bool, uint64)
transportGeneration uint64
)
if wasConnected {
ws.transportGeneration++
transportHandler = ws.transportHandler
transportGeneration = ws.transportGeneration
}
ws.mu.Unlock()
if conn != nil {
err := conn.Close()
if ws.conn != nil {
err := ws.conn.Close()
ws.conn = nil
ws.connected = false
ws.logger.Printf("Disconnected")
notifyTransportState(transportHandler, false, transportGeneration)
return err
}
ws.connected = false
notifyTransportState(transportHandler, false, transportGeneration)
if !wasConnected {
return fmt.Errorf("not connected")
}
return nil
}
// Close permanently stops this client and is idempotent. Device registries use
// it when removal races an initial dial or an automatic reconnect.
func (ws *WebSocketClient) Close() error {
ws.cancel()
ws.mu.Lock()
wasConnected := ws.connected
ws.reconnect = false
if ws.connection != nil {
ws.connection.cancel()
ws.connection = nil
}
conn := ws.conn
ws.conn = nil
ws.connected = false
var (
transportHandler func(bool, uint64)
transportGeneration uint64
)
if wasConnected {
ws.transportGeneration++
transportHandler = ws.transportHandler
transportGeneration = ws.transportGeneration
}
ws.mu.Unlock()
if conn == nil {
notifyTransportState(transportHandler, false, transportGeneration)
return nil
}
err := conn.Close()
ws.logger.Printf("Disconnected")
notifyTransportState(transportHandler, false, transportGeneration)
return err
}
// IsConnected returns true if the WebSocket is connected
func (ws *WebSocketClient) IsConnected() bool {
ws.mu.RLock()
@@ -293,45 +448,63 @@ func (ws *WebSocketClient) IsConnected() bool {
return ws.connected
}
func (ws *WebSocketClient) shouldReconnect() bool {
ws.mu.RLock()
defer ws.mu.RUnlock()
return ws.reconnect
}
func notifyTransportState(handler func(bool, uint64), connected bool, generation uint64) {
if handler != nil {
handler(connected, generation)
}
}
// readLoop continuously reads messages from the WebSocket connection
func (ws *WebSocketClient) readLoop(config *WebSocketConfig) {
func (ws *WebSocketClient) readLoop(config *WebSocketConfig, connection *webSocketConnection) {
defer func() {
ws.mu.Lock()
if ws.connection != connection {
ws.mu.Unlock()
connection.cancel()
_ = connection.conn.Close()
ws.connected = false
if ws.conn != nil {
_ = ws.conn.Close()
ws.conn = nil
return
}
connection.cancel()
ws.connection = nil
ws.conn = nil
ws.connected = false
ws.transportGeneration++
transportHandler := ws.transportHandler
transportGeneration := ws.transportGeneration
reconnect := ws.reconnect
ws.mu.Unlock()
_ = connection.conn.Close()
notifyTransportState(transportHandler, false, transportGeneration)
// Attempt reconnection if enabled
if ws.reconnect {
if reconnect {
go ws.attemptReconnect(config)
}
}()
for {
select {
case <-ws.ctx.Done():
case <-connection.ctx.Done():
return
default:
}
ws.mu.RLock()
conn := ws.conn
ws.mu.RUnlock()
if conn == nil {
return
}
// Set read deadline
_ = conn.SetReadDeadline(time.Now().Add(60 * time.Second))
_ = connection.conn.SetReadDeadline(time.Now().Add(60 * time.Second))
// Read message
messageType, data, err := conn.ReadMessage()
messageType, data, err := connection.conn.ReadMessage()
if err != nil {
if websocket.IsUnexpectedCloseError(err, websocket.CloseGoingAway, websocket.CloseAbnormalClosure) {
ws.logger.Printf("WebSocket read error: %v", err)
@@ -351,30 +524,20 @@ func (ws *WebSocketClient) readLoop(config *WebSocketConfig) {
}
// pingLoop sends periodic ping messages to keep the connection alive
func (ws *WebSocketClient) pingLoop(config *WebSocketConfig) {
func (ws *WebSocketClient) pingLoop(config *WebSocketConfig, connection *webSocketConnection) {
ticker := time.NewTicker(config.PingInterval)
defer ticker.Stop()
for {
select {
case <-ws.ctx.Done():
case <-connection.ctx.Done():
return
case <-ticker.C:
ws.mu.RLock()
conn := ws.conn
connected := ws.connected
ws.mu.RUnlock()
if !connected || conn == nil {
active, err := ws.writePing(connection)
if !active {
return
}
// Set write deadline for ping
ws.writeMu.Lock()
_ = conn.SetWriteDeadline(time.Now().Add(10 * time.Second))
err := conn.WriteMessage(websocket.PingMessage, nil)
ws.writeMu.Unlock()
if err != nil {
ws.logger.Printf("Failed to send ping: %v", err)
return
@@ -383,10 +546,26 @@ func (ws *WebSocketClient) pingLoop(config *WebSocketConfig) {
}
}
func (ws *WebSocketClient) writePing(connection *webSocketConnection) (bool, error) {
ws.writeMu.Lock()
defer ws.writeMu.Unlock()
ws.mu.RLock()
defer ws.mu.RUnlock()
if connection.ctx.Err() != nil || ws.connection != connection || !ws.connected {
return false, nil
}
_ = connection.conn.SetWriteDeadline(time.Now().Add(10 * time.Second))
return true, connection.conn.WriteMessage(websocket.PingMessage, nil)
}
// attemptReconnect attempts to reconnect to the WebSocket
func (ws *WebSocketClient) attemptReconnect(config *WebSocketConfig) {
attempt := 0
for ws.reconnect && (config.MaxReconnectAttempts == 0 || attempt < config.MaxReconnectAttempts) {
for ws.shouldReconnect() && (config.MaxReconnectAttempts == 0 || attempt < config.MaxReconnectAttempts) {
select {
case <-ws.ctx.Done():
return
@@ -533,6 +712,13 @@ func (ws *WebSocketClient) dispatchTypedEventContinued(handlers *models.WebSocke
return true
case models.EventTypeNameUpdated:
if handlers.OnNameUpdated != nil && event.NameUpdated != nil {
handlers.OnNameUpdated(event.NameUpdated)
}
return true
case models.EventTypeRecentsUpdated:
return true
+153
View File
@@ -1,6 +1,7 @@
package client
import (
"context"
"net/http"
"net/http/httptest"
"strings"
@@ -269,6 +270,139 @@ func TestWebSocketClient_Disconnect(t *testing.T) {
}
}
func TestWebSocketClient_DisconnectWhileDisconnectedStopsReconnect(t *testing.T) {
client := NewClientFromHost("192.0.2.10")
wsClient := client.NewWebSocketClient(nil)
if err := wsClient.Disconnect(); err == nil {
t.Fatal("Disconnect() while disconnected should retain its compatibility error")
}
wsClient.mu.RLock()
reconnect := wsClient.reconnect
wsClient.mu.RUnlock()
if reconnect {
t.Fatal("Disconnect() left reconnect enabled")
}
select {
case <-wsClient.ctx.Done():
case <-time.After(100 * time.Millisecond):
t.Fatal("Disconnect() did not cancel the WebSocket context")
}
}
func TestWebSocketClient_CloseCancelsInProgressDial(t *testing.T) {
client := NewClientFromHost("192.0.2.10")
wsClient := client.NewWebSocketClient(nil)
dialStarted := make(chan struct{})
wsClient.dialContext = func(ctx context.Context, _ string, _ http.Header) (*websocket.Conn, *http.Response, error) {
close(dialStarted)
<-ctx.Done()
return nil, nil, ctx.Err()
}
connectDone := make(chan error, 1)
go func() {
connectDone <- wsClient.Connect()
}()
select {
case <-dialStarted:
case <-time.After(time.Second):
t.Fatal("WebSocket dial did not start")
}
if err := wsClient.Close(); err != nil {
t.Fatalf("Close() failed: %v", err)
}
select {
case err := <-connectDone:
if err == nil {
t.Fatal("Connect() succeeded after Close() canceled its dial")
}
case <-time.After(time.Second):
t.Fatal("canceled WebSocket dial did not return")
}
if err := wsClient.Close(); err != nil {
t.Fatalf("second Close() was not idempotent: %v", err)
}
}
func TestWebSocketClient_StaleGenerationCannotOwnPing(t *testing.T) {
client := NewClientFromHost("192.0.2.10")
wsClient := client.NewWebSocketClient(nil)
oldCtx, oldCancel := context.WithCancel(context.Background())
currentCtx, currentCancel := context.WithCancel(context.Background())
defer oldCancel()
defer currentCancel()
oldConnection := &webSocketConnection{ctx: oldCtx, cancel: oldCancel}
currentConnection := &webSocketConnection{ctx: currentCtx, cancel: currentCancel}
wsClient.mu.Lock()
wsClient.connection = currentConnection
wsClient.connected = true
wsClient.mu.Unlock()
active, err := wsClient.writePing(oldConnection)
if err != nil {
t.Fatalf("writePing() for stale generation returned error: %v", err)
}
if active {
t.Fatal("replaced connection generation retained ping ownership")
}
}
func TestWebSocketClient_TransportCallbacksCarryMonotonicGenerations(t *testing.T) {
server, messagesChan := setupMockWebSocketServer(t)
defer server.Close()
defer close(messagesChan)
client := NewClientFromHost("192.0.2.10")
wsClient := client.NewWebSocketClient(nil)
serverWebSocketURL := strings.Replace(server.URL, "http://", "ws://", 1)
wsClient.dialContext = func(ctx context.Context, _ string, header http.Header) (*websocket.Conn, *http.Response, error) {
return websocket.DefaultDialer.DialContext(ctx, serverWebSocketURL, header)
}
type transportState struct {
connected bool
generation uint64
}
states := make(chan transportState, 2)
wsClient.OnTransportState(func(connected bool, generation uint64) {
states <- transportState{connected: connected, generation: generation}
})
nextState := func() transportState {
t.Helper()
select {
case state := <-states:
return state
case <-time.After(time.Second):
t.Fatal("timed out waiting for transport callback")
return transportState{}
}
}
if err := wsClient.Connect(); err != nil {
t.Fatalf("Connect() failed: %v", err)
}
if state := nextState(); !state.connected || state.generation != 1 {
t.Fatalf("connected state = %+v, want connected generation 1", state)
}
if err := wsClient.Close(); err != nil {
t.Fatalf("Close() failed: %v", err)
}
if state := nextState(); state.connected || state.generation != 2 {
t.Fatalf("disconnected state = %+v, want disconnected generation 2", state)
}
}
func TestWebSocketClient_HandleMessage(t *testing.T) {
client := NewClientFromHost("192.0.2.10")
wsClient := client.NewWebSocketClient(&WebSocketConfig{
@@ -278,6 +412,7 @@ func TestWebSocketClient_HandleMessage(t *testing.T) {
var (
nowPlayingEvent *models.NowPlayingUpdatedEvent
volumeEvent *models.VolumeUpdatedEvent
nameEvent *models.NameUpdatedEvent
)
wsClient.OnNowPlaying(func(event *models.NowPlayingUpdatedEvent) {
@@ -288,6 +423,10 @@ func TestWebSocketClient_HandleMessage(t *testing.T) {
volumeEvent = event
})
wsClient.OnNameUpdated(func(event *models.NameUpdatedEvent) {
nameEvent = event
})
t.Run("HandleNowPlayingEvent", func(t *testing.T) {
xmlData := []byte(`<?xml version="1.0" encoding="UTF-8" ?>
<updates deviceID="689E19B8BB8A">
@@ -343,6 +482,20 @@ func TestWebSocketClient_HandleMessage(t *testing.T) {
}
})
t.Run("HandleNameEvent", func(t *testing.T) {
xmlData := []byte(`<updates deviceID="689E19B8BB8A"><nameUpdated deviceID="689E19B8BB8A"><name>Living Room Left</name></nameUpdated></updates>`)
wsClient.handleMessage(xmlData)
if nameEvent == nil {
t.Fatal("Name event handler was not called")
}
if nameEvent.DeviceID != "689E19B8BB8A" || nameEvent.Name.Value != "Living Room Left" {
t.Errorf("Unexpected name event: %+v", nameEvent)
}
})
t.Run("HandleInvalidXML", func(t *testing.T) {
logger := &mockLogger{}
wsClient.logger = logger
+30 -6
View File
@@ -1,6 +1,7 @@
package client
import (
"io"
"net/http"
"net/http/httptest"
"strings"
@@ -295,12 +296,15 @@ func TestClient_RemoveFromZone(t *testing.T) {
getZoneCalled := false
setZoneCalled := false
var removeBody string
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/xml")
if r.URL.Path == "/getZone" && r.Method == http.MethodGet {
switch {
case r.URL.Path == "/getZone" && r.Method == http.MethodGet:
getZoneCalled = true
// Return existing zone with members
// Return existing zone with two members.
response := `<?xml version="1.0" encoding="UTF-8" ?>
<zone master="ABCD1234EFGH">
<member ipaddress="192.0.2.11">EFGH5678IJKL</member>
@@ -309,11 +313,16 @@ func TestClient_RemoveFromZone(t *testing.T) {
w.WriteHeader(http.StatusOK)
_, _ = w.Write([]byte(response))
} else if r.URL.Path == "/setZone" && r.Method == http.MethodPost {
case r.URL.Path == "/removeZoneSlave" && r.Method == http.MethodPost:
b, _ := io.ReadAll(r.Body)
removeBody = string(b)
w.WriteHeader(http.StatusOK)
case r.URL.Path == "/setZone" && r.Method == http.MethodPost:
setZoneCalled = true
w.WriteHeader(http.StatusOK)
} else {
default:
w.WriteHeader(http.StatusNotFound)
}
}))
@@ -321,6 +330,9 @@ func TestClient_RemoveFromZone(t *testing.T) {
client := createTestClient(server.URL)
// Removing one of two members must target that member via /removeZoneSlave,
// not rebuild the zone via /setZone (which does not drop a member from a
// multi-member zone). Regression for #511.
err := client.RemoveFromZone("EFGH5678IJKL")
if err != nil {
t.Errorf("Expected no error, but got: %v", err)
@@ -330,8 +342,20 @@ func TestClient_RemoveFromZone(t *testing.T) {
t.Error("Expected GetZone to be called")
}
if !setZoneCalled {
t.Error("Expected SetZone to be called")
if setZoneCalled {
t.Error("RemoveFromZone must not use /setZone to drop a member from a multi-member zone")
}
if !strings.Contains(removeBody, "EFGH5678IJKL") {
t.Errorf("removeZoneSlave body should target the member, got: %s", removeBody)
}
if !strings.Contains(removeBody, `master="ABCD1234EFGH"`) {
t.Errorf("removeZoneSlave body should name the master, got: %s", removeBody)
}
if !strings.Contains(removeBody, `ipaddress="192.0.2.11"`) {
t.Errorf("removeZoneSlave body should carry the member IP from the zone, got: %s", removeBody)
}
}
+188
View File
@@ -0,0 +1,188 @@
package discovery
import (
"context"
"log/slog"
"net/url"
"sync"
"time"
)
const (
mediaServerDeviceType = "urn:schemas-upnp-org:device:MediaServer:1"
cdsServiceType = "urn:schemas-upnp-org:service:ContentDirectory:1"
// descFetchTimeout is a separate budget for description fetches so the
// overall SSDP sweep timing does not cut them off.
descFetchTimeout = 8 * time.Second
)
// MediaServer is a discovered DLNA UPnP MediaServer that exposes a
// ContentDirectory service.
type MediaServer struct {
// UDN is the stable unique device name (uuid:...) from the UPnP description.
UDN string
// FriendlyName is the human-readable device name, e.g. "FRITZ!Box 7590".
FriendlyName string
// Manufacturer and ModelName let callers show a useful device subtitle.
Manufacturer string
ModelName string
// Address is the "host:port" of the device description endpoint.
Address string
// CDSControlURL is the fully resolved URL for ContentDirectory SOAP actions.
// Empty string means the device does not expose ContentDirectory.
CDSControlURL string
// IconURL is the first icon the device advertised, resolved to absolute form.
IconURL string
}
// DiscoverMediaServers sends SSDP M-SEARCH requests for MediaServer devices,
// fetches each device description concurrently, and returns only the servers
// that expose a ContentDirectory service. Deduplicated by UDN.
func DiscoverMediaServers(ctx context.Context, timeout time.Duration) ([]MediaServer, error) {
if timeout <= 0 {
timeout = defaultTimeout
}
opts := SearchOptions{
Targets: []string{
mediaServerDeviceType,
"ssdp:all",
},
Timeout: timeout,
}
responses, err := SearchSSDP(ctx, opts)
if err != nil {
return nil, err
}
if len(responses) == 0 {
return nil, nil
}
// Fetch descriptions concurrently. Use a fresh context so that the
// description fetches are not cut off by the already-elapsed SSDP timeout.
fctx, fcancel := context.WithTimeout(ctx, descFetchTimeout)
defer fcancel()
type fetchResult struct {
srv MediaServer
ok bool
}
results := make(chan fetchResult, len(responses))
var wg sync.WaitGroup
for _, resp := range responses {
wg.Add(1)
go func(loc string) {
defer wg.Done()
desc, err := FetchDescription(fctx, loc)
if err != nil {
slog.Warn("mediaserver: description fetch failed", "location", loc, "err", err.Error())
results <- fetchResult{}
return
}
srv, ok := mediaServerFromDescription(desc)
results <- fetchResult{srv: srv, ok: ok}
}(resp.Location)
}
wg.Wait()
close(results)
seen := map[string]struct{}{}
var out []MediaServer
for r := range results {
if !r.ok || r.srv.CDSControlURL == "" || r.srv.UDN == "" {
continue
}
if _, dup := seen[r.srv.UDN]; dup {
continue
}
seen[r.srv.UDN] = struct{}{}
out = append(out, r.srv)
}
return out, nil
}
// mediaServerFromDescription maps a parsed Description to a MediaServer.
// Returns ok=false when the description does not expose a ContentDirectory
// service (i.e. the device is not a usable DLNA media server).
//
// It walks the device tree so that nested MediaServer sub-devices (e.g.
// FRITZ!Box root device nesting the NAS MediaServer) are found correctly.
func mediaServerFromDescription(desc *Description) (MediaServer, bool) {
if desc == nil {
return MediaServer{}, false
}
svc, ok := desc.FindService(cdsServiceType)
if !ok || svc.ControlURL == "" {
return MediaServer{}, false
}
srv := MediaServer{
UDN: desc.Root.UDN,
FriendlyName: desc.Root.FriendlyName,
Manufacturer: desc.Root.Manufacturer,
ModelName: desc.Root.ModelName,
CDSControlURL: svc.ControlURL,
}
// Populate Address from the CDS control URL host so callers know which
// host:port to reach the device on. The control URL is absolute after
// FetchDescription resolves it; parse errors leave Address empty.
if u, err := url.Parse(svc.ControlURL); err == nil {
srv.Address = u.Host
}
// Walk sub-devices to fill in UDN / FriendlyName if the root is sparse
// (some devices put it all in the sub-device, e.g. FRITZ!Box).
fillFromTree(desc, &srv)
if ic, ok := desc.FirstIcon(); ok {
srv.IconURL = ic.URL
}
return srv, true
}
// fillFromTree walks the description tree to fill in missing fields on srv
// from sub-devices. Only fills in fields that are still empty.
func fillFromTree(desc *Description, srv *MediaServer) {
walkDevice(&desc.Root, srv)
}
func walkDevice(dev *Device, srv *MediaServer) {
if srv.FriendlyName == "" && dev.FriendlyName != "" {
srv.FriendlyName = dev.FriendlyName
}
if srv.UDN == "" && dev.UDN != "" {
srv.UDN = dev.UDN
}
if srv.Manufacturer == "" && dev.Manufacturer != "" {
srv.Manufacturer = dev.Manufacturer
}
if srv.ModelName == "" && dev.ModelName != "" {
srv.ModelName = dev.ModelName
}
for i := range dev.Devices {
walkDevice(&dev.Devices[i], srv)
}
}

Some files were not shown because too many files have changed in this diff Show More