- Refactors BMX service to support multiple radio providers
- Adds RadioBrowser.com API integration with search and browse
- Splits TuneIn logic into separate module for better organization
- Adds new web UI components for radio station discovery
- Includes new SVG icons for RadioBrowser branding
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>
Status was a value-typed DeviceStatus field on DeviceConnection,
written from the periodic poller (UpdateDeviceStatus) and from four
WebSocket event handlers (OnNowPlaying, OnVolumeUpdated,
OnConnectionState, OnPresetUpdated) while being read from every HTTP
handler and the WebSocket broadcaster. The struct was 8+ words wide
with time.Time and string members, so concurrent readers could
observe torn fields or mixed-update snapshots. The map-level race was
fixed in the previous commit; this one closes the per-connection
struct race.
Hide the field behind atomic.Pointer[DeviceStatus]:
Status() // returns current snapshot
SetStatus(*DeviceStatus) // wholesale replace
UpdateStatus(func(*DeviceStatus)) // CAS retry loop
NewDeviceConnection constructs a connection with the atomic pointer
pre-initialised, so Status() never returns nil for callers that go
through the constructor (the old struct-literal pattern is no longer
possible because the status field is now private).
UpdateDeviceStatus runs network fetches into local vars first, then
batches them into a single UpdateStatus call so the CAS loop only
retries the merge — not the slow IO. WebSocket event handlers and
the connect/disconnect transitions each use UpdateStatus, so any
ordering of poller + event delivery converges to a consistent
status.
The UpdateStatus docstring is explicit about the shallow-copy
contract: nested pointer fields (NowPlaying, Volume, Bass, Presets,
Sources) MUST be replaced, not mutated through, because the copy
mut receives shares those pointers with the prior snapshot. All
production callers already follow this pattern (every value comes
fresh from the device API).
Tests:
- types_test.go: migrated literal struct to NewDeviceConnection +
SetStatus, switched reads to Status().
- status_test.go (new): six tests covering constructor init,
SetStatus replacement semantics, UpdateStatus mutator
application, field preservation across UpdateStatus, snapshot
isolation (old snapshot stable under later writes), and a
concurrent stress test (16 writers + 32 readers x 200 ops) that
runs under -race.
- handlers_test.go, registry_test.go, spa_test.go: migrated to
constructor.
Not addressed by this commit:
- DeviceConnection.WebSocket (set once in ConnectDeviceWebSocket,
read elsewhere). Word-sized pointer, atomic at the hardware
level on amd64/arm64; race detector may still flag.
- DeviceConnection.LastSeen (written under devicesMu by the
registry, read outside that lock via DeviceSnapshot consumers).
time.Time is non-atomic but the read is cosmetic.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The Devices map on WebApp was written from the startup goroutine, the
/api/discover POST handler, and addDevice, while being read from every
HTTP handler and the WebSocket periodic-update loop — all without any
mutex. The Go runtime panics with "fatal error: concurrent map writes"
or "concurrent map read and map write" on any actual collision, so this
was a latent crash, not a tearing issue.
Hide the map behind a sync.RWMutex and a small API:
GetDevice(id) (*DeviceConnection, bool)
DeviceSnapshot() []DeviceEntry
DeviceCount() int
AddDevice(id, conn) bool // atomic insert-or-touch
TouchDevice(id) bool // fast-path LastSeen bump
Update every caller — handlers, websocket, main, tests — to go through
the API. addDevice's existing-host fast path uses TouchDevice; the
final insert uses AddDevice so a race with another writer is rejected
cleanly instead of silently overwriting.
Add a TestRegistryConcurrent stress test that runs 64 goroutines doing
12,800 operations across writers, touchers, and two reader patterns.
It exists to give `-race` (already on in CI) a concrete shape to catch
if the encapsulation ever leaks back out.
Struct-field races on conn.Status.* are not addressed by this change;
they need their own follow-up.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>