Commit Graph
665 Commits
Author SHA1 Message Date
Tobias Gesellchen 3932e9b2b7 docs: Update CLAUDE.md with current project structure and binaries
- Documents soundtouch-web and soundtouch-backup binaries
- Updates build targets and Go version requirements
- Improves session pickup documentation clarity
- Reorganizes project structure documentation
2026-05-18 22:34:26 +02:00
Tobias Gesellchen d8fe03111e update screenshots 2026-05-18 22:34:26 +02:00
Tobias GesellchenandClaude Opus 4.7 75118d9a92 fix(soundtouch-web): keep device WebSocket alive across disconnects
ConnectDeviceWebSocket was a one-shot: connect, wait for disconnect,
log, return. Once the device-side WebSocket died (idle timeout, blip,
speaker reboot), the goroutine ended and conn.WebSocket stayed
pointing at the (now-dead) client — which made the duplicate-spawn
guard `if device.WebSocket == nil` at the five callsites in
handler.go correctly skip spawning, but with nothing else trying to
reconnect, the speaker's status flow froze for the rest of the
process's lifetime. The browser kept receiving status_update
messages on the 5 s ticker (HandleWebSocket), but every payload
carried the same stale data the service last knew.

Symptom: load the page, NowPlaying shows fresh state; some minutes
later, the speaker switches presets or tracks but NowPlaying never
updates — even though playback itself works because those are
one-shot HTTP calls that don't depend on the WebSocket.

Fix: wrap the connect-and-wait in a for-loop with exponential
backoff (1 s → 30 s cap, reset on every successful connect). The
goroutine now lives for the device entry's lifetime; conn.WebSocket
is updated on each successful reconnect and never cleared, so the
existing guards keep working without spawning duplicate loops.

Pre-existing main bug — preserved by the relocation, surfaced when
testing the rebased branch. Fix is contained to the one function;
behaviour is byte-identical for the happy path (one connect, no
disconnect ever).

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 9d2ebb2edd fix(soundtouch-web): unify TuneIn play affordance across item types
Stations still showed a dim ▶ inside the .tunein-item-arrow span
while programs (with the new pill button from 34d4692) showed a
circled play button. Two different play affordances side by side
looked accidental.

Now every item with a playback link renders the same pill button,
and the arrow span carries only the drill-in chevron. Per item type:

  Stations  (play only)            pill ▶
  Programs  (navigate + play)      pill ▶ + chevron ›
  Genres    (navigate only)        chevron ›

The pill stops event propagation, so clicking it triggers play
without bubbling to the row's navigate handler — that lets row
clicks keep drilling into programs while the button cuts straight
to "play latest episode."

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 cd387888aa fix(soundtouch-web): surface play button on TuneIn program rows
The Preact TuneInBrowser hid the play affordance whenever an item
also had a navigate link. TuneIn programs have BOTH (drill into
episodes + play latest episode, after backend PR #317), so the
button never appeared on program rows — only the chevron.

Old vanilla UI showed both. Restored:

- navigate(item) keeps its current behaviour (path wins for row
  clicks, falls through to play if there's no path) — that lets
  pure-leaf items (stations) still play on whole-row click.
- New explicit .tunein-play-btn rendered conditionally when an item
  has BOTH a navigate link and a playback link. Stops event
  propagation so clicking it triggers play (device picker overlay)
  instead of bubbling to the row's navigate handler.
- CSS: pill-shaped 32px button using the same --accent / --text-dim
  tokens the rest of the UI uses; hover state swaps to --accent /
  --accent-fg to avoid same-on-same contrast in either theme.

The chevron stays as the row's "drill in" indicator for any
navigable item, including programs.

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 22f999edaf feat(soundtouch-web): add multi-room zone management
Ports app's commit b040c8a. Five new handlers + five new routes for
master/slave stereo-pair and multi-room management; the Zone.js
frontend was already shipped in the Preact swap.

  HandleGetZone        GET  /api/zone/{id}
    Returns zone info enriched with member names and role flags
    (isMaster / isSlave / isStandalone) computed from the perspective
    of the queried device. Each member carries IP, hwID, and friendly
    name so the frontend can render readable rows.

  HandleZoneAdd        POST /api/zone/{id}/add/{slaveId}
    Adds a slave to the zone where {id} is or becomes the master.
    Standalone master gets a fresh ZoneRequest; existing zone is
    extended via ToZoneRequest + AddMember.

  HandleZoneRemove     POST /api/zone/{id}/remove/{slaveId}
    Removes a named slave from the master's existing zone.

  HandleZoneDissolve   POST /api/zone/{id}/dissolve
    Issues a single-member ZoneRequest so the master goes standalone.

  HandleZoneLeave      POST /api/zone/{id}/leave
    Slave-side leave: looks up the master via findIPByHwID using the
    slave's current zone info, then dispatches RemoveMember against
    the master's client (the speaker protocol requires the master to
    own the SetZone call).

Translation notes:

- All handlers go through app.GetDevice(id) instead of direct
  app.Devices[id] access — matches main's encapsulated-registry
  refactor (post-base on main, see registry_test.go).
- findIPByHwID iterates via app.DeviceSnapshot() instead of ranging
  over the raw map.
- pkg/client (GetZone/SetZone) and pkg/models (ZoneInfo/ZoneRequest/
  Member/NewZoneRequest/AddMember/RemoveMember/IsStandalone/
  ToZoneRequest) API surface confirmed unchanged from app's base —
  verbatim function calls.

Risk recap (per the earlier audit): this was flagged medium-risk
because of pkg/client zone-API drift. Verified clean — all symbols
exist with the expected signatures on current main. The #252 stereo-
pair work that landed on main was in cmd/soundtouch-cli/cmd_group.go
(parallel POST to LEFT and RIGHT), which doesn't intersect with the
single-master SetZone pattern these handlers use.

go build ./... clean. go test ./... clean (only pre-existing
TestDocsConsistency fails, untracked-file issue, unrelated).
golangci-lint run ./pkg/service/soundtouchweb/... ./cmd/soundtouch-web/...
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 b5ed1745ff feat(soundtouch-web): add recents panel + generic content-item player
Ports app's commit 3122c4e to the package layout. Two new handlers
and route registrations; the frontend was already shipped in the
Preact swap.

  HandleDeviceRecents   GET  /api/device-recents/{id}
    Returns the speaker's /recents list as APIResponse{Success,Data}.
    Backs the Recents.js component (lazy-loaded list under the
    device-detail view; hides itself when the device returns no
    recents).

  HandleDevicePlay      POST /api/device-play/{id}
    Generic content-item player. Decodes a {source,type,location,
    sourceAccount,itemName,containerArt,isPresetable} JSON body into
    a *models.ContentItem and runs Client.SelectContentItem. Used by
    Recents.js to replay items the speaker reports, regardless of
    source — TuneIn, Spotify, AUX, etc. Different from HandlePlayTuneIn
    which is TuneIn-specific.

Translation note: app's bodies used app.Devices[id] directly; main's
registry is encapsulated behind GetDevice/AddDevice/TouchDevice (see
the post-base refactor that introduced registry_test.go), so this
commit uses app.GetDevice(id) instead. Same lookup, just through the
maintained API.

go build ./... clean. go test ./... clean (only pre-existing
TestDocsConsistency fails, untracked-file issue, unrelated).
golangci-lint run ./pkg/service/soundtouchweb/... 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 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 Gesellchen b3b2d9d262 fix(web): parallelize independent startup calls again 2026-05-18 22:21:28 +02:00
Tobias Gesellchen 92917e4375 fix(web): clean stale hash when migration device is unknown 2026-05-18 22:21:28 +02:00
Tobias Gesellchen 48e8ff8352 fix(web): guard pushState in showSummary to break popstate loop
skip history.pushState when the hash already matches, so popstate -> selectMigrationDevice -> showSummary no longer pushes a duplicate entry that traps browser-Back in an oscillation between identical `#tab-migration?<id>` entries.
2026-05-18 22:21:28 +02:00
Marcin MennemannandTobias Gesellchen fbbc0de55c fix(web): added proper fallbacks for missing hash and device_id 2026-05-18 22:21:28 +02:00
Marcin MennemannandTobias Gesellchen ff279a150f fix(web): persist selected migration device in URL hash 2026-05-18 22:21:28 +02:00
Marcin MennemannandTobias Gesellchen b26c5627ee feat(web): add hash-based tab navigation for back-button and reload support 2026-05-18 22:21:28 +02:00
Marcin MennemannandTobias Gesellchen f1821d5995 doc: remove mirroring and parity with Bose cloud 2026-05-18 20:45:18 +02:00
Tobias GesellchenandClaude Opus 4.7 e9565983f8 ci(link-check): accept HTTP 202 as a live response
The Check documentation links job on PR #320 flagged a link in
README.md to eur-lex.europa.eu as dead because the EU legal-content
portal responds with HTTP 202 (Accepted) to HEAD requests. 202 is a
2xx success class — the server responded and the link is valid; it
just means "the request was accepted and is being processed".

Adding 202 alongside 200 / 206 in aliveStatusCodes fixes the false
positive broadly, not just for this one URL.

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 2b48d25e5f chore: scrub 192.168.123.x example IPs to RFC-5737 doc range
Three files carried 192.168.123.x as placeholder IPs in examples and
fixtures. RFC-1918 private space — same reader-confusion concern as
the broader 192.168.1.* sweep in 136d24a. Switched to 192.0.2.x
preserving the last octet so the reader-side intent ("CLI host arg
example", "test fixture URL") stays clear.

- docs/analysis/FACTORY-RESET-PROTOCOL.md       — 14 CLI --host examples + 1 log-fragment
- docs/analysis/TELNET-COMMAND-REFERENCE.md     — 1 docker-run env example
- pkg/service/marge/recents_sourceproviderid_regression_test.go
                                                — 2 XML location URLs (matched-pair within file)

docs/analysis/BOSE-LAB-RUNBOOK.md keeps its 192.168.10/24 subnet
unchanged — that's the documented Pi-as-AP network for the runbook,
not a placeholder.

go test ./pkg/service/marge/... clean.

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 50f1c5980a docs(mac-mapping): scrub dash-form of the real test-speaker MAC
The earlier MAC sweep in 04f9c31 only matched the colon form
(A8:1B:6A:53:6A:98). MAC-ADDRESS-MAPPING.md documents the
normalisation behaviour with separator variants, so it also carried
the dash form (A8-1B-6A-53-6A-98) — 2 hits both replaced with the
canonical AA-BB-CC-DD-EE-FF placeholder.

Surfaced by the post-cleanup re-scan.

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 a76a112d92 chore(integration): add testdata rotation target + document workflow
Surfaced via the rfc-5737-cleanup sweep: after the anonymisation pass
updated test-suite assertions to RFC-5737 IPs, the next
`make test-http-client` run failed against the stale local
tests/integration/testdata/ left over from a previous build (which
still carried the old 192.168.1.x state via the compose volume).

Two changes, in one commit so the doc references the target it
documents:

1. Makefile: new `test-http-client-rotate` target that renames any
   existing tests/integration/testdata/ to
   tests/integration/testdata_<timestamp>/. Non-destructive (mv, not
   rm), opt-in (no other target invokes it). Archives stay around
   for retrospective debugging — that directory is debug evidence,
   not disposable scratch.

2. CLAUDE.md: new "Integration tests" section under Build/test/run.
   Explains the docker-compose stack, the testdata mount, the
   per-machine-only nature (via tests/.gitignore), and the
   rotate-then-run pattern when fixtures or schemas have changed.

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 02a1663336 scripts(mitm): parametrise account-id/device-id redaction
convert_mitm_script.py was the last tracked file carrying a real Bose
account ID (9569497) and the maintainer's test-speaker MAC
(A81B6A536A98), hardcoded as the values to redact from MITM captures.

Replaced with mitmproxy `--set` options (`account_id`, `device_id`),
defaulting to empty strings (no-op) so the tracked source no longer
contains either real value. Callers configure their own at runtime:

    mitmdump -s convert_mitm_script.py \
        --set out_dir=_/mitm \
        --set account_id=1234567 \
        --set device_id=AABBCCDDEEFF

Added a module docstring documenting the flags so the usage isn't
folded only into the loader help text.

After this commit, the tree is clean for every personal-data pattern
the audit at _/RFC-5737-cleanup/assessment.md identified. The only
remaining 192.168.1.x references live in
docs/analysis/ANONYMIZATION-SUMMARY.md as intentional doc-context
discussion of why we moved off that range.

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 702092d772 chore: sweep example LAN IPs to RFC-5737 in source and config files
Completes the docs-tier RFC-5737 rollout by sweeping the remaining
192.168.1.x references that lived outside .md / .txt / test files:

  - .env.example                                — active PREFERRED_DEVICES default + examples
  - .github/ISSUE_TEMPLATE/*.yml + workflows    — issue template + CI examples
  - cmd/websocket-demo/main.go, doc.go          — top-level docs
  - examples/*/main.go (7 files)                — example program comments
  - pkg/client/client.go                        — godoc examples
  - pkg/models/doc.go                           — package godoc
  - pkg/service/{amazon,spotify,zeroconf}/zeroconf.go — godoc comments
  - pkg/service/handlers/web/index.html         — placeholder text in the UI
  - scripts/prepare-release.sh                  — example invocations
  - scripts/spotify/spotify-prime-speaker.sh    — usage comment
  - tests/integration/http-client/http-client.env.json — fixture IPs

Same mapping as the docs commit (136d24a): 192.168.1.X → 192.0.2.X
preserving the last octet.

One semantic carve-out: the three zeroconf `zcBaseURL` godoc comments
in pkg/service/{amazon,spotify,zeroconf}/zeroconf.go switched to
192.168.10.10 instead of the doc range, because validateZcBaseURL
only accepts RFC-1918 / loopback / link-local. The comment must show
a value the validator actually accepts — see the matching test fix
in 92f66a2 for the same reason.

go build ./... clean. go test ./... clean except the pre-existing
TestDocsConsistency (untracked DEVICE-LOCAL-INSTALL.md, unrelated).
golangci-lint run ./... — 0 issues after a gofmt fix on
examples/zone-slave-operations/main.go.

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 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 249c2586e9 chore(make): use RFC-5737 documentation IPs in help-text examples
Five `192.168.1.x` references in Makefile usage-error messages and
the `make help` example block. Same hygiene argument as the docs
sweep in 136d24a — replaced with `192.0.2.x` so the example output
clearly reads as a placeholder, not a real LAN.

Behaviour unchanged: these are echo-only strings printed when the
user forgets to set HOST=… or asks for `make help`. The
HOST=<your-IP> contract is unaffected.

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 ffd5974ddb docs(anon): rewrite as canonical placeholder mapping table
The old file documented a single anonymisation pass and embedded the
exact historical mappings (real LAN IPs, real MACs, real account IDs
on the "Original" side of each row). Those values are sensitive even
when presented as "what we replaced" — and they're already in git
history, so reprinting them in tracked content adds nothing.

Replaced with a concise reference that:
- lists the canonical placeholders to USE in new examples and tests
  (RFC-5737 IPs, AA:BB:CC:DD:EE:FF MACs, generic device names,
  1000001/1000002 account IDs)
- explains why RFC-5737 instead of 192.168.1.x
- gives detection regexes that catch *any* non-placeholder value,
  rather than naming the specific leaked values

180 → 65 lines net, and the file no longer contains any of the
sensitive strings it used to track.

Completes the .md / .txt portion of the rfc-5737-cleanup branch.
Test files (.go / .xml / .http) + the convert_mitm_script.py and
the broader 192.168.1.* sweep remain — separate scope per
_/RFC-5737-cleanup/assessment.md.

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 29f3fc6f96 docs: replace real Bose account IDs in examples with placeholders
Two real Bose customer account IDs were embedded in documentation
examples: 3230304 (16 files repo-wide, 5 of them .md/.txt) and
9569497 (2 files, 1 .md). Account IDs look numeric and innocuous but
they're tied to a specific Bose customer — same exposure class as
MACs and home-LAN IPs.

Mapping:
  3230304  → 1000001
  9569497  → 1000002

6 .md files touched in this commit. Remaining occurrences live in
test files and one Python script (scripts/convert_mitm_script.py) —
those are out-of-scope for the docs sweep and will be handled in a
dedicated test-fixtures commit.

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 fa51a6f610 docs: replace real MAC addresses in examples with placeholders
The maintainer's two test-speaker MACs (A81B6A536A98 / A81B6A849D99,
plus colon-separated forms) appeared throughout documentation, runbooks,
and example READMEs. Public repo — same hygiene argument as the LAN-IP
sweep in 787c4fa.

Mapping:
  A81B6A536A98          → AABBCCDDEEFF
  A81B6A849D99          → AABBCCDDEE01
  A8:1B:6A:53:6A:98     → AA:BB:CC:DD:EE:FF
  A8:1B:6A:84:9D:99     → AA:BB:CC:DD:EE:01

The placeholders use the IANA-reserved AA:BB:CC:DD:EE:FF address that's
clearly synthetic, matching the convention the earlier anonymisation
pass had already adopted. 13 .md files touched; no tests, no code.

ANONYMIZATION-SUMMARY.md left for a dedicated rewrite commit.

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 51d196dd03 docs: replace personal LAN IPs and device names with placeholders
Public-repo hygiene: docs and READMEs carried the maintainer's home
LAN range (192.168.178.x) and personal speaker names ("Sound
Machinechen", "A Sound Machine"). Swapped to RFC-5737 documentation
IPs (192.0.2.x — reserved for examples, won't collide with anyone's
real network) and generic names ("Living Room SoundTouch",
"Kitchen SoundTouch").

12 files touched, all .md / .txt documentation. No code or tests
changed in this commit; subsequent commits will address the
docs/analysis/ANONYMIZATION-SUMMARY.md mapping log and the wider
real-MAC/real-account-ID footprint surfaced by the audit at
_/RFC-5737-cleanup/assessment.md.

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 598f69133e docs(env): replace personal device names + LAN IPs with placeholders
The .env.example carried real device names ("Sound Machinechen", "A
Sound Machine") and the maintainer's home-LAN IPs (192.168.178.x).
This repo is public — see CLAUDE.md "What never goes into this repo".

Swapped in:
- generic device names ("Living Room SoundTouch", "Kitchen SoundTouch")
- RFC-5737 documentation IPs (192.0.2.10 / 192.0.2.11), which are
  reserved exclusively for examples and won't collide with anyone's
  real network

The default active line (PREFERRED_DEVICES=…192.168.1.100…) is left
alone for now — that's a different cleanup decision (broader sweep
of 192.168.1.* still pending; see _/RFC-5737-cleanup/assessment.md).

First step on rfc-5737-cleanup. Remaining Phase 1 docs follow in
separate commits.

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 f8108b0dd9 refactor: rename /setup/proxy-settings → /setup/logging-settings
After the proxy/mirror removal there is no proxy left in the service,
but the parallel partial-update endpoint /setup/proxy-settings stuck
around with its legacy name. It serves a legitimate purpose distinct
from the bulk /setup/settings POST: the three checkboxes
(Redact / Log Bodies / Record) use onchange-triggered live save,
while /setup/settings drives a Save-button form for dozens of fields.
Folding the two endpoints together would either lose the live-toggle
UX or send half-edited draft form data on every toggle, so the
partial-update endpoint earns its keep — it just needed the right
name.

Renamed symbols (no behaviour change):

  Go handler funcs:
    HandleGetProxySettings      → HandleGetLoggingSettings
    HandleUpdateProxySettings   → HandleUpdateLoggingSettings
    GetProxySettings            → GetLoggingSettings

  Route:
    /setup/proxy-settings       → /setup/logging-settings

  JS:
    fetchProxySettings()        → fetchLoggingSettings()
    updateProxySettings()       → updateLoggingSettings()

  HTML element IDs (cosmetic, kept consistent):
    proxy-redact / proxy-log-body / proxy-record
                                → logging-redact / logging-log-body / logging-record

  HTML heading:
    "Proxy Logging:"            → "Logging:"

JSON payload shapes (request + response keys) are UNCHANGED: the
endpoint still emits / accepts {"redact", "log_body", "record"}.
Persisted Settings on disk are UNCHANGED. CLI flags are UNCHANGED.
Server struct fields redactLogs / logBodies / recordEnabled
(renamed earlier this session) are UNCHANGED.

testdata/router_routes.txt regenerated. go build clean. go test
./... clean except pre-existing TestDocsConsistency (untracked-file
issue, unrelated). golangci-lint 0 issues.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-17 21:53:33 +02:00
Tobias GesellchenandClaude Opus 4.7 1da654c9b9 refactor(handlers): rename proxy-era leftovers to match public names
After the proxy/mirror removal, two internal Server fields kept their
historical "proxy" prefix even though no proxy code exists anymore:

- s.proxyRedact   still controls recorder.Redact for sensitive-header
                  scrubbing (server.go:393)
- s.proxyLogBody  still controls the [UNHANDLED] body preview in the
                  catch-all (handlers_catchall.go:14)

Both names misled — they read as proxy-related. Renamed to match the
public-facing names that have been used all along: the CLI flags are
--redact-logs / --log-bodies, the persisted Settings fields are
RedactLogs / LogBodies, and the JSON keys are redact_logs / log_bodies.

  proxyRedact  → redactLogs
  proxyLogBody → logBodies

Also renamed the file that now contains only HandleNotFound:

  pkg/service/handlers/handlers_proxy.go      → handlers_catchall.go
  pkg/service/handlers/handlers_proxy_test.go → handlers_catchall_test.go

git mv preserves history. NewServer's positional parameter list is
unchanged at the call site (cmd/soundtouch-service/main.go:391).

go build ./... clean. go test ./... clean except the pre-existing
TestDocsConsistency (unrelated). golangci-lint run ./... 0 issues.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-17 21:53:33 +02:00
Tobias GesellchenandClaude Opus 4.7 018e9fd7cb chore(web): remove obsolete jsdiff dependency
The jsdiff library at pkg/service/handlers/web/js/diff.min.js (29 KB)
was loaded by the management UI to render rich diffs on the parity-
mismatch detail view. The previous two commits removed both the tab
and the JS consumer; the asset, its <script> tag, and the served-
asset test stanza were left behind.

Removes:
- pkg/service/handlers/web/js/diff.min.js (the asset itself)
- web/index.html: <script src="/web/js/diff.min.js"></script>
- handlers_media_test.go: the // 3. Test diff.min.js stanza in
  TestStaticWeb, and renumbers the trailing "// 4. Test Favicon"
  comment to "// 3."

No remaining Diff./jsdiff/diffChars/diffLines references in any
tracked JS or HTML. go build + TestStaticMedia + TestStaticWeb stay
green. The //go:embed pattern in handlers_media.go is web/js/*
(wildcard), so the embed bundle regenerates without the asset on
the next build with no directive edit needed.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-17 21:53:33 +02:00
Marcin MennemannandTobias Gesellchen 2747d95a8f remove: proxy forwarding to Bose upstream 2026-05-17 21:53:33 +02:00
Marcin MennemannandTobias Gesellchen 0f0a96c0ce remove: mirror middleware and parity comparison with Bose cloud 2026-05-17 21:53:33 +02:00
Tobias GesellchenandClaude Opus 4.7 88a2185985 chore: ignore .junie/ workspace dir
Communication principles + project conventions now live in CLAUDE.md
(committed in 4c3fedd). The .junie/ dir becomes per-machine tool
config — matches how .claude/ is handled. Any .junie/guidelines.md
present locally should just point at CLAUDE.md.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-17 19:14:05 +02:00
Tobias GesellchenandClaude Opus 4.7 27b5090ce5 docs(CLAUDE.md): inline communication principles; drop .junie/ pointer
Two reasons:

1. Survives a laptop switch. The principles previously lived only in
   .junie/guidelines.md; that file is per-machine tool config.
   Centralising in CLAUDE.md (which IS tracked) means the rules
   travel with the repo instead of with the workstation.
2. Single source of truth. Other AI assistants pointed at this repo
   should defer to CLAUDE.md, not maintain their own copies that drift.

The .junie/ dir becomes a per-machine breadcrumb that points back at
CLAUDE.md, and is .gitignore'd in a separate commit.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-17 19:14:05 +02:00
Tobias GesellchenandClaude Opus 4.7 0925ece3b2 docs: track CLAUDE.md as the repo onboarding contract
Brings the file into version control so it survives a laptop switch.
Aim: a self-contained briefing that doesn't rely on per-machine
auto-memory or local scratch files.

Notable content:

- "How a new session should start" — concrete read order
- "Load-bearing gotchas" — the ETag header literal must stay
  capitalised; rewriting to Go's canonical "Etag" breaks real speakers
  (encoded in handlers_etag_test.go as caseSensitiveETag/normalizedEtag)
- "What never goes into this repo" — explicit list of data classes
  that must never be committed (real IPs, MACs, account IDs, Bose
  binaries, captures), since the repo is public
- Pre-push quality gate codified: golangci-lint clean before git push
- Trademark disclaimer for "SoundTouch" / "Bose"

Drops the stale ".impeccable.md" reference (no such file in the tree)
and trims the destructive-ops safety prose to the rules that actually
apply during a session.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-17 19:14:05 +02:00
Tobias GesellchenandClaude Opus 4.7 e3cd5a3459 feat(service): expose build version on GET / mirroring /health
Extract a buildVersionInfo helper from HandleHealth so both endpoints
emit identical version + VCS metadata. JSON callers hitting / now get
the same release context they get from /health; under go run/test
where debug.ReadBuildInfo lacks VCS settings, version falls back to
"0.0.1" and the vcs_* keys are omitted (instead of empty strings).

The HTML branch of / is unchanged — the embedded index.html keeps its
own version-display story.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-17 19:14:05 +02:00
Tobias GesellchenandClaude Opus 4.7 776e0cfe44 chore: ignore .claude/ workspace dir
settings.local.json carries per-user permission overrides; report.html
is a session-local artifact. Both belong outside version control,
matching how .vscode/ and .idea/ are already handled.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-17 19:14:05 +02:00
Tobias GesellchenandClaude Opus 4.7 888f6b096e chore: ignore local NEXT.md / DONE.md working notes
Both files are session-local pickup-here / archive notes that have
always lived untracked in the working tree; codify the intent so they
don't keep cluttering git status.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-17 18:44:04 +02:00
Tobias GesellchenandClaude Opus 4.7 cc7675a07c feat(tunein): play stations/episodes/programs via cli source tunein (#226)
Add a `soundtouch-cli source tunein` subcommand that takes a TuneIn
guide ID and routes it through the right SelectContentItem shape —
`--station`, `--episode`, `--program`, or `--id` with prefix
auto-detect. The flag picks the ContentItem Type (`stationurl` for
stations/episodes, `tracklisturl` for programs) and the location
template, then enriches the now-playing metadata from TuneIn's describe
endpoint unless `--no-lookup` is set.

Program IDs (`p<N>`) are containers, not streams. The legacy OPML
`Tune.ashx?id=p<N>` returns `#STATUS: 400`, which pre-filter went out
to the speaker verbatim. Fix in three layers:

  1. `parseTuneInStreamBody` filters `#`-prefixed comment lines out of
     Tune.ashx responses and errors when nothing playable remains, so
     a broken TuneIn reply surfaces as a real 500 instead of corrupting
     the playback response.
  2. `TuneInPlaybackPodcast` expands `p<N>` to its newest episode via
     `api.radiotime.com/profiles/{id}/contents` (same JSON shape as
     api.tunein.com; uses the radiotime mirror so all program traffic
     stays on the host already in `allowedTuneInHosts`).
  3. `tuneInSearchProfile` (Program search items) and
     `TuneInNavigateProfile` (program detail hero) now emit
     `BmxPlayback` links, so soundtouch-web renders play buttons on
     program cards and on the profile hero — clicking either plays the
     latest episode via the same backend expansion.

Tests pin the parser contracts (`#STATUS: 400` filter, program-contents
episode pick) and the navigate Program-only playback emission. CLI
resolver has table-driven coverage for kind selection, prefix
auto-detect, and conflicting-flag errors.

Endpoint contract + raw probe responses captured under
`_/i226/tunein-api-findings.md` and `_/i226/tunein-probe/` for future
reference.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
v0.86.0
2026-05-17 18:29:48 +02:00
Tobias GesellchenandClaude Opus 4.7 4507d82b4c fix(security): address CodeQL findings on Stockholm + SiriusXM stubs
Two of the eight CodeQL alerts on PR #313 had clean, low-cost fixes:

  - go/clear-text-logging (#141, #142): the SiriusXM stub logged the
    raw Authorization header value at INFO. The header carries a
    long-lived bearer token (margeAuthToken) — capturing service logs
    would yield replayable credentials. Switch to logging only the
    boolean presence (`authPresent=%t`).

  - go/bad-redirect-check (#138): the Stockholm handler's bare-path
    redirect uses cfg.BasePath verbatim. basePath is operator-provided
    (CLI flag / STOCKHOLM_BASE_PATH env), not request input — but a
    value like "//evil.com" would still produce a scheme-relative
    redirect to an external host. Reject any leading-double-slash or
    embedded backslash at construction time so the redirect target
    can only ever be an absolute local path.

The remaining CodeQL alerts are out of scope here:

  - go/request-forgery on proxy.go (#139, #140): the /api/http-proxy
    endpoint takes a user-provided url= parameter and fetches it by
    design — that's the whole point of the proxy. Mitigations
    already in place: isProxyLoop rejects self-references; the proxy
    is only reachable under a LAN trust model.

  - go/path-injection on static.go (#143, #144, #145): the
    path-traversal guard in resolveStaticFile (string-prefix check
    on absolute paths) is sound, but CodeQL doesn't trace it across
    the function boundary. A clearer refactor to filepath.Rel might
    silence the alert; deferred.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-17 15:05:39 +02:00
Tobias GesellchenandClaude Opus 4.7 a7f90f4151 test(http-client): tunein_playback_station now expects 200 without auth
Mirrors the auth-gate relaxation in a213b68. The first request in
tunein_playback_station.http (no Authorization header) previously
asserted 401 + the "Unauthorized" body markup; the gate now logs
instead of 401, so the request returns 200 with the same audio
payload the second (authorized) request gets.

Comment above the request points back to handlers_bmx.go so a future
contributor restoring the gate sees what to flip back. The
test-http-client target is what catches drift here — without this
update, CI's http-client step would fail on the first assertion.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-17 15:05:39 +02:00
Tobias GesellchenandClaude Opus 4.7 0038db35d3 test(service): regenerate router_routes golden after SiriusXM routes
The new HandleSiriusXMLiveAdapter and HandleSiriusXMLiveAdapterSubpath
routes were registered via r.HandleFunc (every HTTP method) at the top
level in main.go. The router-shape golden file gets one entry per
(method, path) pair, so SiriusXM adds 14 lines across CONNECT / DELETE
/ GET / HEAD / OPTIONS / PATCH / POST / PUT / TRACE.

Pure regeneration — no behaviour change.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-17 15:05:39 +02:00
Tobias GesellchenandClaude Opus 4.7 1e53f0e8c3 chore: ignore data/backend/
The Stockholm bridge persists its native-bridge state into
`data/backend/state/native-state.json` (per pkg/service/stockholm/handler.go,
which mkdir-p's `<workspaceRoot>/backend/state/`). The directory accumulates
per-session state — auth tokens, guids, device caches — that's not
meant to be tracked alongside the source.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-17 15:05:39 +02:00
Tobias GesellchenandClaude Opus 4.7 2df0adf4e3 feat(bmx): SiriusXM live-adapter logging stub
bmx_services.json advertises SIRIUSXM_EVEREST at
`{BMX_SERVER}/core02/svc-bmx-adapter-siriusxm-everest-eco1/prod/live-adapter`
and bmx_services_availability.json lists it as available, so speakers
that try SiriusXM hit that path. Without a route we 404'd silently
and the call was invisible in our logs.

  - HandleSiriusXMLiveAdapter at the bare base URL returns the
    SIRIUSXM_EVEREST service descriptor (selected by id.name from
    bmx_services.json, with {BMX_SERVER}/{MEDIA_SERVER} substitution).
    Mirrors deborahgu/soundcork main.py:805 in shape.

  - HandleSiriusXMLiveAdapterSubpath catches every sub-path advertised
    by the descriptor's _links (/availability, /token, /navigate,
    /logout) plus the playback URLs the speaker discovers via navigate.
    Logs the request with method+path+UA+Authorization+RawQuery, then
    404s — giving the next implementation pass concrete data about
    what the speaker actually asks for.

Two helpers added to handlers_bmx.go (shared with any future
BMX-segment stub):

  - extractBMXService(json, name) — find a service entry by id.name.
  - (*Server).applyBMXTemplate(content) — {BMX_SERVER}/{MEDIA_SERVER}
    substitution, identical to what HandleBMXRegistry does inline.

Routes registered next to Orion at the top level — same convention
(no /bmx/ prefix) because bmx_services.json advertises baseUrl without
that prefix and speakers reach the path verbatim under either
migration mode.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-17 15:05:39 +02:00
Tobias GesellchenandClaude Opus 4.7 b9c1cdad29 fix(bmx): relax TuneIn + Orion Authorization gate, log instead
Seven BMX adapter handlers required a non-empty `Authorization` header
and returned 401 from writeBMXUnauthorized when missing:

  TuneIn:  Playback, PodcastInfo, PlaybackPodcast, Report, Navigate, Search
  Orion:   Playback

Speakers calling these endpoints directly carry their margeAuthToken in
the header, so the gate works for them. But the Stockholm browser
proxy (pkg/service/stockholm/proxy.go injectBackendHeaders) only injects
Authorization for hosts ending in .bose.com or .apigee.net with a marge
path — when Stockholm calls back into our own service for TuneIn
browsing/playback/search/etc., no header is added and every request
401s.

Disable the gate at all seven sites; log the missing-header case so the
absence remains visible. Keep writeBMXUnauthorized as the future-restore
point (//nolint:unused) — when the gate comes back (e.g. behind a
BMX_STRICT_AUTH env-var or once the Stockholm proxy learns to inject
Authorization for our own host), callers will use this helper again.

Tests that assert 401 for missing Authorization (TestBMXUnauthorized,
TestHandleTuneInReport/Unauthorized, TestHandleTuneInNavigate/Unauthorized,
TestHandleTuneInSearch/Unauthorized) are `t.Skip`'d with a pointer back
to handlers_bmx_tunein.go — they stay in the file to come back to life
the day the gate does.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-17 15:05:39 +02:00
Tobias GesellchenandClaude Opus 4.7 d7bbc09ce6 refactor(handlers): split handlers_bmx.go per BMX service
handlers_bmx.go had grown to ~426 lines covering registry + availability +
shared helpers + TuneIn (9 handlers) + Orion (2 handlers) + our own
custom-playback adapter. The test files were already split per service
(handlers_bmx_test.go, handlers_bmx_tunein_test.go,
handlers_bmx_report_test.go) — the production code now matches that
shape.

Pure move, no logic change:

  - handlers_bmx.go          → BMX registry + availability + shared
                               helpers (writeBMXUnauthorized,
                               bmxServicesJSON file-level vars)
  - handlers_bmx_tunein.go   → all TuneIn handlers (Playback,
                               PodcastInfo, PlaybackPodcast, Token,
                               Report, Navigate, Search, Favorite,
                               DeleteFavorite) plus tuneInStreamFormats
                               helper and parseTuneInNavigatePath
  - handlers_bmx_orion.go    → Orion (LOCAL_INTERNET_RADIO) Token +
                               Playback
  - handlers_bmx_custom.go   → our own /custom/v1/playback adapter
                               (not a Bose-official BMX service —
                               kept distinct from Orion for clarity)

Imports are tightened per file. No public API change; tests pass the
same as before this commit.

A future iteration may extract a common BMX-service interface once 3-4
services are fully implemented. Until then, file-per-service is the
shape — see memory project_bmx_service_interface.md.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-17 15:05:39 +02:00