Commit Graph
29 Commits
Author SHA1 Message Date
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 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 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 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 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 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
Tobias GesellchenandClaude Opus 4.7 42257aeebc refactor(soundtouch-web): relocate handlers/webtypes to pkg/service/soundtouchweb
Mechanical relocation only — zero semantic change. Sets up the package
layout that the future Preact-UI rewrite (branch `app`) wants, while
preserving every line of main's current logic. Subsequent commits will
land the additive parts (frontend rewrite, recents, zones, bass control)
on top of this clean base.

Moves (`git mv`, content unchanged except package decl):

  cmd/soundtouch-web/handlers/handlers.go      → pkg/service/soundtouchweb/handler.go
  cmd/soundtouch-web/handlers/handlers_test.go → pkg/service/soundtouchweb/handler_test.go
  cmd/soundtouch-web/handlers/websocket.go     → pkg/service/soundtouchweb/websocket.go
  cmd/soundtouch-web/handlers/registry_test.go → pkg/service/soundtouchweb/registry_test.go
  cmd/soundtouch-web/webtypes/types.go         → pkg/service/soundtouchweb/webtypes/types.go
  cmd/soundtouch-web/webtypes/types_test.go    → pkg/service/soundtouchweb/webtypes/types_test.go
  cmd/soundtouch-web/webtypes/status_test.go   → pkg/service/soundtouchweb/webtypes/status_test.go
  cmd/soundtouch-web/static/img/tunein-{dark,mono}.svg → pkg/service/soundtouchweb/static/img/

Adjustments:

- `package handlers` → `package soundtouchweb` in the 4 moved handler-tier
  files (plus their package-doc comments).
- Import paths rewritten in cmd/soundtouch-web/{main.go,spa_test.go} and
  in the moved files themselves: cmd/soundtouch-web/{handlers,webtypes}
  → pkg/service/soundtouchweb/{,webtypes}.
- `handlers.` selector renamed to `soundtouchweb.` in the callers.
- `.golangci.yml` errcheck waiver extended from `cmd/.*\.go` to also
  cover `pkg/service/soundtouchweb/.*\.go`. Same code that the
  cmd-tier waiver applied to; same waiver follows it. Documented as
  a carry-over with the intent to tighten in a follow-up review.

Not changed:

- `cmd/soundtouch-web/main.go` keeps the `//go:embed static` pointing at
  the still-vanilla `cmd/soundtouch-web/static/`. The frontend rewrite
  (Preact UI) lands in a later commit; this one is mechanical.
- `cmd/soundtouch-web/resolve_bind_addr_test.go` stays put — it tests
  main.go-local flag plumbing.

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 feadc478d5 test: sweep example data in test files to RFC-5737 + placeholders
Mirrors the .md/.txt sweep across all tracked _test.go, testdata XML,
and .http integration files. Test files are self-contained (producer
+ assertion in the same file), so the matched-pair swap stays green
under `go test ./...`.

Mapping applied:
  192.168.178.[0-9]+   → 192.0.2.[same]
  192.168.1.[0-9]+     → 192.0.2.[same]
  Sound Machinechen    → Living Room SoundTouch
  A Sound Machine      → Kitchen SoundTouch
  A81B6A536A98 + case/separator variants → AABBCCDDEEFF (etc.)
  A81B6A849D99         → AABBCCDDEE01
  A81B6A849D88         → AABBCCDDEE03
  A81B6A536A09         → AABBCCDDEE04
  884AEAEEBD27         → AABBCCDDEE02
  3230304              → 1000001
  9569497              → 1000002

Two semantic fixes alongside the bulk swap:

- pkg/service/zeroconf/zeroconf_test.go: the "private 192" and
  "strips query" cases pin acceptance of RFC-1918 192.168/16. They
  must use a real 192.168 value; doc-range IPs would (correctly) be
  rejected by validateZcBaseURL. Switched to 192.168.10.10 — generic
  enough not to match any home LAN default, real enough for the
  validator. Added a comment explaining why this single test still
  carries a 192.168 literal.

- pkg/service/setup/setup_test.go: TestTestDNSRedirection mocks the
  device's `od -An -tu1` byte output, which is space-separated
  octets ("192 168 1 100"). My sed only matched the dot-separated
  form, so the mock was returning the old IP while the test
  assertions had moved to the doc range. Updated to " 192 0 2 100".

go build ./... clean. go test ./... clean (only TestDocsConsistency
remains failing, which is a pre-existing/untracked-file issue).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-17 22:05:13 +02:00
Tobias GesellchenandClaude Opus 4.7 1b21e0eaa8 docs: sweep example LAN IPs to RFC-5737 documentation range
Phase 4 of the docs portion of the rfc-5737-cleanup. Replaces all
192.168.1.x example IPs in tracked .md / .txt files with the
equivalent last-octet under 192.0.2.x.

192.168.1.x is RFC-1918 private space and routes on real networks,
which leaves readers guessing whether a documented IP is a placeholder
or a documented LAN. 192.0.2.0/24 is reserved by RFC 5737 exclusively
for documentation — readers know on sight that they're examples.

58 files touched, 551 line pairs. Includes .github issue/PR templates,
all docs/ references, example READMEs, and one script doc. No code
changes, no test changes; test files still carry the 192.168.1.x
placeholder pending Phase 2 in _/RFC-5737-cleanup/assessment.md.

Also fixed a small fallout in docs/analysis/ANONYMIZATION-SUMMARY.md
where the explanatory sentence "a reader can't tell whether
192.168.1.10 is a placeholder or a documented LAN address" had
itself been swept by the regex (inverting the point); restored the
literal example and noted the sweep progress inline.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-17 22:05:13 +02:00
Tobias GesellchenandClaude Opus 4.7 4e7a20f7ec refactor(soundtouch-web): make DeviceConnection.Status atomically swappable
Status was a value-typed DeviceStatus field on DeviceConnection,
written from the periodic poller (UpdateDeviceStatus) and from four
WebSocket event handlers (OnNowPlaying, OnVolumeUpdated,
OnConnectionState, OnPresetUpdated) while being read from every HTTP
handler and the WebSocket broadcaster. The struct was 8+ words wide
with time.Time and string members, so concurrent readers could
observe torn fields or mixed-update snapshots. The map-level race was
fixed in the previous commit; this one closes the per-connection
struct race.

Hide the field behind atomic.Pointer[DeviceStatus]:

  Status()                                 // returns current snapshot
  SetStatus(*DeviceStatus)                 // wholesale replace
  UpdateStatus(func(*DeviceStatus))        // CAS retry loop

NewDeviceConnection constructs a connection with the atomic pointer
pre-initialised, so Status() never returns nil for callers that go
through the constructor (the old struct-literal pattern is no longer
possible because the status field is now private).

UpdateDeviceStatus runs network fetches into local vars first, then
batches them into a single UpdateStatus call so the CAS loop only
retries the merge — not the slow IO. WebSocket event handlers and
the connect/disconnect transitions each use UpdateStatus, so any
ordering of poller + event delivery converges to a consistent
status.

The UpdateStatus docstring is explicit about the shallow-copy
contract: nested pointer fields (NowPlaying, Volume, Bass, Presets,
Sources) MUST be replaced, not mutated through, because the copy
mut receives shares those pointers with the prior snapshot. All
production callers already follow this pattern (every value comes
fresh from the device API).

Tests:
  - types_test.go: migrated literal struct to NewDeviceConnection +
    SetStatus, switched reads to Status().
  - status_test.go (new): six tests covering constructor init,
    SetStatus replacement semantics, UpdateStatus mutator
    application, field preservation across UpdateStatus, snapshot
    isolation (old snapshot stable under later writes), and a
    concurrent stress test (16 writers + 32 readers x 200 ops) that
    runs under -race.
  - handlers_test.go, registry_test.go, spa_test.go: migrated to
    constructor.

Not addressed by this commit:
  - DeviceConnection.WebSocket (set once in ConnectDeviceWebSocket,
    read elsewhere). Word-sized pointer, atomic at the hardware
    level on amd64/arm64; race detector may still flag.
  - DeviceConnection.LastSeen (written under devicesMu by the
    registry, read outside that lock via DeviceSnapshot consumers).
    time.Time is non-atomic but the read is cosmetic.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-17 11:14:17 +02:00
Tobias GesellchenandClaude Opus 4.7 e7d1b44587 refactor(soundtouch-web): encapsulate WebApp device registry behind methods
The Devices map on WebApp was written from the startup goroutine, the
/api/discover POST handler, and addDevice, while being read from every
HTTP handler and the WebSocket periodic-update loop — all without any
mutex. The Go runtime panics with "fatal error: concurrent map writes"
or "concurrent map read and map write" on any actual collision, so this
was a latent crash, not a tearing issue.

Hide the map behind a sync.RWMutex and a small API:

  GetDevice(id) (*DeviceConnection, bool)
  DeviceSnapshot() []DeviceEntry
  DeviceCount() int
  AddDevice(id, conn) bool        // atomic insert-or-touch
  TouchDevice(id) bool            // fast-path LastSeen bump

Update every caller — handlers, websocket, main, tests — to go through
the API. addDevice's existing-host fast path uses TouchDevice; the
final insert uses AddDevice so a race with another writer is rejected
cleanly instead of silently overwriting.

Add a TestRegistryConcurrent stress test that runs 64 goroutines doing
12,800 operations across writers, touchers, and two reader patterns.
It exists to give `-race` (already on in CI) a concrete shape to catch
if the encapsulation ever leaks back out.

Struct-field races on conn.Status.* are not addressed by this change;
they need their own follow-up.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-17 11:14:17 +02:00
Tobias GesellchenandClaude Opus 4.7 2c50ce3ee8 refactor(soundtouch-web): unify manual and discovered device registration
`addManualDevice` and the per-device branch of `discoverDevices` were
~40 lines of near-identical client setup, info fetch, connection
build, and map write — differing only in log wording. Extract a
shared `addDevice(app, host, port, source)` helper used by both
paths.

Side effects of consolidating:

- Duplicate-host guard (LastSeen bump) now applies to both paths, so
  passing `--devices 1.2.3.4` twice is idempotent and matches how
  discovery treats repeat sightings.
- Map write happens before the UpdateDeviceStatus goroutine launch,
  so a concurrent GET /api/devices sees the device with
  `IsConnected: false` instead of racing the status update.
- Log wording is consistent: "Failed to fetch device info from <host>
  (<source>): <err>" and "Added <source> device <name> (<type>) at
  <host>:<port>".

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-17 11:14:17 +02:00
Tobias Gesellchen 1269481411 lint 2026-05-17 10:30:25 +02:00
chrizgandTobias Gesellchen 712801259e feat(soundtouch-web): rename --host to --devices, support multiple devices via StringSliceFlag 2026-05-17 10:30:25 +02:00
chrizgandTobias Gesellchen 46546f5494 feat(soundtouch-web): add --host flag for manual device IP 2026-05-17 10:30:25 +02:00
Tobias GesellchenandClaude Opus 4.7 e496974f0d feat(web): default --interface to --bind's interface name
When the user passes --bind <iface> and doesn't set --interface,
discovery now reuses the same interface name instead of auto-picking.
Common single-interface setups stop needing to repeat the flag, while
the two flags remain independent for the cases that legitimately want
HTTP and discovery on different interfaces.

Update the --interface help text to document the default. The --bind
text is unchanged: it still describes the HTTP listener address.

Refs #264

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-14 15:37:56 +02:00
Tobias GesellchenandClaude Opus 4.7 206ef1f665 fix(web): harden --bind interface resolution and add tests
The previous implementation silently returned the literal interface
name when the interface existed but had no IPv4 address (or when
listing addresses failed). That reproduces the exact error from #264
("listen tcp: lookup eth103 on ...: no such host") for users on
IPv6-only or admin-down interfaces, so the fix only worked for the
happy path.

Return an explicit error for those cases and fatal in main with a
message that identifies the offending --bind value. Add an IPv6
fallback (single non-link-local address, bracketed) and treat any
ambiguity -- multiple IPv4 or multiple IPv6 addresses on the same
interface -- as an error rather than picking one silently. Log when an
interface name was resolved to an IP so the indirection is visible.
Update the --bind flag help text to reflect the supported inputs.

Add a test covering the pass-through cases (host, IP, empty, unknown
name) and a portable loopback-interface test that skips cleanly when
the loopback isn't in a single-IPv4 configuration.

Refs #264

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-14 15:37:56 +02:00
mehmet turacandTobias Gesellchen 413ae74315 fix: resolve interface names for web bind address
Fixes #264

Signed-off-by: mehmet turac <mehmetturac@gmail.com>
2026-05-14 15:37:56 +02:00
Tobias GesellchenandClaude Opus 4.7 cb071c9b1b feat(discovery): allow pinning mDNS and UPnP to a specific interface
On a multi-homed host the discovery layer used to walk net.Interfaces()
and pick the first non-loopback IPv4 NIC, while UPnP/SSDP bound a
wildcard UDP socket and let the kernel route the multicast send. That
meant --bind on soundtouch-web only moved the HTTP listener; the
discovery still went out whatever interface the kernel preferred (often
the wrong one on hosts where the speakers sit behind a secondary NIC).

Introduce a separate DiscoveryInterface knob:

  * pkg/config: DiscoveryInterface field + DISCOVERY_INTERFACE env var.
  * pkg/discovery/mdns: NewMDNSDiscoveryServiceWithInterface; the
    interface resolver now honours an explicit name and validates it
    has a usable IPv4 address before handing it to hashicorp/mdns.
  * pkg/discovery/upnp: when an interface is configured, bind the UDP
    socket's source IP to the NIC's IPv4 and call
    ipv4.PacketConn.SetMulticastInterface so M-SEARCH leaves the right
    NIC. Without an interface, behaviour is unchanged.
  * cmd/soundtouch-web: new --interface flag (DISCOVERY_INTERFACE env)
    plumbed into the config before the discovery service is built.

go.mod/go.sum reflect promoting golang.org/x/net from indirect to a
direct dependency (now imported for ipv4.PacketConn).

Refs #264.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-13 19:36:06 +02:00
Tobias GesellchenandClaude Opus 4.7 67111b6f5e docs(web): clarify that --bind takes a host or IP, not an interface name
The flag's value is concatenated with ":PORT" and passed to
http.ListenAndServe, so it has always been a host/IP. The previous help
text invited users to pass an interface name like "eth0", which then
failed with a confusing DNS lookup error.

Refs #264.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-13 19:36:06 +02:00
Tobias GesellchenandGitHub 4a46df1167 Make the soundtouch-web port configurable via env (#186)
See
https://github.com/gesellix/Bose-SoundTouch/issues/181#issuecomment-4313151490
2026-04-25 21:29:13 +02:00
Tobias GesellchenandGitHub cdaf9f0c0a Build and publish a soundtouch-web Docker image (#184)
Relates to https://github.com/gesellix/Bose-SoundTouch/issues/181
2026-04-23 22:23:00 +02:00
Tobias GesellchenandGitHub 522492177d Embed web resources in soundtouch-web (#182)
Relates to https://github.com/gesellix/Bose-SoundTouch/issues/181
2026-04-23 20:51:00 +02:00
Tobias Gesellchen 88c83b6131 Fix security issues 2026-04-19 21:59:55 +02:00
Tobias Gesellchen 56e82d5a01 Add TuneIn search/browse/playback
We might peek into https://github.com/core-hacked/tunein-api for more advanced use cases
2026-04-19 21:59:55 +02:00
Tobias Gesellchen 56256de47b lint 2026-04-19 21:59:55 +02:00
Tobias Gesellchen 5b99d7f46b Add a web-based app 2026-04-19 21:59:55 +02:00