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>
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>
#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>
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>
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>
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>
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>
--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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
The templates were written during the Go-library era and no longer match the
project: they asked reporters (mostly speaker owners) for Go versions, library
versions, pkg/client pickers, and minimal repro code, while pointing at dead doc
links. #478 reported one of those dead links (the troubleshooting guide).
Issue templates:
- Fix the dead troubleshooting + API-cookbook links (now the published docs site).
- Delete the legacy .md duplicates of bug_report/feature_request/device_compatibility
(GitHub was showing them alongside the .yml forms).
- Rewrite bug_report.yml and feature_request.yml around how people actually run
AfterTouch (service/CLI/player/backup); make them short and easy to file, with
the encrypted diagnostic export as the headline ask.
- Add device_compatibility.yml (slim) and a config.yml chooser that links
Discussions, the Survival Guide, and the Troubleshooting Guide. Blank issues stay
enabled.
Diagnostic-export transparency: instead of claiming the report "contains no
readable secrets", state honestly that the raw datastore XML (e.g. Sources.xml) is
included as-is and can carry access tokens for linked services (Spotify/Amazon),
that there is no datastore-redaction setting, and that users can unlink first or
send privately. Point at the same support email the Health tab shows
(aftertouch-support@gesellix.net) and note GitHub blocks .age uploads (rename to
.age.txt or zip).
PR template: cut the library-era ceremony down to summary/issue/type/testing/
checklist, add an "AI-assisted contributions" note (agent code welcome, unreviewed
slop rejected), a no-personal-data reminder, and an MIT + Code of Conduct footer.
CONTRIBUTING.md: reframe from "Bose SoundTouch API Client / Go library" to the
AfterTouch toolkit; fix build paths (./build/) and make targets; drop broken
references; point at CLAUDE.md; add the AI stance and the no-personal-data rule.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
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>
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>
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>
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>
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>
The soundtouch-player image (and its transitional soundtouch-web alias) ran
as root for no reason: the player is stateless, binds an unprivileged port
(8080), and its mDNS/SSDP discovery uses unprivileged multicast. Drop to
USER nobody. Verified the image starts, binds 8080, and discovers as uid
65534.
The soundtouch-service image is left as root for now: it persists to
/app/data (commonly a host-mounted volume whose ownership we can't assume)
and its optional built-in DNS server binds the privileged :53. Making it
non-root needs a chowned data dir plus NET_BIND_SERVICE (or moving DNS off
:53), so it's handled separately.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Lands the groundwork to run the service container as non-root, but keeps it
running as root by default so this is NOT a breaking change yet. Enabling it
(BREAKING) is planned for v1.0.0 and reduced to a one-line flip.
Image prep (all harmless while running as root):
- A fixed non-root user, uid/gid 65532 (aftertouch), with /app chowned to it.
- A cap_net_bind_service file capability on the binary so the optional DNS
server can still bind :53 as non-root (NET_BIND_SERVICE is in Docker's
default cap set; no --cap-add needed). Applied after chown so it survives.
- USER ${APP_USER} with ARG APP_USER=root: still root by default. To enable
non-root, flip the default to "aftertouch" (one line) or build with
--build-arg APP_USER=aftertouch.
Startup safety net (active now, no-op while writable):
- warnIfDataDirNotWritable probes DATA_DIR and, if it can't write, logs the
exact `chown -R 65532:65532 <dir>` fix (with the process uid) instead of
failing later with a cryptic permission error. This is the common snag when
a non-root container meets a bind-mounted host dir owned by someone else.
Verified: default build runs as root; --build-arg APP_USER=aftertouch runs as
65532, serves /health, writes the data dir; a read-only data dir triggers the
warning + chown hint.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The community Markdown conversion of the official SoundTouch Web API PDF moved
from jaas666/bose-soundtouch-player-api to jaas666/bose-soundtouch-web-api.
Update the links in the community-tools comparison and related-resources list
so the docs link check passes. (Supersedes an earlier mistaken removal; the
repo was renamed, not deleted.)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
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>
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>
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>
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>
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>
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>
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>
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>
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>