86 Commits
Author SHA1 Message Date
Tobias Gesellchen 9f61d00b2d feat(admin): live Settings-page toggle for the opt-in update check
Follow-up to #591: UpdateCheckEnabled/UpdateCheckInterval are now
persisted, live-reloaded Settings fields (mirroring the discovery
enabled/interval pattern), editable from the admin Settings page
without a restart. The env var/CLI flag remains the seed value for a
fresh install with no settings.json yet.

The background goroutine now always runs and polls the live settings
every minute (updateCheckPollTick), instead of being started only if
enabled at process launch, so flipping the toggle takes effect within
a minute rather than requiring a restart.
2026-08-10 23:17:36 +02:00
Tobias Gesellchen 59a881e667 feat(announcements): support a proper link, not a raw URL in the message text
Follow-up to #591, prompted by the update-check notice showing a raw
https:// URL as plain text instead of a clickable link. Made it general
rather than a one-off fix, since future announcements may also want to
link to docs.

Added Announcement.LinkText/LinkURL (+ LinkURLFunc, the dynamic
counterpart, for the update-check entry's per-release URL) alongside the
existing Message/MessageFunc pair. Both frontends render it as a real
<a> element now: the admin UI (innerHTML) escapes Message/LinkText/LinkURL
via the existing escapeHtml() before composing the markup — previously
Message went into innerHTML unescaped, which this incidentally hardens;
the player (Preact/htm) templates an actual <a> rather than interpolating
a string, since Preact escapes string children by default and a raw
<a href=...> string would otherwise render as literal text, not a link.

Rephrased the #419 admin-gate announcement to use the new field too (was
a plain "See issue #419 for details." text mention).

Bug found while wiring this up: UpdateCheckState never persisted the
release URL, only the version — so after a restart, the announcement
would show a correct message but a broken/empty link until the next live
check completed (which can be up to a full interval away, since a fresh
check is skipped when the persisted last-check is still recent). Fixed by
adding UpdateCheckState.LastReleaseURL and threading it through
Checker.persist/NewChecker's seeding path, with a test
(TestNewChecker_SeedsFromPersistedState) that would have caught it.

Also fixed two gocritic rangeValCopy findings in
handlers_announcements.go (switched to index-based iteration) surfaced by
the Announcement struct growing with the new fields.

Refs #591
2026-08-09 10:18:19 +02:00
Tobias Gesellchen f33a306c47 chore: suppress math/rand Semgrep finding on two non-security use sites
Addresses the Semgrep finding on PR #599
(go.lang.security.audit.crypto.math_random.math-random-used) on the
update-check jitter delay, and applies the same treatment to the #419
activity-log filename suffix, which has the same non-security shape but
predates this PR's diff so it wasn't flagged.

Neither value is ever compared, kept secret, or otherwise security-
sensitive (a sleep duration and a filename-uniqueness suffix), so
crypto/rand would only add error-handling overhead for no real benefit.
Suppressed with the same // nosemgrep: <rule-id> pattern already used in
the mock-amazon/mock-spotify/mock-tunein servers, mirroring the existing
//nolint:gosec on the same lines.

Refs #591
2026-08-09 01:22:03 +02:00
Tobias Gesellchen 500f7850be feat(datastore): add UpdateCheckState persistence
First piece of #591 (opt-in periodic update check). A small persisted
state (last_checked_at, last_seen_version) under update-check.json,
mirroring Settings' Get/Save shape — separate from Settings itself since
this is runtime state, not operator-editable config.

Not wired to anything yet.

Refs #591
2026-08-09 01:22:03 +02:00
Tobias Gesellchen 5d12e7fac9 feat(admin): add local activity log + in-memory dismissal cache
Second piece of #419: a generic, local-only, append-only activity log
(datastore.RecordActivity/GetActivityRecords, one file per event under
stats/activity/<kind>/, same shape as SaveUsageStats) meant to back the
upcoming announcement-banner dismissals and be reusable for other admin-UI
action kinds later.

The read path never touches disk: a scoped startup scan folds prior
dismissals into an in-memory map once, RecordDismissal updates it
write-through. Same id can recur with a new timestamp (re-shown, dismissed
again) — it's a log, not a keyed store.

Not wired to anything user-facing yet — no announcements exist to dismiss.

Refs #419
2026-08-08 23:49:57 +02:00
Tobias Gesellchen 9090fad563 feat(admin): add tri-state AdminAreaAuth setting with default-creds guard rail
First piece of the #419 admin-area gate: a persisted, live-reloadable
tri-state setting ("" unset / "enabled" / "disabled") so a later release
can flip the default from open to gated without breaking an explicit
opt-out. Rejects enabling while MGMT_USERNAME/MGMT_PASSWORD are still the
published default, since that would give a false sense of security.

No behavior change yet — nothing reads this field to actually gate
anything. That's the next chunk.

Refs #419
2026-08-08 23:49:57 +02:00
Tobias GesellchenandClaude Opus 4.8 d31710bd8e docs(clientip): document X-Forwarded-For-only proxy client-IP resolution
Update the HTTPS reverse-proxy guide and the trust_forwarded_headers /
trusted_proxy_cidrs settings comments to reflect that the client IP is now
resolved from X-Forwarded-For only (no longer X-Real-IP / True-Client-IP),
read via the request context rather than by rewriting r.RemoteAddr. The
nginx example now sets X-Forwarded-For.

(Release note staged locally at _/releases/v0_117_0.md, which is gitignored
like prior release notes.)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-28 13:15:49 +02:00
Tobias GesellchenandClaude Opus 4.8 6ad75657e8 fix(datastore): dedup recents by ID in SaveRecents (stop same-recent pile-up)
A speaker<->marge recents sync could re-store the same recent (same ID) multiple
times — observed live as one STORED_MUSIC track appearing 4x in the speaker's
/recents, the service's stored Recents.xml, and /full. The duplicates crowd the
capped (10) recents list and evict other sources (e.g. a freshly played Spotify
track never appears). SaveConfiguredSources already dedups by ID; SaveRecents did
not, so dupes introduced by any path (AddRecent move-to-front, syncRecents from
the speaker's /full, setup/health) persisted and fed back through the sync loop.

SaveRecents now dedups by ID (first occurrence wins) at the single chokepoint all
callers share, so the list self-heals on the next write. Regression test added.

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

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

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

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-07 15:08:26 +02:00
Tobias GesellchenandClaude Opus 4.8 7e0573032c chore(sanitize): remove real device ID and personal LAN IPs from tracked files
Per the repo's no-real-data rule (CLAUDE.md), scrub committed files only (the
gitignored _/ local captures are left as-is):

- Real Bose-OUI device ID 08DF1F0BA325 -> placeholder AABBCCDDEE0A across 4 docs
  and 8 Go test files (consistent 1:1 rename; affected packages tested green).
- Personal/topology LAN IPs -> RFC-5737: the lab runbook's AP subnet
  192.168.10.x -> 198.51.100.x (192.0.2.x is already used contrastively there)
  and 192.168.100.1 -> 203.0.113.1; illustrative example IPs in
  ANONYMIZATION-SUMMARY / spotify-overview / TROUBLESHOOTING -> 192.0.2.x.
- Kept factual RFC-1918 range citations (10.0.0.0/8 trusted-proxy example,
  192.168.0.0/16 "all private subnets") since they name the ranges themselves.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 19:11:24 +02:00
Tobias Gesellchenandlnx01 519526852d Potential fix for code scanning alert no. 308: Log entries created from user input
Co-authored-by: Copilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com>
2026-06-04 22:13:23 +02:00
Tobias GesellchenandClaude Opus 4.8 188c5521b7 fix(datastore): sanitize wrapped errors in malformed-XML logs (CodeQL go/log-injection)
The #458 empty/0-byte resilience logging logged the raw xml.Unmarshal error with
%v. A parse error can echo attacker-controlled file content, so a newline-bearing
error string reached the log unsanitized (CodeQL go/log-injection, medium). Wrap
the error with sanitizeErr (strips \n/\r), the barrier logutil.go documents.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-04 19:38:12 +02:00
Tobias GesellchenandClaude Opus 4.8 b1a5428ebf fix(datastore): fsync atomicWriteFile for crash-safe durability (#458)
atomicWriteFile wrote a temp file and renamed it, but never fsync'd — so an
unclean power-cut on a journaling NAND filesystem (UBIFS on the speaker's
/mnt/nv) could leave the renamed datastore file present but 0 bytes (the rename
was journalled, the data blocks were not flushed). Now fsync the temp file
before the rename and the parent directory after, via os.Root.OpenFile/Open;
directory fsync is best-effort (unsupported on some filesystems).

Pairs with the read-side resilience fix (#459): durability prevents the 0-byte
files; resilience tolerates any that already exist.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-04 19:38:12 +02:00
Tobias GesellchenandClaude Opus 4.8 d7c3976684 fix(datastore): treat empty/0-byte/unparseable XML as missing → serve defaults (#458)
A power-cut on the speaker's NAND can leave a datastore file present but 0-byte
(a not-yet-flushed atomicWriteFile write). The read paths now treat empty/0-byte/
unparseable Presets/Recents/Sources the same as missing: GetConfiguredSources
serves the managed defaults (so /full self-heals instead of wiping the speaker),
GetPresets/GetRecents return an empty list (no more HTTP 500 on the device-level
endpoints), and HasConfiguredSources reports a 0-byte file as absent (so the
create_default_sources health quick fix is offered again).

Read-side resilience only; the write-side durability fix (fsync in
atomicWriteFile) follows in a separate PR.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-04 19:23:09 +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 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 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 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 14ba012c02 sec5b: sanitize log-injection in pkg/service/datastore and pkg/service/marge
Fixes CodeQL go/log-injection alerts in the datastore and marge packages.

Adds logutil.go with a package-private sanitizeLog helper to each package.

pkg/service/datastore/datastore.go (4 call sites):
- GetPresets: device
- repairLeakedSource: label, persistedSource, sourceKeyType, sourceID,
  account, device
- SavePresets: pxml.ID, account, device, p.Source

pkg/service/marge/marge.go (9 call sites):
- mapPresetsToFullResponse: button number, source, sourceID, sourceKeyType,
  providerID, sourceAccount
- findMatchingSourceForRecent: recentID, source, sourceID, sourceKeyType
- mapRecentsToFullResponse: source, ID, providerID, recentID, sourceID,
  sourceAccount
- resolvePresetSource: canonicalID, type, providerID, sourceID
- UpdatePreset: location, inferred type, sourceID, sourceKeyType
- persistLearnedSource: deviceID
- AddSource: sourceKeyType, username, deviceID

pkg/service/marge/sync.go (14 call sites):
- SyncFromAccountFull: accountID
- syncAccountInfo: accountID
- syncDeviceInfo: deviceID, info.Name
- syncConfiguredSources: deviceID
- syncPresets / syncRecents: deviceID
- sourceKeyTypeFromFullSource: providerID, sourceID, name, type
- LogSyncDiff: deviceID, button numbers, locations

No behaviour change. make check passes.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-24 16:36:24 +02:00
Marcin MennemannandTobias Gesellchen 65b142881a replace copy-and-delete migration with atomic MoveDevice 2026-05-24 09:52:02 +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 c305d22de0 refactor(datastore): drop INTERNET_RADIO from initial Sources.xml
Add getInitialSources() that excludes the legacy INTERNET_RADIO (10002)
provider from newly-created device Sources.xml files. GetDefaultSources()
retains the entry for backward-compatible canonicalisation of existing
devices and cloud-level account responses.

Fix mergeDefaultSources() to rebuild the merged list in canonical ID
order (defaults first, using stored credentials when present, then
custom sources such as Spotify). This prevents INTERNET_RADIO from
landing at the end of the cloud /sources response when a device's
Sources.xml was created without it.

Drop the two verbose search-loop log lines from resolvePresetSource.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-23 22:45:15 +02:00
Tobias GesellchenandClaude Sonnet 4.6 1eb1fefc1d fix(datastore): parse legacy <ContentItem> (capital C) in Presets.xml
encoding/xml is case-sensitive, so Presets.xml files written by older
AfterTouch versions using <ContentItem> (capital C) had all source,
location, and type attributes silently dropped on read. Every preset for
such a device had empty fields, causing mapPresetsToFullResponse to skip
them all — the speaker received /full with zero presets and stored nothing.

Fix: normalise <ContentItem> → <contentItem> before unmarshaling in the
new readPresetsLocked helper. If normalisation was needed, GetPresets
rewrites the file in canonical form after releasing the read lock, so the
issue self-heals on first service start with no manual intervention.

Diagnosed via the i218 encrypted diagnostic export (device 304511B46CBC,
ST30 Master Bedroom): health check speaker_presets_count reported
"Speaker shows 0 preset slot(s); service Presets.xml has 6", and the
service log showed six [Marge] /full: skipping preset N — source ""
messages per /full call.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-23 13:08:51 +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 2b50ef0c98 fix(datastore): prefer named default entry when two default dirs collide in ListAllDevices
When the same device appears under `default/` in two separate data dirs
(e.g. primary DataDir and the legacy st-go/data path), the first-seen entry
was kept unconditionally even when it had an empty name. A subsequent
default entry carrying a real name was silently dropped, causing name loss
in SyncFromAccountFull.

Addresses TestReproduceMissingName regression introduced by the
dedup-default-last change.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-20 22:41:32 +02:00
Tobias GesellchenandClaude Sonnet 4.6 8604f1e6ba fix(datastore,health): enumerate all stale account dirs per device
User reported "we might have another issue with the account mapping"
after the prior commit only handled the default-vs-real case. The
backup at /backup/var_20260520_01 showed device A81B6A536A98 living
under four directories — accounts/9569497, accounts/default,
accounts/1111111, and the top-level default/ — only the third of
which currently receives the speaker's PUTs.

The authoritative "which account does this device belong to" signal
is the URL of the speaker's incoming PUT (per "speaker decides"),
which only the live handler observes. mtime is a proxy and can be
fooled by backup tools, manual touches, etc., so this commit drops
the mtime tiebreaker the previous attempt added.

Instead:
  - ListAllDevices' dedup keeps default-deprioritisation (clear
    placeholder semantics) but otherwise picks the first real account
    encountered in stable alphabetical order. No heuristic guessing
    among real accounts.
  - New AllAccountsForDevice(deviceID) enumerates every on-disk
    account directory containing the deviceID.
  - The consistency check's orphan finding now lists every stale
    account dir for each device, with the path the operator needs to
    inspect and a pointer to the service log so they can verify which
    account the speaker is actually targeting before deleting
    anything.

We don't delete automatically — destructive filesystem actions need
explicit operator consent (CLAUDE.md "destructive actions" rule).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-20 22:41:32 +02:00
Tobias GesellchenandClaude Sonnet 4.6 e6954eed60 fix(datastore): real account wins over "default" placeholder in dedup
ListAllDevices used to let an entry under accounts/default/devices/<id>
replace the real-account entry for the same physical device whenever
the default-side DeviceInfo.xml had a non-empty <name>. The consistency
check then reported the device under "account default" even while the
speaker was happily POST/PUT'ing to its actually-paired account — the
operator saw "preset slot 1 present on speaker but missing from
service" for slots that very obviously did exist, just under the real
account they couldn't see.

The dedup now treats "default" as a fallback placeholder: sorts it to
the back of the iteration, and never lets it replace a real-account
entry. A default-only device (fresh discovery, never paired) is still
returned exactly as before.

Also adds an orphan-detection finding in the consistency check that
walks accounts/default/devices/ directly and flags entries whose
deviceID is also paired under a real account, with a copy-pasteable
rm -rf hint. We don't delete automatically — destructive filesystem
actions need explicit operator consent (CLAUDE.md).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-20 22:41:32 +02:00
Tobias GesellchenandClaude Sonnet 4.6 f0a63f19f4 fix(datastore): self-heal legacy Audio leak on read, preserve speaker intent
The pre-fix marge.syncPresets / syncRecents path persisted the upstream
cloud's <source type="Audio"> attribute into ServicePreset.Source /
ServiceRecent.Source. That value doesn't match what the speaker writes
via its own /presets endpoint (which is the source of truth), and one
operator's consistency-check scan surfaced ~50 recent_mismatch findings
all tracing back to this single leak.

GetPresets / GetRecents now repair the leak on load: when persisted
Source is "Audio" (or empty) AND SourceID resolves in the current
Sources.xml, substitute the speaker-perspective SourceKeyType. The
repair fires only on the *leak signature* — when persisted Source
carries a non-leak symbolic value like "TUNEIN", we never touch it.

That asymmetry is load-bearing for GH-343: a TUNEIN preset whose
SourceID has been re-classified to RADIOPLAYER in Sources.xml stays
TUNEIN here. The speaker's previously-stored intent wins over a stale
current source-list entry — soundcork's blind matching_src.source_key_type
substitution is the silent rewrite we're protecting against.

Also:
  - sourceKeyTypeFromFullSource now logs when the providerid isn't
    canonical and we fall back to upstream Type, so future leak
    signatures are visible instead of silent.
  - Removes the loadServiceView workaround that resolved Source via
    SourceID at consistency-check time — datastore now repairs at
    the layer where every consumer benefits.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-20 22:41:32 +02:00
Tobias GesellchenandClaude Sonnet 4.6 ccdc2bd6a4 fix(datastore): preserve speaker's isPresetable verdict in SavePresets
SavePresets hard-coded isPresetable="true" on every persisted preset,
overwriting the speaker firmware's verdict. The speaker sets
isPresetable="false" for content it can't independently recall later
(notably Spotify Connect pushes from a phone — see GH-235); masking
that flag made the on-disk XML look valid while pressing the preset
on the speaker still did nothing, leaving users debugging a phantom
"stored but won't play" state.

Now preserve the caller's value and default to "true" only when it's
empty. A non-recallable preset is logged at info level so users can
tell from the service log why a stored preset isn't playing.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-20 22:30:53 +02:00
Tobias GesellchenandClaude Sonnet 4.6 22f60459ba fix(marge): auto-add canonical sources on UpdatePreset, accept Stockholm <username>
UpdatePreset returned "invalid account/source" with a 500 when the
speaker's preset PUT referenced a source that wasn't in AfterTouch's
per-device configured-sources list. After a factory reset the speaker
locally knows the built-in radio sources but AfterTouch's Sources.xml
may not, so a long-press appeared to succeed on the speaker but the
preset was never persisted — and the next /full sync wiped the local
copy. Closes GH-314 (and the underlying trigger described in GH-253).

For the canonical built-in IDs (10001..10005) AfterTouch now auto-adds
the source from the same template post-pair would have used, then lets
the preset land. Non-canonical / account-bound IDs (Spotify "100004",
Amazon, custom) are still rejected — we can't fabricate per-account
credentials. The rejection now logs the diagnostic context so users
don't have to grep source to understand why their long-press didn't
stick.

Also accepts the Stockholm mobile app's <username> field as the preset
name when <name> is empty (soundcork documents the same divergence).

Every code path that silently repairs preset data now logs at info
level: synthesised /full source blocks, skipped presets, auto-added
canonical sources, and the Stockholm name fallback. This makes user
diagnostic dumps actionable without source-spelunking.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-20 22:30:53 +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
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 04b3a445ca feat(bmx): make TuneIn formats= configurable via Settings.TuneInStreamFormats
PR #249 added "hls" unconditionally to TuneIn's Tune.ashx formats=
query. That regressed playback on the SoundTouch line: TuneIn returns
an .m3u8 HLS playlist for stations like K-LOVE (s33828), the speaker
can't parse it, blinks amber and falls silent. Verified that
firmware 27 on ST10 and ST20 ships the byte-identical Mozilla CCADB
bundle and validates the actual stream chain cleanly, so it isn't a
cert-expiry issue (#292's hypothesis) — the speaker simply has no
HLS support.

Changes:

  - TuneInStream is now a builder, not a const: takes the station ID
    plus a formats string (empty falls back to the new exported
    DefaultTuneInStreamFormats = "mp3,aac,ogg" — matches the pre-#249
    request shape).
  - TuneInPlayback and TuneInPlaybackPodcast take the formats string.
  - New Settings.TuneInStreamFormats string. Empty by default.
    Operators with HLS-capable speakers can set it to
    "mp3,aac,ogg,hls" — or any other comma-separated list — via
    settings.json. The value is passed through verbatim; AfterTouch
    does not validate the individual format tokens, so this is also
    the right knob for trialling additional formats without code
    changes.
  - Two regression tests pin both the empty-uses-default contract
    and the override-passes-through contract (with the whitespace-
    trim sub-case) so PR #249-style regressions surface at
    compile/test time.

The setting is settings.json-only (matches the existing pattern for
AllowInsecureUpstreamTLS / TrustForwardedHeaders / TrustedProxyCIDRs
which are also edit-the-file settings). UI surface can be a small
follow-up if reporters ask for it.

Example settings.json snippet to re-enable HLS (only if your
speaker can actually play it):

    {
      "server_url": "http://aftertouch.local:8000",
      "tunein_stream_formats": "mp3,aac,ogg,hls"
    }

Restart soundtouch-service after editing.

Related to https://github.com/gesellix/Bose-SoundTouch/issues/292.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-16 16:14:28 +02:00
Tobias GesellchenandClaude Opus 4.7 49904635f2 fix(marge): preserve CreatedOn + IPAddress across the device rename PUT
The PUT handler shipped in 5f31616 + the routing fix in 66b83b6 made
the rename PUT reach AfterTouch and return 200. But the response and
the on-disk record both drifted away from real Bose's parity on every
rename: CreatedOn was rewritten to now() (so the "first paired in
2017" semantics evaporated on the second rename) and IPAddress
landed empty (because the speaker's PUT body doesn't carry it and
the marge handler had no preservation path).

Pre-shutdown capture at
data/parity_mismatches/1771797308__streaming_account_3230304_device_A81B6A536A98.json
shows real Bose's 200 OK shape: createdOn pinned to the original
pairing timestamp (2017-02-07), ipaddress populated, only updatedOn
and name change across renames. Aligning with that.

Three small persistence additions:

  - models.ServiceDeviceInfo grows CreatedOn + UpdatedOn (ISO8601
    strings, omitempty so existing JSON consumers don't break).
  - datastore.SaveDeviceInfo persists them inside the DeviceInfo.xml
    payload as <createdOn> / <updatedOn> alongside the other fields.
  - mergeWithExistingDeviceInfo preserves CreatedOn unconditionally
    (it's the "first-paired" timestamp and never re-derived from
    inbound data) and preserves UpdatedOn only if the caller didn't
    set a fresh one.

marge.AddDeviceToAccount becomes precedence-aware:

  - Reads the existing record once at the top.
  - CreatedOn: preserved from existing if present, else now() for
    first registration.
  - IPAddress: preserves what's in the existing record; falls back
    to r.RemoteAddr's host portion only when no prior IP exists.
    Lets first-time PUTs seed an IP from the inbound connection
    without later renames clobbering a known-good value.
  - UpdatedOn: always now().
  - Response XML now re-reads the persisted record so the
    response body matches what's on disk — no parallel hand-built
    XML drifting from the merge result.

Function signature gained a remoteAddr parameter. Both callers
(HandleMargeAddDevice and HandleMargeUpdateDevice) pass r.RemoteAddr.

Test coverage:

  - TestIssue285_RenamePutAcceptedAndPersisted seeds the datastore
    with a 2017 CreatedOn and a known IP, then PUTs the rename;
    asserts both survive on disk AND in the response body, and
    that UpdatedOn refreshes. The same pre-shutdown capture cited
    above is the parity reference.

  - TestIssue285_NewDeviceGetsRemoteAddrAndFreshTimestamps (new)
    covers the no-prior-record path: first-time PUT against an
    unknown device produces CreatedOn = now() and IPAddress
    pulled from the inbound TCP connection. Pins the fallback
    behaviour so it can't quietly stop seeding new devices.

Authorization is still not enforced — the speaker has no Bose token
to send post-shutdown, and we don't (yet) have a token-authority
story of our own. Adding a warn-only auth check is a deferred
follow-up (see NEXT.md). Real Bose returned 401 for this PUT in the
2026-05-15 capture; we knowingly accept anything.

Refs #285.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 19:04:41 +02:00
Tobias GesellchenandClaude Opus 4.7 6078309724 test(datastore): compare MAC lookup to update by ratio, not wall clock
The lookup branch of AccountDeviceDir does up to two Stat() syscalls,
so its wall-clock cost is dominated by filesystem latency. On shared
CI runners that latency varies enough that the existing 70 ms absolute
threshold has been tripped repeatedly -- the previous bump from 50 ms
to 70 ms in d97cd45 was the same story. Incrementally relaxing an
absolute bound to track CI noise is a treadmill.

Replace the lookup-time wall-clock check with a ratio against the
in-memory update cost (currently ~8x on dev machines, ~12x on CI).
The 30x threshold leaves comfortable headroom for noise while still
catching an algorithmic regression in the lookup path, where the ratio
would explode well past 30 (an O(n^2) walk over 1000 entries would
push it into the hundreds).

The update path's absolute cap stays in place as a backstop against
catastrophic regressions in that hot in-memory path.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-14 16:30:56 +02:00
Tobias GesellchenandClaude Opus 4.7 eab1b7a15a fix(security): close go/path-injection alerts via os.Root containment
The previous filepath.IsLocal-up-front pattern in safeJoin/safeJoin-equivalents
turned out not to satisfy CodeQL's go/path-injection rule — the post-validation
filepath.Join still constructs the joined string from tainted input, so the
analyser conservatively assumes the os.* sink that consumes it is tainted
too. Only one of 34 alerts closed on the previous attempt.

Switch to *os.Root (Go 1.24+, available on the project's 1.26.3 toolchain).
The Go runtime guarantees that operations on a Root cannot escape the
anchored directory regardless of what's in the relative path, and CodeQL has
a built-in model that recognises *os.Root.* methods as path-traversal
sanitisers. Result: every os.* sink in the datastore, marge, recorder,
mirror parity-mismatch writer, and docs handler is now reached only via a
*os.Root, which closes the rule-level alerts cleanly.

Changes per file:

* pkg/service/datastore/datastore.go — Adds a `root *os.Root` to DataStore,
  lazily opened at first use (after MkdirAll-ing baseDir) and closed by a
  new `(*DataStore).Close()`. Adds package-private helpers
  (rootStat / rootReadFile / rootWriteFile / rootMkdirAll / rootRemove /
  rootRemoveAll / rootRename / rootReadDir / rootOpen / rootExists) plus
  three exported wrappers (ReadDirUnderBase, MkdirAllUnderBase,
  WriteFileUnderBase) for the cross-package marge / handlers callers.
  Every os.* call that previously consumed safeJoin output now goes through
  these helpers. The post-join belt-and-suspenders prefix check inside
  safeJoin is preserved as a defence-in-depth fallback.

* pkg/service/marge/marge.go — Replaces the five `os.ReadDir(devicesDir)`
  call sites with `ds.ReadDirUnderBase(...)` so the datastore's root
  enforces containment.

* pkg/service/proxy/recorder.go — Mirrors the datastore pattern with its
  own `root *os.Root` anchored at Recorder.BaseDir, lazily opened. New
  helpers convert the eight existing `os.*` sites that consume sessionID
  / relPath / sanitizedSegments inputs. The earlier safeJoin (filepath.IsLocal
  pre-check) stays in place as the same belt-and-suspenders guard.

* pkg/service/handlers/handlers_docs.go — Opens a *os.Root at "docs" via
  sync.Once and reads file content (and SUMMARY.md sidebar) through it.
  Removes the prior filepath.IsLocal pre-check; the runtime now guarantees
  containment.

* pkg/service/handlers/mirror_middleware.go — Routes the parity-mismatch
  JSON write through `s.ds.WriteFileUnderBase` so the datastore's root
  performs the path-traversal sanitiser.

Behavioural fix: *os.File.ReadDir(-1) returns directory entries in
filesystem order, but os.ReadDir is documented to sort by name and at least
one regression test
(handlers.TestMargeAccountFullExcludesEmptyAmazonSource) depends on the
sorted contract. Both rootReadDir helpers explicitly sort by name to match.

All test suites pass for the touched packages; the unrelated
TestDocsConsistency failure about untracked working-tree docs is
pre-existing. golangci-lint reports 0 issues.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 15:18:24 +02:00
Tobias GesellchenandClaude Opus 4.7 f951fc92df feat(handlers): proxy-aware RemoteAddr via opt-in TrustForwardedHeaders
Wire up X-Real-IP / X-Forwarded-For / True-Client-IP support for
deployments fronted by a reverse proxy, while staying safe on flat-LAN
deployments where a malicious speaker could spoof those headers
directly.

Two new fields on `datastore.Settings`:

* TrustForwardedHeaders (bool, default false) — opt-in switch.
* TrustedProxyCIDRs ([]string, default `["127.0.0.0/8", "::1/128"]`)
  — only requests whose immediate TCP peer falls in one of these
  blocks may have their source IP rewritten from forwarded headers.
  Loopback default matches the documented same-host nginx layout in
  docs/guides/HTTPS-SETUP.md.

New middleware in `pkg/service/handlers/middleware_realip.go`:

* TrustedRealIP wraps `chi/middleware.RealIP` with a trusted-peer
  gate. When the immediate TCP peer is in the allowlist, chi's
  parsing handles the actual header → IP rewrite. When it isn't
  (e.g. a speaker sending forwarded headers itself), we ignore the
  headers and r.RemoteAddr stays as-is.
* ParseTrustedProxyCIDRs converts string CIDRs into *net.IPNet,
  applying the loopback default on empty input and erroring loudly
  on invalid entries.

Server.TrustedRealIPMiddleware() returns the middleware (or nil) by
reading the live settings; the router setup in
cmd/soundtouch-service/main.go installs it as the very first
middleware so SnapshotMiddleware and downstream handlers see the
correct r.RemoteAddr.

HandleMargePowerOn now prefers r.RemoteAddr over the body's
self-reported `<IPAddress>` for outbound credential push:

* The body field is treated as a hint only — a malicious LAN speaker
  could set it to any value; using it for outbound HTTP requests is
  the SSRF surface the previous zeroconf hardening was guarding
  against from the sink side. Fixing it at the source as well closes
  the gap entirely.
* When body IP and TCP source disagree, a log line names both and
  the device ID so the discrepancy is investigable.
* RemoteAddr is unparseable → fall back to the body so we don't
  silently drop the priming.

docs/guides/HTTPS-SETUP.md gains a follow-up note next to the existing
nginx snippet explaining the new flag, the loopback-only default, and
the explicit warning against enabling the flag on a flat-LAN
deployment without a real proxy.

Eleven test cases in middleware_realip_test.go lock in the gate
behaviour: trusted peers honoured for X-Real-IP / X-Forwarded-For /
no-headers / IPv6, untrusted peers' headers ignored, garbage values
rejected, ParseTrustedProxyCIDRs covers default / override / invalid.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 14:40:15 +02:00
Tobias GesellchenandClaude Opus 4.7 fbeae8bb11 fix(security): make upstream TLS verification opt-in via settings flag
CodeQL alerts #70 and #71 (go/disabled-certificate-check) flagged the
hard-coded `InsecureSkipVerify: true` in handlers_proxy.go (the
/proxy/{url} reverse proxy) and mirror_middleware.go (the parity-check
mirror). Both target *.bose.com whose certificate chain is becoming
unreliable post end-of-service, but unconditionally disabling
verification is still wrong: a deployment that doesn't actually need
the bypass loses TLS hygiene for free.

Add an `AllowInsecureUpstreamTLS bool` field to datastore.Settings,
default false. Read it in both call sites — they aren't on a hot path
— and pass the value as InsecureSkipVerify. CodeQL accepts the
configurable boolean as a non-flag (vs. the previously hard-coded
`true`), and the runtime behaviour now defaults to verifying
certificates with an explicit opt-in for the broken-chain scenario.

Behaviour change: TLS upstream traffic is verified by default. Anyone
relying on the previous always-skip behaviour can re-enable it by
setting `"allow_insecure_upstream_tls": true` in settings.json.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 14:30:55 +02:00
Tobias GesellchenandClaude Opus 4.7 648eedefde fix(security): close go/path-injection alerts via filepath.IsLocal sanitiser
CodeQL flagged 34 go/path-injection alerts across datastore.go, marge.go,
recorder.go, handlers_docs.go and mirror_middleware.go. The existing
defences (DataStore.safeJoin's post-join prefix check, handlers_docs's
HasPrefix(filepath.Clean(...))) are functionally correct but sit
downstream of the join, so CodeQL's interprocedural taint tracking
treats every os.* sink that consumes them as still tainted.

Move the validation up-front using filepath.IsLocal, which CodeQL
recognises as a path-traversal sanitiser. IsLocal rejects absolute
paths, ".." segments, and (on Windows) reserved device names — the
same set the existing checks intended to block, just expressed in the
shape the analyser understands.

Changes:

* DataStore.safeJoin (datastore.go) — pre-validates each non-empty
  element with filepath.IsLocal before joining. Existing post-join
  prefix check stays as belt-and-suspenders. ~30 of the 34 alerts
  flow through this helper.

* Recorder (recorder.go) — adds a new (*Recorder).safeJoin method
  with the same sanitiser. getRecordingDir, DeleteSession,
  GetInteractionContent and ArchiveSession route through it; their
  signatures already returned error so plumbing it through is local.

* HandleDocs (handlers_docs.go) — replaces the post-join HasPrefix
  check with an up-front filepath.IsLocal gate.

* Mirror parity recorder (mirror_middleware.go) — also strips
  backslash separators (Windows) and gates the resulting filename
  component on filepath.IsLocal, falling back to "invalid" rather
  than letting malformed paths reach os.WriteFile.

No behaviour change for legitimate inputs (account IDs, device IDs,
session IDs, doc paths all satisfy IsLocal). Datastore and proxy
test suites pass; handler suite's pre-existing TestDocsConsistency
failure is unrelated.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 14:30:55 +02:00
255dd9612a fix(datastore): normalize AUX source to canonical id/type after sync (#233)
The on-device Sources.xml carries only displayName + sourceKey for AUX,
no id and no type. The previous read path synthesized id="2000001+i" and
type="AUX" (echoed from SourceKey.Type), which the speaker rejects as
INVALID_SOURCE once it pulls config from soundtouch-service after
migration. Look up known providers in getDefaultSources and fill
canonical id/type/sourceproviderid; also drop the AUX carve-out in
marge's ensureSourceType so existing poisoned type="AUX" entries are
normalized to type="Audio" at the served-XML layer.

Relates to https://github.com/gesellix/Bose-SoundTouch/issues/195

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 14:16:19 +02:00
e99c04888c feat: implement missing endpoints and serve static resources from downloads/media hosts (#199)
Endpoints:

- POST /streaming/music/musicprovider/{id}/trial/is_eligible (reuses
is_eligible handler)
- POST /bmx/tunein/v1/favorite/{stationID} with datastore persistence
(SaveTuneInFavorite)
- DELETE /bmx/tunein/v1/favorite/{stationID} (DeleteTuneInFavorite)
- POST /bmx/core02/svc-bmx-adapter-orion/prod/orion/token (anonymous
Orion token)
- GET /bmx-icons/* serving embedded static/media assets (media.bose.io)
- GET /ced/* serving embedded firmware index, release notes, and 10
app-help XMLs (downloads.bose.com)

Add media.bose.io and downloads.bose.com to DNS redirect lists (setup.go
both domain slices, dns.go shouldIntercept list, main.go getDomains
map). Document implemented endpoints in
tests/interactions_20260502_missing_external.md; mark rows 0246–0247 as
self/☑ in the interactions table.

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-02 21:22:36 +02:00
Tobias GesellchenandClaude Sonnet 4.6 147a1a8490 feat: add Spotify and Amazon credential fields to Settings UI
- Add SpotifyClientID/Secret/RedirectURI and AmazonClientID/Secret/RedirectURI
  fields to datastore.Settings for persistent storage
- Server: add amazonClientID/Secret/RedirectURI fields, SetAmazonConfig,
  GetSpotifyConfig/GetAmazonConfig, ReinitSpotifyService/ReinitAmazonService,
  and applyMusicServiceCredentials (called under lock from HandleUpdateSettings)
- GET /setup/settings: expose credential fields; mask secrets as "***" when set
- POST /setup/settings: apply credential updates and reinitialize services live
- applyPersistedSettings: fill in music credentials from settings.json when not
  set via CLI/env (CLI takes precedence)
- Settings tab: replace read-only Spotify status with editable Client ID / Secret /
  Redirect URI inputs for both Spotify and Amazon; save via existing Save button
- script.js: populate and collect the six new fields in fetchSettings/updateSettings

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-29 20:30:06 +02:00
Tobias GesellchenandClaude Sonnet 4.6 5fbad7d315 feat: add Amazon Music source classification and fix ETag caching
- Recognize Amazon Music in learned sources (classifyAsAmazon) and
  AddSource dispatch, using CredentialTypeToken (cs1) not cs3
- Exclude Amazon from default sources: an empty-credential Amazon entry
  triggers the speaker's AmazonController to fail JSON parsing with
  MUSIC_SERVICE_ACCOUNT_LOGIN_FAILED; Amazon must only appear once a
  real OAuth token is present
- Merge missing defaults into stored sources at request time so devices
  with older Sources.xml still receive all current defaults
- Fix source providers ETag: was time.Now().UnixMilli() (always new),
  now a content hash so If-None-Match/304 works correctly
- Include default sources fingerprint in GetETagForAccount so adding a
  new default invalidates cached /full responses on speakers
- Refactor createLearnedSource into classifyLearnedSource +
  classifyAsX helpers to reduce cyclomatic complexity below linter limit
- Add regression test for two-device scenario matching production setup

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-29 20:30:06 +02:00
Tobias Gesellchen c6fbc45be5 lint 2026-04-28 17:57:46 +02:00
9412b5ffa0 feat: add group CRUD endpoints (POST add, POST modify, DELETE delete) (#191)
Groups (stereo pairs of ST10 speakers) were read-only — the GET endpoint
always returned an empty <group/>. Add POST /account/{account}/group,
POST /account/{account}/group/{groupId}, and DELETE
/account/{account}/group/{groupId} with datastore persistence, matching
the API shape observed in soundcork. The GET endpoint now reads live
group state from the datastore.

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-28 15:28:38 +02:00
Tobias Gesellchen 174d087b8e Do not duplicate existing sources with default sources 2026-04-23 22:08:54 +02:00
Tobias GesellchenandGitHub ceb08cd6bf Fix ETag for account-level endpoints (#177) 2026-04-20 21:09:00 +02:00
Tobias Gesellchen 5e6885cfe8 Add missing RADIO_BROWSER default source 2026-04-20 19:18:39 +02:00
Tobias GesellchenandGitHub 1fecb3948e Refactor constants for sources and source providers (#168) 2026-04-17 19:08:50 +02:00