617 Commits
Author SHA1 Message Date
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
github-actions[bot]andTobias Gesellchen 8695816d36 chore: sync static dependencies with package.json 2026-07-03 20:39:57 +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
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 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 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 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 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 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
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
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 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
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 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
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
Tobias GesellchenandClaude Opus 4.8 2dada5a61a fix(web): play Radio Browser via native RADIO_BROWSER source (refs #479)
Selecting a Radio Browser station from the player UI returned HTTP 500
and the speaker dropped to INVALID_SOURCE. The play path sent the speaker
a ContentItem with source="URL" and an absolute location
(https://all.api.radio-browser.info/soundtouch/stations/byuuid/<uuid>).
source="URL" makes the speaker fetch that location as a raw audio stream,
but the URL returns station JSON, not audio, so the speaker rejects it.

"URL" was never a real source: it is not in the speaker's sourceprovider
registry and never persisted in any datastore. The rest of the stack is
already built for the native RADIO_BROWSER source (BMX registry provider
39 with base URL .../soundtouch, a seeded RADIO_BROWSER source, marge
classification, and the documented relative location form). Working
RADIO_BROWSER items use source="RADIO_BROWSER" with a relative
location="/stations/byuuid/<uuid>", which the speaker resolves against
the registry base URL and plays directly.

- stations.ResolveContentItem: emit source=RADIO_BROWSER for the provider
- RadioBrowserSearch: emit the relative /stations/byuuid/<uuid> playback
  href so the speaker prepends the registry base URL
- marge classifier: match the relative /stations/byuuid/ segment (covers
  both the relative and legacy absolute forms)
- tests updated to assert the native source + relative location

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-08 20:44:31 +02:00
Tobias GesellchenandClaude Opus 4.8 6c8a50c049 feat(cli): opt-in hardening for setup enable-ssh (--close-17000, --authorized-key) (refs #471)
Adds the #471 "secure" steps as opt-in flags on `setup enable-ssh`, off by
default (per the decision that closing 17000 must be opt-in):

- --close-17000: blocks port 17000 from the LAN. Manager.Close17000 remounts /
  read-write, persists an idempotent iptables rule in
  /etc/init.d/Firewalls/update_iptables (keyed on a marker), and applies it
  immediately; loopback access is kept.
- --authorized-key <pubkey>: Manager.InstallAuthorizedKey writes the key to
  /home/root/.ssh/authorized_keys so root SSH no longer relies on the
  empty-password login.

Both run over the SSH the enable step just opened. Default output reminds the
user that 17000 is left open and how to close it. Unit tests cover the
firewall command sequence and the key upload path.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-07 21:11:19 +02:00
Tobias GesellchenandClaude Opus 4.8 b7009a50eb feat(cli): setup enable-ssh — bootstrap SSH via the port-17000 envswitch trick (refs #471)
Adds `soundtouch-cli setup enable-ssh`, the first iteration of foob61451's #471:
turn on SSH on a speaker that has no prior SSH access and without a USB
recovery stick, then fall into the migration / CA-install flow we already have.

Mechanism (new Manager methods, reusing the existing telnet :17000 client):
- EnableSSHViaTelnet sends `envswitch boseurls set "<url>;touch
  /tmp/remote_services;/etc/init.d/sshd start" "<url>/update"`. The injected
  shell commands run when the speaker next parses its boseurls (~60s), starting
  sshd. The URL is only the vehicle for the injection — it does NOT need a live
  server, so this works before any AfterTouch service exists.
- WaitForSSHPort polls :22 until sshd is up.
- ResetBoseURLs restores a clean marge URL afterwards.
- Persistence reuses the existing EnsureRemoteServices (writes the marker over
  the now-open SSH so it survives reboot).

CLI flow: inject → wait for :22 → reset clean URLs → persist. `--service-url`
is optional (placeholder used otherwise; set real URLs later via migration).
Securing/closing port 17000 is deliberately OPT-IN and not done here. Unit
tests pin the exact injected/reset command strings and the double-quote guard.

This lands in -cli first (cheapest to iterate); the future soundtouch-app can
reuse the same Manager methods.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-07 21:11:19 +02:00
Tobias GesellchenandClaude Opus 4.8 843ec732d5 fix(service): route embedded player TTS self-call over loopback
The embedded player's TTS proxy made a server-side call back to the
service over the public ServiceURL. When that URL is HTTPS with the
service's self-signed CA, the call failed with "x509: certificate
signed by unknown authority" — the service didn't trust its own CA.

Route the player's own server-side self-calls to the service's loopback
HTTP listener instead (new WebApp.InternalServiceURL, used via
proxyServiceURL()). Loopback is plain HTTP, so it needs no CA and works
on HTTP and HTTPS deployments alike, including before the CA is
generated, and it doesn't depend on the public URL being routable from
inside the service. ServiceURL stays public: Play URL bakes it into the
stream URLs the speaker fetches, and the UI displays it.

config.port is always the plain-HTTP listener (http.Serve); TLS lives
on a separate httpsAddr, so the loopback URL can never hit a TLS-only
socket.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-07 21:11:02 +02:00
Tobias GesellchenandClaude Opus 4.8 3dd39e85d4 fix(health): set_clock verifies the change and falls back to SSH (refs #345)
The set_clock quick-fix pushed the time via POST /clockTime and reported
success unconditionally. On real hardware (ST10, observed live) the firmware
dispatches POST /clockTime to its read handler (HandleClockGetTime) and
ignores the value: it returns 200 but the clock never moves, so the fix was a
silent no-op that still claimed success.

Now setSpeakerClock:
- tries HTTP POST /clockTime (works on firmware that honours it), then
- verifies by re-reading /clockTime; if the clock did not move, it
- sets the clock over SSH (`date -u -s …`, with a BusyBox positional
  fallback) on an SSH-reachable speaker (root, empty password), and
- verifies again. It only reports success when the clock actually changed;
  otherwise it returns an honest error pointing at the real root cause
  (the speaker can't resolve/reach NTP, so the clock is stuck — restore
  DNS/NTP reachability; a wrong clock breaks HTTPS/TLS).

The HTTP request format itself was already correct (the device's own GET uses
`utcTime`); the problem was never the payload, only that some firmware has no
HTTP setter at all. Durable NTP-side fix (AfterTouch resolving/serving NTP) is
tracked separately.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-07 19:07:21 +02:00
Tobias GesellchenandClaude Opus 4.8 bd62fd6658 refactor: rename soundtouch-web to soundtouch-player (transitional alias) (refs #451)
The web player is intrinsically LAN-resident: it reaches speakers directly
and only delegates cloud-only features (e.g. TTS) to a possibly-remote
AfterTouch service via --service-url. That is exactly what a cloud-hosted
soundtouch-service cannot do, so the standalone player binary stays useful
and is not being deprecated. Rename it to state its purpose, with a
transition window so existing downloads keep working.

- cmd/soundtouch-web -> cmd/soundtouch-player; CLI name is now
  soundtouch-player. When the binary is invoked under its old name it prints
  a one-line rename notice (filepath.Base(os.Args[0])).
- Build/release both names from the same source: Makefile (build-player +
  build-web alias, dev-player* targets), Dockerfile (soundtouch-player image
  + transitional soundtouch-web image), release.yml and ci.yml (player +
  web artifacts, checksums, Docker images; release notes announce the
  rename). The soundtouch-web binary, image, and install script remain a
  transitional alias to be dropped in a future release (which will break
  stale fetch scripts and nudge users to the release notes).
- scripts/raspberry-pi/install-player.sh is canonical; install-web.sh keeps
  working but warns.
- Sweep docs, code comments, user-facing strings, and assets
  (soundtouch-web-ui.png, soundtouch-web-tunein.png, soundtouch-web-roadmap.md)
  to soundtouch-player; README documents the rename and why the player
  remains separate from the embedded /app.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-07 16:33:39 +02:00
Tobias GesellchenandClaude Opus 4.8 2657e5411c style(web): keep the braille logo in brand colours on every bar (refs #451)
The header bars are intentionally monochrome, but the logo was recoloured
along with them (admin forced it white; the player and chooser whitened it
in light mode). Drop the filter on the brand mark only so it stays in its
blue/yellow brand colours as the single accent, while the mono nav icons
still recolour via --nav-icon-filter. Removes the now-unused --logo-filter
var from the chooser.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-07 15:08:26 +02:00
Tobias GesellchenandClaude Opus 4.8 631422967d feat(web): consistent footers + a shared docs affordance (refs #451)
Unify the three surfaces' footers and rework the documentation link:

- All footers now show the same version-only line (AfterTouch <version>
  (<commit>) • <date>), centered and full-width. The chooser footer no
  longer caps its width or carries a docs link; the admin footer uses the
  same "•" separator as the player and chooser instead of "-".
- The chooser gets a prominent in-body Documentation link with a book
  icon, distinct from the two destination rows (and removed from the top
  bar, which is now brand-only).
- That same book icon becomes a small docs button in the player navbar
  and the admin header bar, so documentation is one click away from every
  surface.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-07 15:08:26 +02:00
Tobias GesellchenandClaude Opus 4.8 d9ef84067e feat(service): keep the chooser reachable via /?chooser (refs #451)
With default_landing set to app or admin, "/" redirects straight there,
which made the chooser (and through it the other surface) unreachable
from the "home" link. Add a "?chooser" override: "/" always serves the
chooser when that query is present, regardless of the configured default.

Point the "home" brand links on the player, the admin console, and the
chooser itself at /?chooser, so "home" always lands on the hub instead of
bouncing back through the default redirect. The bare "/" still honours the
default for direct hits and bookmarks.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-07 15:08:26 +02:00
Tobias GesellchenandClaude Opus 4.8 38603a6f03 fix(admin): cap concurrent live-info probes so navigation isn't starved (refs #451)
Root cause of the "navigating away from /admin waits ~30s" report: the
device list refreshed every device's live /info at once. Over HTTP/1.1 a
browser opens only ~6 connections per origin, and it keeps the current
document's in-flight requests (and their sockets) alive until a new
navigation's response begins. With several offline speakers each holding
an /info socket until timeout, all ~6 connections were occupied, so the
next navigation (GET /) could not get a socket until a probe freed one.
The page genuinely waited the full timeout before painting.

Cap the live-info probes at LIVE_INFO_CONCURRENCY (3) via a small mapLimit
helper, leaving sockets free for navigation and other requests. Combined
with the 5s GET timeout, an offline-heavy datastore no longer stalls the
UI. The device table still renders immediately from the datastore; only
the live enrichment is throttled.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-07 15:08:26 +02:00
Tobias GesellchenandClaude Opus 4.8 d73ce7b559 fix(setup): bound speaker HTTP GETs so offline devices fail fast (refs #451)
Manager.HTTPGet defaulted to http.Get, which uses http.DefaultClient with
no timeout. An offline speaker therefore hung the caller for the OS-level
TCP timeout (~30 s). The admin device list refreshes every device's live
/info on each load (updateDeviceInfo per row), so a handful of offline
speakers each held a request for 30 s. Server-side those run concurrently
and never blocked other routes, but the browser's ~6-connections-per-origin
limit got saturated by the long-held /info requests, which made the whole
admin page (and navigating away from it) feel stuck.

Give HTTPGet a 5 s timeout (liveDeviceHTTPTimeout): ample for a healthy
speaker on the LAN, quick to fail a dead one. Applies to the /info,
/presets, /recents, /sources, inspect, and peer-probe GETs.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-07 15:08:26 +02:00
Tobias GesellchenandClaude Opus 4.8 a8759f91d5 fix(admin): remove duplicate on-load discovery trigger (refs #451)
The previous commit gated the on-load discovery sweep to a cold start,
but a second, redundant DOMContentLoaded handler still called
triggerDiscovery() ungated on every admin load, so /admin kept kicking
off a full sweep (and its reseed) even with devices already known. The
second handler only duplicated fetchDevices + fetchSettings + the
ungated trigger, all of which the first (gated) handler already does, so
remove it outright. That also drops the duplicate per-device live /info
refresh the second handler caused.

Also drop two em dashes (a code comment and the landing meta description).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-07 15:08:26 +02:00
Tobias GesellchenandClaude Opus 4.8 c97f760153 fix(admin): only auto-discover on load when no devices are known (refs #451)
The admin console ran a full discovery sweep on every page load whenever
discovery was enabled (DOMContentLoaded -> triggerDiscovery). With devices
already in the datastore, that re-probed every host (including offline
ones) on each visit, which felt slow and surprising.

Gate the on-load sweep on a cold start only: fetch the cached device list
first, and trigger discovery just when it is empty. With devices known,
rely on the cached list, the periodic sweep, and the explicit Discover
button. fetchDevices now returns the device count for that check.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-07 15:08:26 +02:00
Tobias GesellchenandClaude Opus 4.8 09113afd87 perf(web): probe datastore hosts concurrently in SeedExtraDevices (refs #451)
SeedExtraDevices probed each datastore host serially via AddDeviceByHost,
whose /info call blocks up to its 10 s timeout for an unknown host. With
offline speakers in the datastore, a re-sync (e.g. the admin page's
discovery sweep on load, or the periodic discovery) stalled for 10 s per
offline device, one after another.

Fan the per-host probes out across goroutines and wait for all of them,
so the seed costs roughly a single timeout regardless of how many devices
are offline. AddDeviceByHost is already registry-safe under concurrency
(covered by TestRegistryConcurrent).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-07 15:08:26 +02:00
Tobias GesellchenandClaude Opus 4.8 86878cd23b feat(service): landing chooser at /, shared header + footer (refs #451)
Post-merge, "/" was the admin console with a small text link to the
player. This makes "/" a neutral chooser and unifies the chrome across
all three surfaces (landing, player, admin).

- "/" now serves a lean chooser page (web/landing.html): a calm, self-
  contained page (no framework, inline CSS) that routes to the Player
  (/app) or the Admin & Setup console (/admin), with the console framed
  as the privileged surface. API/speaker clients (non-HTML Accept) still
  get the version JSON from "/" unchanged.
- The admin console moved to /admin (HandleAdmin); its assets and APIs
  are absolute, so it works unchanged at the new path.
- New persisted setting default_landing (chooser|app|admin): when set to
  app or admin, "/" 302-redirects straight there. Exposed in the admin
  Settings tab; defaults to the chooser.
- Shared header: all three carry the same accent bar (braille mark +
  "AfterTouch" + "Bose SoundTouch Toolkit"); the mark is the home link
  back to "/". Shared footer: all three show the same version line
  (the landing fetches /api/setup/version with a tiny vanilla script).

Light/dark and mobile refinements are deliberately left for a later
pass; the admin keeps its existing light-only styling for now.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-07 15:08:26 +02:00
Tobias GesellchenandClaude Opus 4.8 b861c11d37 feat(web): remove devices from the player UI (refs #451)
The merge of soundtouch-web into soundtouch-service was asymmetric:
manual device *adds* propagated to the player UI (HandleAddManualDevice
notifies, the hook re-seeds + broadcasts), but *removals* did not. The
datastore-removal handler never notified, and the web registry's sync
only ever added entries — its map was append-only, so a removed device
lingered in the player UI until restart.

This adds the missing removal path:

- DELETE /api/control/devices/{id} (HandleDeleteDevice). The registry is
  keyed by host/IP; the datastore by device ID (MAC), so the handler
  resolves one to the other via the connection's DeviceInfo, cascades to
  the datastore through a new RemoveDeviceHook (embedded build only),
  prunes the in-memory entry, and broadcasts the updated list.
- WebApp.RemoveDevice prunes the registry and stops the per-device
  goroutines (status poller + WebSocket reconnect loop) via a new
  done-channel + Close() on DeviceConnection — previously both ran for
  the life of the process.
- Server.RemoveDeviceByID extracts the cross-account lookup + remove from
  HandleRemoveDevice and now fires notifyDevicesChanged, so the admin
  Devices tab removal also propagates to the player UI.
- Player UI: a quiet per-card Remove control (visible on hover), a
  confirm dialog, optimistic prune, and a note that a still-online
  device may reappear after the next discovery scan (honest v1 — no
  ignore-list).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-07 15:08:26 +02:00