314 Commits
Author SHA1 Message Date
Tobias GesellchenandClaude Opus 4.8 9bfe2a1a08 fix(ci): gate http-client tests on mock readiness; address semgrep findings (refs #451)
The integration suite flaked in CI: with three `go run` mocks now compiling
concurrently, the spotify/amazon mocks weren't listening within the fixed
`sleep 10`, so the registration requests at the start of the suite hit a
connection-refused and the "Account exists" assertions (and the cascading amazon
oauth token test) failed. Locally it passed because the mock builds were warm.

Replace the fixed sleep with real readiness gating:
- Add a /healthz endpoint to the spotify, amazon and tunein mocks.
- Give all four CI services (the three mocks + soundtouch-service) a compose
  healthcheck (busybox wget; all images are alpine-based), and make the service
  depend_on the mocks being service_healthy.
- `docker compose up -d --build --wait` blocks until everything is healthy, so
  the JetBrains client only runs against a fully-ready stack.

Also clear the two semgrep advisories on the new TuneIn mock:
- cmd/mock-*: annotate the intentional plaintext ListenAndServe with nosemgrep
  (throwaway loopback/CI test servers, never production).
- pkg/testutils/tunein: sanitize the query-supplied guide id to a safe charset
  before interpolating it into the JSON/XML response (raw-html-format).

make test-http-client: 73 requests, 0 failed (clean testdata, healthcheck-gated).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 19:11:24 +02:00
Tobias GesellchenandClaude Opus 4.8 51ad72adcf test(service): add frozen-route contract-coverage guard (refs #451)
TestFrozenRouteContractCoverage walks the service router for frozen speaker/app
contract routes (the /streaming, /accounts, /customer, /bmx, /core02, /oauth,
/custom, /media, /updates, /v1, /alexa, /ced prefixes) and checks each is hit by
at least one .http integration test. The set of uncovered frozen routes is
golden-filed (testdata/frozen_routes_uncovered.txt), mirroring the existing
router_routes.txt pattern: adding a frozen route without a test, or a test that
newly covers one, changes the set and fails the guard, forcing a conscious
update. This makes COVERAGE.md a machine-checked invariant rather than a doc
that can silently drift.

Restricted to GET/POST/PUT/DELETE (chi HandleFunc-registered routes otherwise
add CONNECT/TRACE/... noise). golangci-lint clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 19:11:24 +02:00
Tobias GesellchenandClaude Opus 4.8 6efad165f6 test(http-client): mock TuneIn upstream so playback tests run offline (refs #451)
Make the BMX TuneIn integration tests independent of the live TuneIn
(radiotime.com) service, the same way Spotify/Amazon are already mocked.

- pkg/service/bmx: the TuneIn upstream base URLs become configurable vars with
  a SetTuneInEndpoints(opmlBase, apiBase) setter that also registers the host in
  the outbound allowlist. Defaults are unchanged (real radiotime hosts), so
  production behaviour is identical; tests can redirect to a mock.
- cmd/soundtouch-service: new --tunein-opml-url / --tunein-api-url flags
  (TUNEIN_OPML_URL / TUNEIN_API_URL) wired through to SetTuneInEndpoints.
- cmd/mock-tunein + pkg/testutils/tunein: a mock TuneIn server serving Tune.ashx
  (stream URLs) and describe.ashx (name/logo) with RFC-5737 values; unmocked
  endpoints 404 so a test needing them fails loudly.
- docker-compose.ci.yml: add the tunein-mock service and point the service at it.
- tunein_playback_station.http now asserts the mock-served stream URL + name,
  proving the path is offline. tunein_favorite.http covers the local-only
  favorite add/remove (202).
- TUNEIN-MOCK-MISSING.md lists the upstream captures still needed (episode /
  navigate / search) before those routes can be mocked + tested.

make test-http-client: 61 requests, 0 failed. golangci-lint clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 19:11:24 +02:00
Tobias GesellchenandClaude Opus 4.8 4f561944a3 fix(bmx): strip trailing slash from server_url so TuneIn playback routes
A server_url configured with a trailing slash (e.g. http://host:8000/)
flowed verbatim into the BMX registry base ("{BMX_SERVER}/bmx/tunein"),
so speakers were handed "http://host:8000//bmx/tunein" and requested
"//bmx/tunein/v1/playback/station/{id}". The chi router does not match
the doubled-slash path, so TuneIn playback returned 404 and the speaker
reported INVALID_SOURCE. Confirmed from a reporter's diagnostic export.

- Add NormalizeServerURL (trim whitespace + trailing slashes); apply in
  NewServer so the BMX base is always clean.
- Normalize server_url at ingestion in main (flag + persisted) so the
  margeServerUrl/bmxRegistryUrl pushed to speakers stays clean too.
- Normalize in the live settings-update path so a UI-saved trailing slash
  is trimmed before validate/persist.
- Mount chi middleware.CleanPath as a defensive net: any "//" path
  collapses to "/" before routing, regardless of source.
- Regression tests: NormalizeServerURL table + BMX registry must not emit
  "//bmx"/"//media" for a trailing-slash server_url.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-05 14:08:41 +02:00
Tobias GesellchenandClaude Opus 4.8 c466246dee feat(health): add on-demand DNS-path diagnostics for the #345 speaker-DNS escape
When a speaker resolves the firmware-hardcoded content.api.bose.io through
the operator's own DNS instead of AfterTouch, TuneIn/BMX content requests
escape AfterTouch and fail (CURL 60, or a dead-cloud 404), so the speaker
reports INVALID_SOURCE. The existing dns_sanity check only probes AfterTouch's
own answering side over loopback, so it passes even when no speaker uses
AfterTouch as its resolver. This adds a speaker-side, on-demand check.

dns_speaker_usage:
- pkg/discovery/dns.go tracks distinct non-loopback clients that query an
  intercepted Bose hostname (interceptClients set, populated in recordQuery,
  exposed via InterceptClientIPs()). Loopback is excluded so dns_sanity's own
  probes don't register.
- The check lists each unconfirmed speaker as an info finding with a "Test DNS
  path" quick-fix. It never emits a standing warning, so it does not
  false-positive after a restart (the querier set is in-memory and starts empty).

Active probe (the "Test DNS path" quick-fix; also POST /setup/health/dns-path-probe):
- Sends a /speaker notification carrying a per-probe nonce as the app_key. To
  accept it the speaker must resolve audionotification.api.bosecm.com
  (intercepted) and call back GET /v1/auth with that nonce; the callback
  arriving is direct proof the speaker resolves Bose hosts through AfterTouch.
- HandleSpeakerAuth returns 403 for a matching nonce so the speaker refuses the
  notification (silent, no audio, confirmed on hardware); any other key still
  gets 200 so real TTS is untouched. Reuses resolveTTSHost for SSRF-safe
  targeting; the nonce is never logged. Registered without refresh so the probe
  result stays visible in the Health tab.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-03 23:29:15 +02:00
Tobias GesellchenandClaude Opus 4.8 d94b1bc067 fix(web): trust service CA and send a known target for TTS
soundtouch-web's "Speak" feature proxies to the AfterTouch service's
/setup/tts/speak endpoint. Two issues blocked it end to end.

1. TLS: the proxy used http.DefaultClient, which trusts only system
   roots, so the HTTPS call to a service using its own self-signed CA
   failed with "x509: certificate signed by unknown authority". Add a
   --service-ca flag (SERVICE_CA env) that loads the CA PEM, appends it
   to the system pool, and uses a custom client for the TTS call.

2. Target: soundtouch-web sent device.Client.Host() (a full base URL
   like http://ip:8090), but the service's SSRF guard exact-matches the
   target against bare datastore IPs, returning "host ... is not a known
   device". Prefer the device ID (the canonical key) and send a bare-IP
   host fallback. Also normalize the incoming host in resolveTTSHost so a
   URL/host:port form still resolves; it still only ever returns a
   datastore IP, so the SSRF guarantee is unchanged.

Adds unit tests for the CA client builder, hostOnly, and resolveTTSHost
(including the preserved unknown-host/device rejections). Documents
--service-ca in the soundtouch-web README and TROUBLESHOOTING guide.
Wires SERVICE_URL and SERVICE_CA (empty defaults) into the Raspberry Pi
install-web.sh env file and documents them in the Pi guide.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-31 23:37:33 +02:00
Tobias GesellchenandClaude Opus 4.8 80cfb03f6e feat(tts): default to /speaker playback; drop /v1/auth debug dump
Confirmed working on a real speaker (Bose_Lisa/27.0.6): the speaker GETs
/v1/auth at audionotification.api.bosecm.com (DNS-redirected to us) with
the app_key in an "Apikeyheader" header, and an empty 200 is sufficient.

- Make "speaker" the default playback method (ducks + resumes the current
  playback, supports volume) for the speak endpoint, the CLI --method flag,
  and the web UI button; "radio" remains opt-in.
- Remove the temporary full-request debug dump from /v1/auth now that the
  contract is understood; document it in the handler comment instead.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-31 22:35:31 +02:00
Tobias GesellchenandClaude Opus 4.8 22c3142a79 refactor(cli): move Cloud TTS under speaker tts-cloud, use global --host
Replaces the awkward top-level `tts speak --speaker-host` with a
`speaker tts-cloud` subcommand that sits alongside the existing
`speaker tts` and uses the global --host flag (--device still works as
an alternative). The two are now clearly related: `speaker tts` sends a
Google Translate URL straight to the speaker, while `speaker tts-cloud`
routes through the service for server-side synthesis (Cloud TTS) and
playback. --speaker-host is gone.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-31 22:35:31 +02:00
Tobias GesellchenandClaude Opus 4.8 e6d5588b99 feat(tts): add method selector (speaker | radio) to TTS speak
/setup/tts/speak now accepts a "method" field (and the CLI a --method
flag): "radio" (default, LOCAL_INTERNET_RADIO, no app_key, replaces
source) or "speaker" (POST /speaker notification, ducks+resumes, honours
volume). The speaker method defaults the app_key to "aftertouch" when
none is configured, since the speaker validates it via GET /v1/auth which
we answer 200 regardless.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-31 22:35:31 +02:00
Tobias GesellchenandClaude Opus 4.8 56c4ae4e2d fix(tts): play via LOCAL_INTERNET_RADIO; accept app_key at /v1/auth
Root cause of the failed TTS playback: the /speaker notification path
makes the speaker validate the app_key via GET /v1/auth against the
service, which returned 404 -> the speaker reports an invalid app key
(HandleInvalidAppKeyCb) and refuses to play. Our /media/tts hosting was
fine all along (confirmed by a direct GET returning the mp3).

Two fixes:

- TTS speak now plays the synthesized clip as a LOCAL_INTERNET_RADIO
  ContentItem via the /custom/v1/playback proxy (the same mechanism the
  "ding" health check uses), which needs no app_key. New
  buildCustomPlaybackURL helper + tts.Service.BaseURL().
- Add GET /v1/auth -> 200 so the /speaker notification path also works
  (we're the cloud replacement; a 404 there is read as "invalid app
  key"). Includes a TEMPORARY full-request debug dump on /v1/auth to
  learn how the speaker presents the app_key; to be removed later.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-31 22:35:31 +02:00
Tobias GesellchenandClaude Opus 4.8 169c1c5b9f fix(tts): move TTS endpoints from /mgmt to /setup (no Basic Auth)
The TTS speak/config endpoints were under /mgmt (Basic-Auth protected),
but the soundtouch-web proxy and CLI authenticated with their own
mgmt-password default (empty) while the service defaults to "change_me!",
so speaking from -web returned 401.

This was also inconsistent: the Google API key is configured via the
unauthenticated /setup/settings, and Play URL already proxies to /setup,
so gating only TTS playback behind mgmt auth made no sense. Move
/mgmt/tts/{speak,config} to /setup/tts/{speak,config} (LAN-trust, like
the rest of the setup surface), rename the handlers accordingly, and drop
the now-unused mgmt-credential plumbing from soundtouch-web and the CLI
tts command.

Verified: POST /setup/tts/speak now reaches the handler without auth
(502 only because the test speaker IP is unreachable; previously 401).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-31 22:35:31 +02:00
Tobias GesellchenandClaude Opus 4.8 8f2939a9a6 feat(tts): configure Google Cloud TTS from the settings UI; group integrations into collapsible panels
The Google Cloud TTS API key (and app_key / provider / language / voice /
volume) can now be set in the service settings page, persisted to
settings.json, and applied at runtime — same model as Spotify/Amazon
(CLI/env wins at startup, else persisted; secrets masked as "***" over
the wire; a save triggers ReinitTTSService without a restart).

To keep the settings page from bloating as integrations grow, Spotify,
Amazon, and Google Cloud TTS are now collapsible <details> panels under
an "Integrations" heading, each showing an Active/Saved/Inactive badge in
its summary that stays visible when collapsed. Adding a future provider
(e.g. Apple Music) is now just another panel.

Provider construction moved from cmd initTTSService into
handlers.Server.ReinitTTSService so the UI can re-apply changes; the
tts-provider flag default is now empty (empty => translate) so a value
saved in the UI can take effect.

Also: the soundtouch-web TTS source view now shows the AfterTouch service
URL with an override (shared with Play URL via localStorage), and
/api/device-speak accepts a serviceUrl override, mirroring Play URL.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-31 22:35:31 +02:00
Tobias GesellchenandClaude Opus 4.8 c852d07da1 feat(tts): add Google Cloud Text-to-Speech via a pluggable provider
Adds text-to-speech that synthesizes higher-quality audio (Google Cloud
TTS) and plays it on a speaker via the /speaker endpoint. Because Cloud
TTS returns audio bytes (not a fetchable URL), the service caches the
clip and hosts it at GET /media/tts/{id}, mirroring the "ding" endpoint,
then points the speaker at that local URL.

The design is a pluggable Provider interface (pkg/service/tts) wrapping
two modes:
- translate: hands the speaker the (undocumented) Google Translate URL
  directly (no credentials), reusing models.BuildTranslateTTSURL.
- google-cloud: REST API key auth (no SDK/gRPC), bytes cached locally.

Surfaces:
- service: POST /mgmt/tts/speak, GET /mgmt/tts/config, GET /media/tts/{id};
  configured via TTS_PROVIDER / TTS_GOOGLE_API_KEY / TTS_LANGUAGE /
  TTS_VOICE / TTS_APP_KEY / TTS_VOLUME.
- CLI: `soundtouch-cli tts speak` (calls the service with mgmt Basic Auth).
- web: a "TTS" source view (like Play URL / TuneIn), proxied to the
  service via /api/device-speak/{id}.

The /speaker app_key requirement and model limitations still apply; see
docs/content/docs/reference/SPEAKER-ENDPOINT.md.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-31 22:35:31 +02:00
Tobias GesellchenandClaude Opus 4.8 bf5309f49f feat(cli): show bare station/episode id as its own column in station find
`station find` previously surfaced the id only inside the Location href
(e.g. /v1/playback/station/s228737). Render the bare id (s228737,
p1864248, or radiobrowser UUID) alone in a leading column so it is easy
to copy-paste, with the name beside it and the description plus full
Location indented below. The Location line stays because that path, not
the bare id, is what play/preset commands consume.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-31 11:28:16 +02:00
Tobias GesellchenandClaude Opus 4.8 c7eda7ed7b feat(cli)!: deprecate speaker-based station search in favour of find
The `find` family runs the search inside the CLI, querying the radio
provider's public API directly (no speaker cloud, no soundtouch-service).
Make it the canonical path and deprecate the speaker-based search family.

- Add `find-tunein` and `find-radiobrowser` siblings; refactor the find
  actions onto a shared `runFind` helper (all support `--more`).
- Rename the unreleased `search-radiobrowser` to `find-radiobrowser`.
- Deprecate `search`, `search-tunein`, `search-pandora`, `search-spotify`:
  they keep working but print a stderr deprecation notice (new
  `PrintDeprecation` helper) pointing at the `find*` replacement. Pandora
  and Spotify have no built-in equivalent yet (they need the speaker +
  account), so their notices say so.
- Docs: lead with the `find` family as recommended; mark the speaker-based
  search commands deprecated; drop the misleading "service-side" wording
  in favour of "built-in / queries the provider directly".

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-30 20:53:40 +02:00
Tobias GesellchenandClaude Opus 4.8 d101e515a9 feat(cli): service-side station search for TuneIn + Radio Browser
Add a provider-neutral station orchestration layer and expose it in the
CLI so TuneIn and Radio Browser search work consistently without
depending on the speaker's (dead) cloud search. Substance of #338.

- pkg/service/stations: new package with Search/SearchNext/Navigate/
  ResolveContentItem/Play over both providers; centralises the
  SourceAccount placeholder guard.
- soundtouchweb: the six TuneIn/Radio Browser handlers become thin
  adapters over the new package (behaviour preserved; bmxpkg retained
  for HandlePlayURL).
- bmx/radiobrowser: add offset/cursor pagination
  (RadioBrowserSearchPage + RadioBrowserSearchNext) mirroring the
  TuneIn opaque-cursor pattern; BmxNext only on full pages.
- marge: classifyLearnedSource gains a provider-39 (RADIO_BROWSER)
  case + classifyAsRadioBrowser helper (candidate fix for #334
  INVALID_SOURCE; location-substring match still to be confirmed
  against a real recording).
- cli: new `station search-radiobrowser` sibling and unified
  `station find --provider tunein|radiobrowser [--more]`. The existing
  generic device-side `station search --source` is kept unchanged.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-30 20:53:40 +02:00
Tobias GesellchenandClaude Sonnet 4.6 a4b4a51cdb feat(web): add Play URL view for custom stream playback
Adds a top-level "Play URL" view (nav icon: link) so users can paste an
arbitrary stream URL and play it on any discovered device — same
browse-globally-pick-device pattern as TuneIn and RadioBrowser.

- pkg/service/bmx: extract BuildOrionLocation (encode side), shared by
  CLI and web handler; check json.Marshal error (errchkjson)
- cmd/soundtouch-cli: use bmxpkg.BuildOrionLocation instead of local
  copy; merge dual LOCAL_INTERNET_RADIO branches to reduce cyclomatic
  complexity (gocyclo)
- cmd/soundtouch-web: add --service-url / SERVICE_URL flag; expose it
  in WebApp.ServiceURL
- soundtouchweb handler: HandlePlayURL wraps raw stream in Orion
  location when ServiceURL is set (client-supplied fallback when not);
  exposes service_url in /api/version for frontend pre-fill
- soundtouchweb mount: POST /api/play-url/{id}, GET /playurl SPA route
- frontend: PlayURL.js component with device-picker overlay; AfterTouch
  URL persisted to localStorage, pre-filled from server when no override

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-29 00:28:04 +02:00
Tobias GesellchenandClaude Sonnet 4.6 6eb3829888 chore(lint): fix golangci-lint issues in navigation-station-demo
- Add package comment (revive: package-comments)
- Use index-based range loop for stations slice to avoid 160-byte copy
  per iteration (gocritic: rangeValCopy)
- Rename unused client parameters to _ in three stub functions (revive:
  unused-parameter)
- Remove custom min() helper; Go 1.21+ provides a built-in min (revive:
  redefines-builtin-id)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-29 00:28:04 +02:00
Tobias GesellchenandClaude Sonnet 4.6 e54738d367 fix(preset): wrap LOCAL_INTERNET_RADIO stream URL in Orion location
The speaker's BMX module calls GET on the stored preset location and
expects a BmxPlaybackResponse JSON from the AfterTouch Orion endpoint.
Storing a bare stream URL (e.g. http://davefmradio.no-ip.org:8000/stream)
causes BMX to receive raw ICY audio, which it cannot parse; playback
silently stays on the previous source and no error is surfaced.

Add --service-url / SOUNDTOUCH_SERVICE_URL to `preset set`. When set
alongside --source LOCAL_INTERNET_RADIO and a raw HTTP(S) location, the
CLI wraps the stream URL in the Orion station endpoint:

  <service-url>/core02/svc-bmx-adapter-orion/prod/orion/station
    ?data=<base64({"name":"…","imageUrl":"…","streamUrl":"…"})>

Without --service-url the command still works but prints a clear warning
explaining why the saved preset is likely to not play, rather than saving
a silently broken location.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-29 00:28:04 +02:00
Tobias GesellchenandClaude Sonnet 4.6 a5f5bdb916 fix(group): propagate removeGroup to all members; handle DELETE /group/
Two bugs prevented clean stereo-pair teardown:

1. removeGroup (CLI) only contacted the --host speaker (master). The
   slave never received /removeGroup and stayed stuck in GroupSlave state
   indefinitely, blocking direct playback. Fix: fetch the current group
   first, then send /removeGroup to every member in parallel — mirrors
   the same symmetry as createGroup (issue #252).

2. Speakers send DELETE /streaming/account/{id}/group/ (trailing slash,
   no group ID) during teardown. Master and slave live in different
   accounts, so each deletes its own copy independently. AfterTouch had
   no route for this form → 405. Fix: add DeleteAllGroupsForAccount to
   the datastore (scans Group_*.xml, idempotent if none found) and wire
   DELETE /group and DELETE /group/ to a new HandleMargeDeleteAccountGroups
   handler in both routing blocks.

Confirmed: after the fix both DELETE calls return 200 and the slave
exits GroupSlave state cleanly.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-27 21:28:20 +02:00
Tobias GesellchenandClaude Sonnet 4.6 58a5adde5e fix(service): include configured bind address in startup log
The 'listening on' message now shows both the configured address
(config.addr, e.g. ':8000') and the true effective address returned
by the listener (e.g. '0.0.0.0:8000'), making it immediately clear
which port was requested and which was actually bound:

  Go service listening on 0.0.0.0:8000 (configured: :8000, server URL: http://192.0.2.1)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-27 00:20:20 +02:00
Tobias GesellchenandClaude Sonnet 4.6 417c0223dd feat(health): add server_url self-reachability check + log actual listen port
The most common misconfiguration on install-on-speaker setups is an
HTTP server URL that omits the port (e.g. http://192.0.2.1 instead of
http://192.0.2.1:8000). Port 80 is occupied by the Bose firmware's
PtsServer, so AfterTouch binds its default port 8000 — but the
margeURL pushed to speakers still resolves to port 80 and hits
PtsServer instead of AfterTouch. Marge calls are silently dropped,
sources are never registered, and TuneIn playback fails with error
1005 (UNKNOWN_SOURCE_ERROR). See issue #319.

Changes:
- pkg/service/health/checks_server_url.go — new health check
  (server_url_reachable) that probes GET {serverURL}/setup/version from
  inside the service; emits SeverityWarning with remediation steps when
  the endpoint is not reachable or returns non-200.
- pkg/service/handlers/server.go — register the new check in NewServer.
- cmd/soundtouch-service/main.go — replace http.ListenAndServe with an
  explicit net.Listen so the true effective port is logged before TLS
  starts. Both HTTP and HTTPS log lines now show the listener's actual
  bound address alongside the configured server URL:
    Go service listening on 0.0.0.0:8000 (server URL: http://192.0.2.1)
  Previously only the server URL was logged, creating the false
  impression that AfterTouch had bound that URL's implicit port.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-27 00:20:20 +02:00
Tobias GesellchenandClaude Sonnet 4.6 aafc5ba3f9 fix(datastore): stop INTERNET_RADIO from being re-added on service restart
initializeDefaultSources() called GetDefaultSources(), which includes
the legacy INTERNET_RADIO stub (ID 10002). On every service start it
would re-add that entry to any device whose Sources.xml had it removed
— including devices where the stale_internet_radio health-check quick
fix was applied — silently undoing the clean-up.

getAccountSources() in marge.go had the same issue: it passed the full
default list into the /full cloud response, causing a phantom
"sources_xml_diff" Info finding after a clean-up.

Fix: export the existing private getInitialSources() as
GetInitialSources() (excludes INTERNET_RADIO) and use it in both call
sites instead of GetDefaultSources().

Existing devices that still have INTERNET_RADIO in their Sources.xml
are unaffected: the merge loop only appends entries that are missing,
so a present entry is preserved (the token is refreshed as before).

Update unit and integration test expectations accordingly: the no-device
fallback now returns 3 cloud sources (LOCAL_INTERNET_RADIO, TUNEIN,
RADIO_BROWSER) instead of 4 (dropping INTERNET_RADIO / ID 10002).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-26 22:51:35 +02:00
Tobias GesellchenandClaude Sonnet 4.6 2722e2383c fix(lint): sec6/sec7 post-pass — static.go Close + remove unused sanitizeErr
- stockholm/static.go: wrap deferred root.Close() in func(){}() to
  silence errcheck; change 'rel = rel + ...' to 'rel += ...' (gocritic).

- Remove sanitizeErr from four logutil files where no call site exists
  (cmd/soundtouch-cli, cmd/websocket-demo, pkg/discovery, pkg/service/setup).
  The log-injection fixes in those packages used sanitizeLog on string
  arguments rather than sanitizeErr on error values.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-25 11:28:26 +02:00
Tobias GesellchenandClaude Sonnet 4.6 0e9445af47 fix(security): sec7 — log-injection sweep, sanitizeErr helper
~30 remaining go/log-injection alerts share a common pattern: other
positional args in a log call are wrapped in sanitizeLog() but the
trailing 'err' value (via "%v") is not. CodeQL traces taint through
error chains back to the log.Printf call site itself.

Add sanitizeErr(err error) string to every affected package's
logutil.go (strips newlines from err.Error(), returns "<nil>" when
nil). Three packages had no logutil.go yet; new files added for
cmd/soundtouch-cli, cmd/websocket-demo, and examples.

Call-site changes (replace "%v, err" with "%s, sanitizeErr(err)" and
wrap any other unsanitised args in sanitizeLog):

pkg/client:
  - websocket.go:42   DefaultLogger.Printf now pre-formats and sanitises
                       the entire message (all variadic args sanitised)
  - websocket.go:445  err → sanitizeErr(err)

pkg/service/handlers:
  - handlers_account_mgmt.go:44   err
  - handlers_bmx_tunein.go:324,336 err (stationID already sanitised)
  - handlers_marge.go:288,510      err (deviceID/account already done)
  - handlers_mgmt.go:409,436,720  err
  - handlers_setup.go:1345        session + err
  - server.go:500                  bind
  - server.go:504,863,944,1029,   err (deviceIP/accountID already done)
    1164,1174

pkg/service/marge:
  - marge.go:1469,1923  saveErr / err

pkg/service/setup:
  - setup.go:1417,2316,2462  fmt.Printf — deviceIP / hostsContent / ip

pkg/service/stockholm:
  - proxy.go:117  effectiveTarget.String() + err

pkg/service/zeroconf:
  - zeroconf.go:312  err

pkg/service/proxy:
  - recorder.go:403  err (task.path already sanitised)

pkg/service/datastore:
  - datastore.go:940  werr (device already sanitised)

pkg/discovery:
  - dns.go:72   strings.Join(derived)
  - dns.go:503  d.upstreamDNS (fmt.Sprint of []string)

cmd/soundtouch-cli:
  - cmd_events.go:571  VerboseLogger.Printf — pre-format + sanitise
  - common.go:335      PrintError message

cmd/websocket-demo:
  - main.go:576   VerboseLogger.Printf — pre-format + sanitise

examples:
  - recording-filename-demo.go:79  err

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-25 11:28:26 +02:00
Tobias GesellchenandClaude Sonnet 4.6 dc8ec69c61 sec5e: sanitize log-injection in client, discovery, testutils, cmd
Fixes CodeQL go/log-injection alerts in the final batch of packages.

New logutil.go helpers: pkg/client, pkg/testutils/amazon,
pkg/testutils/spotify, cmd/soundtouch-service, cmd/soundtouch-web,
cmd/dummy-speaker, cmd/mdns-scanner.

pkg/discovery/logger.go: added sanitizeLog and a nil-safe
remoteAddrString helper to the existing file (alongside logVerbose).

Call sites wrapped across 11 files — device IDs, source types,
hostnames, IPs, interface names, URLs, service names, HTTP method/form
values, WebSocket URLs and payloads, TLS SNI names, remote addresses.

No behaviour change. golangci-lint and make check pass.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-24 17:29:39 +02:00
Tobias GesellchenandClaude Sonnet 4.6 96a8eda1a4 fix: update cross-repo doc links after Jekyll-to-Hugo restructure
Files in cmd/ examples/ scripts/ referenced docs/guides/ and docs/reference/
which moved to docs/content/docs/guides/ and docs/content/docs/reference/.
A few links to loose files at the docs/ root were updated to their new
location under docs/content/docs/appendix/.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-24 13:30:36 +02:00
Tobias Gesellchen 439b2cb9fc TODO We need to ensure that ids here are consistent with the ones used in the AfterTouch service. 2026-05-24 10:23:38 +02:00
Tobias GesellchenandClaude Sonnet 4.6 fbc96c0c01 fix(cli): make --service-url required, remove default
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-23 22:45:15 +02:00
Tobias GesellchenandClaude Sonnet 4.6 9172072601 feat: add source removal — health check, API endpoint, and CLI commands
Health check (checks_stale_internet_radio.go): detects stub INTERNET_RADIO
sources (empty credentials) left on devices initialised before the stub was
removed from the default source list. Quick-fix removes by ID; skips any
INTERNET_RADIO source that has real credentials.

Datastore: DeleteSourceByID and DeleteSourceByType (uniqueness-guarded).

API: DELETE /setup/sources/{account}/{device}/{sourceID}

CLI — two new commands:
  soundtouch-cli cloud source remove --service-url ... --account ... --device ... [--id 10002 | --type INTERNET_RADIO]
    Talks to AfterTouch (service side). --type resolves to canonical ID
    locally; fails for unknown types.
  soundtouch-cli source notify-updated --host <speaker-ip>
    Talks to the speaker directly. Fetches device ID from /info, then
    POSTs sourcesUpdated to :8090/notification so the speaker re-fetches
    its source list immediately.

CloudCommonFlags (--service-url / AFTERTOUCH_URL) mirrors CommonFlags
(--host) for AfterTouch-facing command groups.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-23 22:45:15 +02:00
Tobias GesellchenandClaude Sonnet 4.6 11a6515f4d feat(tunein): add section-grouped results and load-more pagination
TuneIn's profiles API caps initial results at ~10 per container (Stations,
Shows, etc.) and exposes a Pivots.More.Url cursor for the remainder. This
change wires that cursor through the stack so users can load additional
results without leaving the search view.

- tuneInSearchSection now extracts Pivots.More.Url as bmx_next when
  itemToken is present; absent for containers already at their limit
- TuneInSearchNext fetches the cursor URL, which returns a flat Items[]
  (not nested containers), and maps Station/Program/Topic items using
  the existing play/profile builders
- New GET /v1/search/next and /api/tunein/search/next endpoints with
  matching handlers in both service paths
- TuneInBrowser: flat items state replaced with per-section sections
  state; each section shows a header label and a Load more button when
  a cursor is available; browse/navigate mode is unaffected

Relates to #336.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-23 14:25:01 +02:00
Tobias GesellchenandClaude Sonnet 4.6 93248659a5 fix(tls): also cover derived OAuth subdomain in served cert SAN list
#337's first commit added the OAuth-derivation to the DNS interceptor
but missed the served TLS certificate. With a serverURL of
`http://mac.fritz.box:8000` the cert SAN list covered `mac.fritz.box`
but not `macoauth.fritz.box`, so the speaker would resolve the OAuth
host correctly (via the new DNS hijack) and then immediately fail the
TLS handshake — Spotify / Amazon Music token refresh dies before
reaching AfterTouch.

getDomains now calls discovery.DeriveOAuthHostnames(serverURL) and
discovery.DeriveOAuthHostnames(httpsServerURL), feeding the derived
names into the SAN map alongside the existing entries. IP-based
serverURLs continue to produce no derivation (the OAuth construction
is unrecoverable for them — see the existing oauth_target_reachable
health check).

Tests in cmd/soundtouch-service/main_test.go lock in:
  - Hostname serverURL → derived OAuth variant present in SAN list.
  - IP serverURL → no malformed `192oauth.…` entry leaks in.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-23 00:34:36 +02:00
Tobias GesellchenandClaude Sonnet 4.6 89bfa8c2fb feat(discovery): quiet per-packet logs by default; CLI keeps verbose
Discovery cycles emit one line per UPnP M-SEARCH header, one per
parsed response, and one per enrichment step — by default. A typical
service-binary cycle prints ~50–80 lines for a 3-speaker LAN. Most
operators want a startup-and-summary view; the per-packet trace is
only useful for debugging.

- New SetVerbose/IsVerbose/logVerbose helpers in pkg/discovery (atomic
  bool, zero-value off).
- Chatty log.Printf calls in upnp.go and mdns.go demoted to logVerbose:
  per-header dumps, per-response dumps, per-device enrichment steps,
  M-SEARCH details, read-deadline / cancel-context noise.
- Kept at default level: discovery start ("Starting SSDP discovery
  for…"), end ("Discovery completed. Processed N responses, found N
  unique devices" + per-device summary), warnings ("Configured
  interface not found", "Failed to fetch device description", …), and
  the new "Rejecting non-Bose device" classifier.
- cmd/soundtouch-cli/discover devices grew a --verbose / -v flag that
  flips the package toggle on; the service binary leaves it at the
  zero value.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-22 21:15:34 +02:00
Tobias GesellchenandClaude Sonnet 4.6 3a0b30bc33 feat(tls): persist TLSExtraHosts + Settings UI + speaker_marge_url QuickFix
Operators who deploy AfterTouch on an IP-only host (no DNS hostname) and
who get a speaker_marge_url health warning previously had to SSH in, edit
their systemd unit or docker-compose, add --tls-extra-host, and restart.
The fix is now reachable from the UI:

- datastore.Settings gains TLSExtraHosts []string. At startup
  applyPersistedSettings merges CLI/env values (still authoritative)
  with persisted ones, deduplicating while preserving order.
- /setup/settings (GET) exposes tls_extra_hosts (editable list) and
  tls_san_hosts (the full effective SAN list, read-only).
- /setup/settings (POST) accepts tls_extra_hosts (*[]string so callers
  can distinguish "field omitted" from "explicitly empty").
- Settings tab grows a "TLS extra hosts" textarea + an info panel
  explaining the restart-required dance.
- speaker_marge_url emits a QuickFix labelled "Add <host> to TLS hosts"
  alongside the existing CLI manual command. The fix re-probes the
  device's /info, extracts the margeURL host, and appends it to the
  persisted list — race-safe against stale findings.
- HTTPS-SETUP.md documents both paths.

Tests cover: merge dedup + ordering + whitespace, the new QuickFix
emission shape, and the margeURL host extraction across HTTPS/HTTP/bare
input forms.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-22 20:32:44 +02:00
Tobias GesellchenandClaude Sonnet 4.6 377fa9ceda feat(preflight): skip :443 check in HTTP-only deployments + OUTPUT-chain caveat
The :443 reachability preflight was emitting a WARN on every deployment
where AfterTouch's configured --server-url is HTTP (not HTTPS), even
when speakers were migrated to that HTTP URL and never connect to :443.
Operators reproduced this on #218 (CTonyPeterson) and #344
(california444) — both saw the warning even though their setups had no
need for iptables port forwarding, and CTonyPeterson followed the
recommended iptables OUTPUT rule which then caught his host's own
outbound HTTPS traffic and broke `go install` and his browser.

Two changes:

- Probe443Result gains NotApplicable + Reason. Check443Reachability
  returns the NotApplicable verdict when the parsed serverURL scheme is
  http. The settings UI renders an ℹ️ info badge with the reason instead
  of a red ✗.
- FormatPreflightGuidance grows a one-line caveat about the iptables
  OUTPUT chain: it catches all outbound :443 on the host, including
  browsers / go install / apt-get, which is rarely what the operator
  wants.

HTTPS-SETUP.md gains the same caveat plus a section documenting the
new not-applicable verdict.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-22 20:32:44 +02:00
Tobias GesellchenandClaude Sonnet 4.6 a1754a4500 feat(setup): remote_services CLI integration
- setup remote-services subcommand: enables (default) or removes
  (--remove) the remote_services SSH-enablement marker via SSH, targeting
  persistent locations (/etc or /mnt/nv) before the volatile /tmp fallback
- setup plan now includes a "persist remote_services" step when the marker
  is only in /tmp (would be lost on next reboot, breaking SSH mid-migration)
- setup plan state header shows a [⚠] line when remote_services is
  enabled but not persistent

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-22 19:09:41 +02:00
Tobias Gesellchen 722b2ca9a6 lint 2026-05-22 19:04:13 +02:00
Tobias GesellchenandClaude Sonnet 4.6 2ec5efde7f feat(setup): dual DNS preflight check — CLI and speaker perspectives
Replaces the single-sided requireAfterTouchDNSReachable with runDNSPreflight
that probes both the CLI machine and the speaker (via SSH nslookup) in
parallel, then renders a two-row table when results differ.

The speaker's perspective is authoritative: a CLI-only failure no longer
blocks the migration (the speaker may reach the DNS listener via a network
path the CLI host cannot). Migration is only aborted when the speaker itself
definitively cannot reach AfterTouch's DNS listener.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-22 19:04:13 +02:00
Tobias Gesellchen ff61f0ca65 lint 2026-05-22 18:58:57 +02:00
Tobias GesellchenandClaude Sonnet 4.6 b0df8ba963 fix(setup): correct false-positive migration detection and plan command errors
- isXMLMigrated and isResolvConfMigrated now guard against empty hostname
  (Go's strings.Contains(s, "") is always true, causing any speaker to
  appear migrated when --service-url has a malformed single-slash scheme)
- renderPlanSteps message no longer claims "and paired" when --include-pair=false
- validateServiceURL rejects malformed service URLs early with a hint
  (e.g. "did you mean https://soundtouch.fritz.box?")
- Generated plan-step commands move --host before the subcommand name
  (urfave/cli/v2 requires global flags before the first subcommand token)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-22 18:58:57 +02:00
Tobias GesellchenandClaude Sonnet 4.6 3cfb3da498 feat(export): encrypted diagnostic report for issue reporting
Adds a "Download diagnostic report" button on the Health tab that
produces an age-encrypted .age file the user can attach to a GitHub
issue without exposing sensitive data.

Archive contents (tar.gz, then age-encrypted with the maintainer's
SSH ed25519 public key):
- diagnostic.json         structured health/device summary (no secrets)
- datastore/…/*.xml       raw on-disk XML verbatim for diff vs HTTP
- http/service/…          live service HTTP responses per account/device
- http/speaker/…          live speaker API responses (port 8090)
- ssh/speaker/…           CA bundles + logread (last 20 min, 127.0.0.1
                          filtered) + dmesg fetched via SSH
- system/ca.pem           service CA cert
- system/resolv.conf      host DNS resolver config
- settings.json           service settings (OAuth secrets redacted)
- env.txt                 filtered process environment
- logs/service.txt        in-memory service log buffer

Supporting tooling:
- scripts/setup-diagnostic-key.sh  one-time SSH key-pair generation
- scripts/decrypt-diagnostic.go    go run helper for maintainer decryption
- keys/public/diagnostic.pub       committed public key (matches github.com/gesellix.keys)
- docs/DIAGNOSTIC-EXPORT.md        maintainer setup + user workflow guide
- docs/concepts/ENCRYPTED-EXPORT.md  research notes and architecture rationale

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-21 20:02:57 +02:00
Tobias GesellchenandClaude Opus 4.7 29d611f9c1 feat(health): aggregate device-summary panel on Devices tab
Audit item #1 (11+ recurrences in issues / discussions): pull
speaker /info + /sources + /presets, plus service-side state
and pairing inference, into one view per device.

Backend: GET /setup/device-summary/{deviceId} probes the three
speaker endpoints concurrently (sync.WaitGroup, 3 s per probe)
and merges the result with what the datastore knows for the
same device. Partial failures don't break the response — each
sub-section carries its own reachability + error + curl_command
so the UI can render copy-paste fallbacks when the service host
can't reach the speaker.

JSON shape covers four panels:
  - device      identity + firmware
  - speaker     {info, sources, presets} with raw outcomes
  - service     server URL, expected hosts, Sources.xml /
                Presets.xml presence and counts
  - pairing     paired flag, marge host, host match

UI: new "Inspect" button per row on the Devices tab. Clicking
expands a sibling row with five summary cards (info / sources /
presets / service / pairing). Each unreachable card renders the
matching curl command with a Copy button — same dual-mode
pattern as Health findings. Closes the gap operators were
filling by manually concatenating curl output across the three
speaker endpoints when filing bug reports.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-19 23:20:40 +02:00
Tobias GesellchenandClaude Opus 4.7 147a69d1c3 refactor(ding): synthesise on demand instead of vendoring the WAV
Move the ding renderer into pkg/service/ding so it can run both
at request time (from the new HandleDing handler) and offline
(from the existing scripts/gen-aftertouch-ding CLI, now a thin
wrapper around the same package).

- GET /media/aftertouch-ding.wav synthesises on first call,
  caches the default-options bytes via sync.Once, and accepts
  query-string overrides for every knob (pitch-{high,mid,low},
  chirp-ms, gap-ms, attack-ms, release-ms, sample-rate, peak).
  Invalid / out-of-range values silently fall back to defaults.
- Embedded WAV is gone from VCS — no 52 KB binary in the
  repo, and tweaking the sound is now a query-param away rather
  than a regenerate-and-commit cycle.
- Health-tab playback_test check is unchanged: the URL it
  references (/media/aftertouch-ding.wav) keeps the same shape,
  the handler just produces the bytes dynamically now.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-19 23:20:40 +02:00
Tobias GesellchenandClaude Opus 4.7 dee5a0146c feat(health): check speaker <margeURL> against configured hosts
For each device, probe /info and extract the <margeURL> the
speaker is configured to talk to. Compare the hostname against
the service's expected-hosts list (serverURL host +
httpsServerURL host + --tls-extra-host values).

When the speaker is pointed at a host AfterTouch doesn't claim,
emit a warning with two pieces of context:
  - the actual <margeURL>, so the operator sees the drift
  - a copyable `soundtouch-service --tls-extra-host=<host>`
    suggestion, which is the right fix when the speaker should
    keep talking to AfterTouch via the unexpected hostname (the
    other fix is re-migration, which is mentioned in the details).

Reachability / parse failures are intentionally silent here —
speaker_info_reachable already covers those, no need to double-warn.

Required plumbing: Server.SetExpectedHosts so main.go can pass
config.domains in, plus an ExpectedHosts() getter the closure-form
registration reads at run time.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-19 23:20:40 +02:00
Tobias GesellchenandClaude Opus 4.7 c3723dc0e6 feat(service): add Logs tab streaming the live stderr trace
Cloud-deploy operators on Discussion #295 needed to leave the
admin UI for docker logs / journalctl to see what the service was
doing. Mirror log.Default() output into an in-memory ring buffer
and expose it under /setup/logs so the admin UI can show a live
trace alongside the existing tabs.

The buffer is a second sink under log.SetOutput(io.MultiWriter(
os.Stderr, buf)) — stderr keeps receiving every line verbatim,
so docker logs / journalctl are unaffected. Default capacity
2000 lines (~400 KB), tunable via SOUNDTOUCH_LOG_BUFFER_LINES.

- pkg/service/logbuf: io.Writer ring with \n splitting,
  partial-line buffering, monotonic Seq, Since(since, limit)
  reporting dropped count when the caller falls behind.
- New /setup/logs (GET) returns {entries, nextSince, dropped,
  capacity}. Polls at 1.5s while the tab is active; paused on
  document.hidden.
- "8. Logs" tab with substring filter, tail-follow toggle
  (auto-disables when the user scrolls up), monospace dark view.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-19 20:12:25 +02:00
Tobias GesellchenandClaude Opus 4.7 f791145976 feat(service): add Health tab with datastore checks and quick fixes
Discussion #295 surfaced that a paired device without Sources.xml
silently breaks playback — /full omits TUNEIN and selection fails
with 1005. initializeDefaultSources only runs at startup over
existing devices, so a device that checks in later is never
seeded.

Add a Health tab to the admin UI that runs registered checks
against the datastore and offers one-click remediations. The
first check flags missing Sources.xml per device; its quick fix
writes the canonical defaults via SaveConfiguredSources. The
check/fix registry is designed so adding Presets.xml,
Recents.xml, or future reachability probes is a one-file diff.

- New /setup/health (GET) and /setup/health/fix (POST) routes
- pkg/service/health: Registry, Check, Finding, QuickFix types
- Sources.xml-present check + create_default_sources fix
- "7. Health" tab in pkg/service/handlers/web/

Inspired by issue #327's MAINTENANCE tab proposal; curl/URL
helper content from that issue can slot into the same tab in
a follow-up.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-19 20:00:16 +02:00
Tobias Gesellchen 68760a8977 feat(service): add --tls-extra-host for additional TLS cert SAN entries
The leaf cert generator already routes IP-shaped entries into the
IPAddresses SAN, and getDomains already feeds it the hostnames parsed
from --server-url and --https-server-url. Add an explicit
--tls-extra-host flag (repeatable, env TLS_EXTRA_HOST) for the
remaining cases: multi-homed hosts, reverse-proxy frontends, or
browsing the admin UI via a LAN IP that isn't part of the configured
server URLs.

Resolves the ERR_CERT_COMMON_NAME_INVALID Chrome refuses when the URL
bar hostname (e.g. the host's LAN IP) isn't in any cert SAN, even
when the local CA is trusted.
2026-05-18 23:09:28 +02:00
Tobias Gesellchen b95bdae751 feat: Add RadioBrowser integration alongside TuneIn support
- Refactors BMX service to support multiple radio providers
- Adds RadioBrowser.com API integration with search and browse
- Splits TuneIn logic into separate module for better organization
- Adds new web UI components for radio station discovery
- Includes new SVG icons for RadioBrowser branding
2026-05-18 22:34:26 +02:00
Tobias GesellchenandClaude Opus 4.7 7e696ea000 refactor(soundtouch-web): package owns discovery + route registration
Replays app's commit-1 architectural restructure onto current main —
mechanical move only, behaviour preserved verbatim. main.go shrinks
from 333 to ~190 lines; the binary now orchestrates lifecycle and
flag handling, the package owns the WebApp's responsibilities.

Moves (no logic change vs the previous main.go bodies):

  main.go addDevice         → (*WebApp).AddDeviceByHost in discovery.go
  main.go discoverDevices   → (*WebApp).DiscoverDevices in discovery.go
  main.go setupRoutes       → (*WebApp).Mount(r, ds) in mount.go
  inline serveIndex closure → (*WebApp).serveIndex in mount.go

New helper:

  soundtouchweb.NewDiscoveryService(interfaceName) wraps
  config.LoadFromEnv + cfg adjustments + NewUnifiedDiscoveryService.
  Single source of truth for the web UI's discovery settings;
  identical to the inline wiring main.go used to do.

main.go still owns (kept verbatim, post-base on main):

- --port / --bind / --interface / --devices flags
- resolveBindAddr (NIC-name → IP resolution for --bind)
- defaultDiscoveryInterface (--bind ↔ --interface defaulting)
- Startup goroutine sequence: broadcast start → preseed loop
  (AddDeviceByHost for each --devices entry) → DiscoverDevices →
  broadcast complete + device list
- http.ListenAndServe

Behaviour parity checklist:

- Routes registered: identical set (see Mount). /api/discover still
  reuses the startup discoveryService instance, same as before.
- Preseeded --devices still added BEFORE the mDNS/UPnP sweep, so the
  UI doesn't briefly show empty for hosts that come from --devices.
- Discovery interface still pinned via --interface (or inherited from
  --bind), threaded through NewDiscoveryService.
- Static FS still served at /static/*, SPA fallback at / /devices
  /device/* still hits the same index.html.

go build ./... clean. go test ./... clean (only pre-existing
TestDocsConsistency fails, untracked-file issue, unrelated).
golangci-lint run ./... 0 issues.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-18 22:34:26 +02:00
Tobias GesellchenandClaude Opus 4.7 9c5ba43fb3 feat(soundtouch-web): swap vanilla Bootstrap UI for Preact+htm SPA
Brings forward the frontend rewrite from the `app` branch
(6723515 + later refinements) onto the relocated package layout.
The Go side untouched — main.go's orchestration, discovery, routes,
and handlers all remain. Only the static-asset layer changes.

Frontend (lives in pkg/service/soundtouchweb/static/):

- index.html (importmap-driven, ES modules, no build step)
- css/app.css (CSS-custom-property design system, dark by default)
- js/api.js (typed-ish fetch wrappers)
- js/app.js (Preact App shell: routing, toast, websocket reconnect)
- js/components/{DeviceList,NowPlaying,Controls,Presets,Sources,
                 Recents,Zone,TuneInBrowser}.js
- img/favicon.{ico,svg}
- lib/{preact,preact-hooks,htm}.module.js (vendored ES modules)

Backend wiring:

- New pkg/service/soundtouchweb/embed.go exports `StaticFS embed.FS`
  via `//go:embed static`. main.go drops its own `//go:embed` and
  consumes `soundtouchweb.StaticFS` instead, so the static tree
  lives alongside the handlers it serves.
- cmd/soundtouch-web/static/{index.html,css/app.css,js/app.js} are
  deleted; the old `cmd/soundtouch-web/static/` directory is empty
  now and removed entirely.

Path rename vs. app branch:

- app's importmap pointed at `/static/vendor/preact*.js` and the
  vendor files were never committed because `.gitignore:44 vendor/`
  silently masked them. Renamed to `/static/lib/` to escape the
  global rule and `git add`-ed the three modules.

Known regressions vs. main's vanilla UI (acceptable for this commit;
flag in review or follow-up if any matter):

- Per-card power toggle on the device list — Preact only exposes
  power inside the device-detail view, not on the list card.
- WebSocket reconnect uses `location.reload()` after 5s; main had
  exponential backoff. Functional, simpler, less elegant.
- Theme icon control absent (Preact UI is dark-only via CSS vars;
  no light-mode toggle).

Features carried over and confirmed at the route-shape level:
device list / device detail / nowPlaying / volume+key+power controls
/ presets / sources / TuneIn search + browse + play / discovery /
toasts / WebSocket status updates.

go build ./... clean. go test ./... clean (only pre-existing
TestDocsConsistency fails). golangci-lint run ./... 0 issues.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-18 22:34:26 +02:00