Commit Graph
1225 Commits
Author SHA1 Message Date
Lukáš Lipinský 0748e4042c feat(client): add capability-driven device settings 2026-09-05 13:32:57 +02:00
Tobias GesellchenandClaude Sonnet 5 369887f642 fix(cli): stop pushing to Marge, make its preflight read advisory
Same fix as -service and -player, for consistency: newGroupCoordinator
proactively pushed group cleanup/rename to an external Marge backend
the CLI doesn't own. A speaker's own firmware already self-reports
that create/rename/teardown to whatever Marge backend it's configured
with -- that's the entire reason HandleMargeAddGroup/HandleMargeModifyGroup/
HandleMargeDeleteGroup exist, they're only ever called by speakers.

Extracted the wiring into cliStereoPairGenerationPersistence (mirrors
the -service/-player equivalents): cleanup and rename are now no-ops,
and preflight's read-only dangling-generation check stays but its
failure no longer blocks Create -- it's printed as a warning instead.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-05 12:29:37 +02:00
Tobias GesellchenandClaude Sonnet 5 cb87d86a0b fix(player): stop pushing to Marge, make its preflight read advisory
Same reasoning as the -service fix: NewWebApp's default generation-
lifecycle wiring (used by the standalone player, and by embedded
-service until SetStereoPairGenerationPersistence overrides it)
proactively pushed group cleanup/rename to an external Marge backend
the player doesn't own. A speaker's own firmware already self-reports
that create/rename/teardown to whatever Marge backend it's configured
with -- that's the entire reason HandleMargeAddGroup/HandleMargeModifyGroup/
HandleMargeDeleteGroup exist, they're only ever called by speakers.

Extracted the wiring into playerStereoPairGenerationPersistence
(mirroring cmd/soundtouch-service's own testable helper): cleanup and
rename are now no-ops, and preflight's read-only dangling-generation
check stays but its failure no longer blocks Create.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-05 12:29:37 +02:00
Tobias GesellchenandClaude Sonnet 5 66eedeefd3 fix(stereo): stop pushing to external Marge, make its preflight read advisory
A speaker's own firmware self-reports its group create/rename/teardown
to whatever Marge backend it's configured with -- that's the entire
reason HandleMargeAddGroup/HandleMargeModifyGroup/HandleMargeDeleteGroup
exist, they're only ever called by speakers, never by us. Coordinator
cleanup/rename proactively pushing the same update to an external
(non-local) Marge target duplicated that self-report against a backend
we generally can't authenticate to anyway (real Bose cloud, another
instance, ...), for zero benefit.

The one part of the external path with a real, distinct purpose --
preflight's read-only check for a dangling stale generation before a
new Create -- stays, but its failure (network error, wrong
credentials, an unreachable backend) no longer blocks Create. It's a
best-effort safety net on top of the coordinator's own physical
preflight (capability/zone/reachability checks against the live
speakers), not the primary guard, and the live speakers' own state is
the authoritative signal either way.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-05 12:29:37 +02:00
Tobias GesellchenandClaude Sonnet 5 00803d0947 fix(stereo): recognize DNS-hijacked speakers as local for generation preflight
A speaker migrated at the DNS level keeps reporting its own MargeURL as
the literal Bose cloud hostname (e.g. https://streaming.bose.com) --
DNS migration only changes how that hostname resolves on the network,
never the device's own advertised URL. embeddedStereoPairGenerationPersistence's
isLocal() only matched against this service's own advertised server
URL, so it misclassified such a speaker as "external" and sent the
stereo-pair Create preflight's generation-conflict check out over the
real internet instead of checking the local datastore. Bose's cloud is
still live enough to answer (just not to authenticate us), so the
check failed with HTTP 401 and hard-blocked Create for an otherwise
perfectly normal DNS-migrated setup.

Added a DNS-hijack-aware check: if this service's own DNS hijack is
active and the reported MargeURL's host is one of the known redirected
Bose hostnames (discovery.InterceptedBoseHosts), treat it as local too.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-05 12:29:37 +02:00
Tobias GesellchenandClaude Sonnet 5 d987fa8c9f test(player): wire frontend_test/ into CI
#672 added pkg/service/soundtouchweb/frontend_test/*.test.mjs (Node's
built-in test runner) but never hooked them into anything: not the
Makefile, not any GitHub Actions workflow. They only ran if someone
happened to invoke `node --test` manually, so CI would stay green even
if isSoundTouch10StereoPair or the DeviceDetail notice markup broke.
Added `make test-frontend` and a step in browser-tests.yml (which
already runs the Go-side chromedp player tests) to run them on every
push/PR, matching the pinned actions/setup-node version already used
by update-static-deps.yml.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-05 11:56:18 +02:00
Lukáš Lipinský cdd06e367a docs(player): explain stereo-pair AirPlay limit 2026-09-05 11:56:18 +02:00
Lukáš Lipinský cb441da981 style(player): satisfy zone handler lint 2026-09-05 11:38:49 +02:00
Lukáš Lipinský ca2e2f9257 fix(player): guard multiroom zone creation 2026-09-05 11:38:49 +02:00
Lukáš Lipinský aa16d1040b Prevent long now-playing metadata overflow
Constrain device and playback metadata across narrow layouts while retaining complete values through tooltips and a touch-friendly details disclosure, including RAOP tracks.
2026-09-05 11:36:54 +02:00
Tobias GesellchenandClaude Sonnet 5 96b1de2163 fix(handlers): stop TestCheck443Reachability_LANProbeMatchesListenerOutcome dialing a real IP
The test's own comment said "we point the LAN host at 127.0.0.1", but
the resolver stub actually returned 1.2.3.4 -- a real, internet-
routable address, not 127.0.0.1 -- since the test was first written.
Probing an arbitrary internet destination's reachability depends on
the tester's own network path: a transparent proxy, a DPI middlebox,
or "known test IP" sinkholing can all make 1.2.3.4:443 appear
reachable, failing the test's core assumption outside a sandboxed CI
network. See #683.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-05 11:13:47 +02:00
Tobias GesellchenandClaude Sonnet 5 b534bc5615 refactor(models): consolidate group-role topology equality
pkg/stereopair's sameRoles and pkg/service/datastore's
sameGroupGenerationTopology independently reimplemented the same
Role-keyed topology comparison, but normalized IP addresses
differently (net.ParseIP-only vs. plain string equality) -- exactly
the class of disagreement models.SameGroup was already created to fix
for order-sensitivity. Both now delegate their per-role comparison to
a new models.SameGroupRoles, which treats equal-but-differently-
formatted IPs as a match without regressing the common
both-addresses-unset case either implementation relied on.
models.SameGroup and datastore's sameStereoPair stay distinct
(commented why): both are intentionally ID/IP-agnostic for reasons
unrelated to this consolidation.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-05 11:08:04 +02:00
Tobias GesellchenandClaude Sonnet 5 dbedef614b fix(service): stop async stereo-pair refresh from clobbering a fresher projection
completeStereoPairMutation's refreshStereoPairMembersAsync ran after
applyStereoPairProjection, and its UpdateDeviceStatus call always
minted a strictly newer group generation via BeginGroupRefresh -- so
ApplyPolledGroup's staleness guard could never reject it, even if its
/getGroup read raced a slower path and was stale relative to the
mutation that had already completed. The refresh now snapshots each
member's post-projection generation and only applies its own read via
ApplyPolledGroupIfBaseline, which requires nothing else (no other
event or poll) to have changed group state in the meantime. Other
UpdateDeviceStatus callers keep their existing always-newer semantics.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-05 11:08:04 +02:00
Tobias GesellchenandClaude Sonnet 5 aa49d904c4 fix(stereo): retry dissolve on a member that never converges
applyDissolve tore down LEFT/RIGHT as independent goroutines with no
compensation path: a partial failure (one member's RemoveGroup
succeeds, the other never verifies empty even after the existing
reverification retries) only ever produced StatusDegraded, requiring
manual operator intervention. compensateDissolve retries RemoveGroup
on any member still unverified, giving it the same chance to converge
that Create's compensateCreate already gets on its own partial
failures.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-05 11:08:04 +02:00
Tobias GesellchenandClaude Sonnet 5 2014c95ac4 fix(marge): delete stored groups on account teardown
HandleMargeDeleteAccountGroups (DELETE /streaming/account/{id}/group/,
no group ID) had become a pure acknowledgement, leaving
DeleteAllGroupsForAccount dead code. This is the exact request real
firmware sends on factory reset/teardown; skipping the delete leaves a
stale Group_*.xml behind, permanently rejecting the next legitimate
Create for those devices via EnsureNoGroupsForDevices with no operator
remedy.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-05 11:08:04 +02:00
Tobias GesellchenandClaude Sonnet 5 536b465a51 fix(stereo): drop unjustified same-Marge-account Create restriction
validateCreateCandidates rejected LEFT/RIGHT pairs whenever their
MargeAccountUUID differed, even though no hardware or design
requirement calls for it: the pre-lifecycle CLI's direct /addGroup
calls never checked Marge accounts, and the existing read/display
projection has no account awareness either. Confirmed via live
hardware testing that this newly rejects a real, previously-working
cross-account stereo pair.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-05 11:08:04 +02:00
Tobias Gesellchen 3cdd20883d chore(player): fix import grouping after rebase conflict resolution
goimports grouping regressed to a flat alphabetical block during the
i655 rebase's conflict resolution; restore stdlib/third-party grouping.
2026-09-05 11:08:04 +02:00
Lukáš Lipinský 8e4520c292 fix(stereo): simplify cleanup classification 2026-09-05 11:08:04 +02:00
Lukáš Lipinský c8f0d9e580 fix(stereo): preserve persistence conflicts 2026-09-05 11:08:04 +02:00
Lukáš Lipinský 6324448e64 fix(stereo): recover from partial rename drift 2026-09-05 11:08:04 +02:00
Lukáš Lipinský f4ef38fc3b fix(player): publish stereo lifecycle projections 2026-09-05 11:08:04 +02:00
Lukáš Lipinský 58c8baa113 fix(stereo): accept right-role group masters 2026-09-05 11:08:04 +02:00
Lukáš Lipinský 2d074d4ecf fix(stereo): skip inspection on unsupported models 2026-09-05 11:08:04 +02:00
Lukáš Lipinský 4bd548915f refactor(stereo): satisfy cleanup lint 2026-09-05 11:08:04 +02:00
Lukáš Lipinský 22586da982 fix(stereo): verify wrapped missing-group cleanup 2026-09-05 11:08:04 +02:00
Lukáš Lipinský a0d1fa7a04 feat: manage stereo pair lifecycle 2026-09-05 11:08:04 +02:00
Lukáš Lipinský fc585e98a0 fix(player): publish speaker events immediately 2026-09-05 11:08:04 +02:00
Tobias Gesellchen ca50451f9c fix(player): ignore port when checking WebSocket handshake origin
Gorilla's default same-origin CheckOrigin compares Origin and Host as
raw strings, port included. This repo's own documented nginx reverse-
proxy config forwards a portless Host header (nginx's $host never
includes the port, unlike $http_host) regardless of what public port
the proxy listens on. That's harmless on the scheme's default port
(the browser's Origin also omits it there), but on a non-default
public port (e.g. :8443, a realistic multi-service-hosting shape) the
browser's Origin keeps the port while the forwarded Host doesn't --
gorilla's strict compare then 403s every WebSocket handshake, silently
breaking the player's live updates in a deployment topology the docs
actively recommend.

Add checkWebSocketOrigin/sameHostIgnoringPort: gorilla's own default
policy, but comparing hostname only. Same-origin and cross-hostname
behavior is unchanged; only a port mismatch on an otherwise-matching
hostname is now tolerated. Also extracted newTestWebSocketServer,
shared by dialTestWebSocket and the origin-policy test, instead of the
origin test re-implementing the same httptest scaffolding inline.

Found in code review of PR #669 (findings #1, #2).
2026-09-04 22:34:35 +02:00
Lukáš Lipinský 32c1040554 fix(player): enforce same-origin WebSockets 2026-09-04 22:34:35 +02:00
Tobias Gesellchen 6689bbbe23 fix(service): make status-poll vs. push-event ordering per field, not per connection
BeginStatusPoll/ApplySpeakerEvent/CompleteStatusPoll gated an entire
poll's merge (NowPlaying/Volume/Presets/Sources/Bass/IsConnected) behind
one shared speakerEventGeneration counter. Any unrelated push event
during the poll's flight discarded the whole result -- not just the
field that event touched. Sources has no push event at all, so it could
go stale indefinitely under ordinary event traffic, defeating both call
sites that depend on this poll (the 30s fallback poll and the
post-reconnect refresh).

Replace it with StatusField + BeginFieldPoll/CompleteFieldPoll/
ApplyFieldEvent: the same two-counter (issued/applied) pattern already
used for Group, generalized to one instance per independently-racing
field via a small fixed-size array. A poll or event for one field can
now only ever supersede that same field, never a different one. This
also removes the map-based generation bookkeeping the old mechanism
needed (issue/lookup/prune per poll) and the unreachable
"unknown generation" branch it required.

applyGroupUpdatedEvent now shares the same queueBroadcastIfChanged
helper as the other five event types, instead of duplicating the
"broadcast if changed" check inline.

Found in code review of PR #666 (findings #1, #2, #3, #4).
2026-09-04 21:56:37 +02:00
Tobias Gesellchen 4b7c088a50 test(player): adapt browser-WS tests to the per-connection write lock
Two tests from PR #666 simulated "the browser WS write path is busy" by
holding the global webSocketWriteMu, which #665's fix removed. Adapt
them to the per-connection replacement: register a connection and hold
its own lock directly (same technique as the #665 test suite), rather
than a lock that no longer exists.
2026-09-04 21:56:37 +02:00
Lukáš Lipinský faaa75d614 test(websocket): group imports 2026-09-04 21:56:37 +02:00
Lukáš Lipinský 01ce3f2df1 fix(player): publish speaker events immediately 2026-09-04 21:56:37 +02:00
Tobias Gesellchen a951758463 fix(player): serialize browser WebSocket writes per-connection, not globally
webSocketWriteMu serialized writes across ALL browser WebSocket
connections, not just the single connection gorilla actually requires.
HandleDeleteDevice's synchronous BroadcastDeviceList call, and every
other client's own periodic update, all contended on one lock -- directly
contradicting the PR's own goal that a stalled client cannot block
healthy ones.

Replace it with a per-connection *sync.Mutex stored in WSClients
(withConnWrite). Registration is fully decoupled from discovery-status
publication: a new connection reads whatever discoveryStatus.Load()
currently returns and is never blocked by an in-flight publication,
which stays safe because Store() always commits before a publication
takes its client snapshot. BroadcastDeviceList/BroadcastDiscoveryStatus
now write each client under only that client's own lock.

Found in code review of PR #665 (finding #1).
2026-09-04 21:13:44 +02:00
Tobias Gesellchen d0e8194ab5 fix(player): normalize stereo-pair member Role/DeviceID in JSON
newStereoPairView emitted raw, un-normalized role.DeviceID/role.Role
while validMasterGroup/registeredMembersAgree/sameGroupClaim trim and
uppercase those same fields for internal comparison. Normalize before
assigning so a future frontend feature reading member.Role/.DeviceID
directly doesn't need to re-normalize it itself.

Found in code review of PR #665 (finding #8).
2026-09-04 21:13:44 +02:00
Tobias Gesellchen fd62f6fbeb fix(player): route HandleAPIDevice through stereo-pair projection
The singular GET /api/control/devices/{id} bypassed the projection that
HandleAPIDevices and both WebSocket frames already apply. A hidden
stereo-pair member was absent from the list but still fully fetchable,
unprojected, by its own id. Add deviceViewForID and return 404 for a
hidden member's own id, consistent with it already being absent from
the list.

Found in code review of PR #665 (finding #3).
2026-09-04 21:13:44 +02:00
Tobias Gesellchen 78c847f929 fix(service): invalidate polled Group by completion order, not start order
BeginGroupRefresh/ApplyPolledGroup keyed invalidation off "has any newer
poll started" via groupGeneration equality. A later poll that starts but
never applies (its own GetGroup fails) still discarded an earlier poll's
still-arriving successful result, even though nothing newer ever actually
landed. Add groupAppliedGeneration and gate on "strictly newer than the
last applied", not "equal to the latest issued".

Found in code review of PR #665 (finding #2).
2026-09-04 21:13:44 +02:00
Lukáš Lipinský 4b3b455d52 fix(player): serialize browser WebSocket writes 2026-09-04 21:13:44 +02:00
Tobias GesellchenandClaude Sonnet 5 8de3d56d4b Fix HTTP-client integration test mock images and improve failure logging
The mock services in docker-compose.ci.yml were pinned to
golang:1.27.0-alpine, which is now too old to run against go.mod's
1.27.1 requirement (GOTOOLCHAIN=local makes this an immediate, silent
container crash: "go.mod requires go >= 1.27.1 (running go 1.27.0)").
Bump all three mock images to 1.27.1-alpine to match.

Also make `make test-http-client` dump docker compose logs (and tear
down) whenever `docker compose up --wait` itself fails, not only
after the .http test run — that path previously aborted with no
diagnostic output at all. Switch the post-run log dump to plain
`docker compose logs` (all services) instead of three hardcoded
per-service calls, which had silently omitted tunein-mock.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-03 19:14:12 +02:00
Tobias GesellchenandClaude Sonnet 5 ec02b24e8c Bump Go toolchain requirement to 1.27.1
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-03 19:14:12 +02:00
Tobias GesellchenandClaude Sonnet 5 1752e7c79d Raise chromedp websocket-URL read timeout to reduce CI flakiness
The default 20s wsURLReadTimeout in chromedp's exec allocator can be
too tight on a loaded shared CI runner spawning headless Chrome,
surfacing as an unrelated "websocket url timeout reached" test
failure. Raise it to 45s and widen the per-test context to match.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-03 19:04:51 +02:00
dependabot[bot] a3d4f0701b deps(deps): bump filippo.io/age from 1.3.1 to 1.3.2
Bumps [filippo.io/age](https://github.com/FiloSottile/age) from 1.3.1 to 1.3.2.
- [Release notes](https://github.com/FiloSottile/age/releases)
- [Commits](https://github.com/FiloSottile/age/compare/v1.3.1...v1.3.2)

---
updated-dependencies:
- dependency-name: filippo.io/age
  dependency-version: 1.3.2
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-09-03 18:54:36 +02:00
dependabot[bot] 0c64136967 docker(deps): bump golang from 1.27.0-alpine to 1.27.1-alpine
Bumps golang from 1.27.0-alpine to 1.27.1-alpine.

---
updated-dependencies:
- dependency-name: golang
  dependency-version: 1.27.1-alpine
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-09-03 18:43:51 +02:00
Tobias Gesellchen 42419a12ae Bump golang.org/x/crypto to v0.56.0
See https://pkg.go.dev/vuln/GO-2026-6354 and https://pkg.go.dev/vuln/GO-2026-6355
2026-09-03 18:43:35 +02:00
dependabot[bot] 1d6014f24f ci(deps): bump the codeql-action group with 3 updates
Bumps the codeql-action group with 3 updates: [github/codeql-action/init](https://github.com/github/codeql-action), [github/codeql-action/analyze](https://github.com/github/codeql-action) and [github/codeql-action/upload-sarif](https://github.com/github/codeql-action).


Updates `github/codeql-action/init` from 4.37.8 to 4.37.9
- [Release notes](https://github.com/github/codeql-action/releases)
- [Changelog](https://github.com/github/codeql-action/blob/main/CHANGELOG.md)
- [Commits](https://github.com/github/codeql-action/compare/db488ddef3bf6cb639b32c2e9a7c0a7ea8271d28...cdf488f595d80d6e07e03d4674febd5ab45fa938)

Updates `github/codeql-action/analyze` from 4.37.8 to 4.37.9
- [Release notes](https://github.com/github/codeql-action/releases)
- [Changelog](https://github.com/github/codeql-action/blob/main/CHANGELOG.md)
- [Commits](https://github.com/github/codeql-action/compare/db488ddef3bf6cb639b32c2e9a7c0a7ea8271d28...cdf488f595d80d6e07e03d4674febd5ab45fa938)

Updates `github/codeql-action/upload-sarif` from 4.37.8 to 4.37.9
- [Release notes](https://github.com/github/codeql-action/releases)
- [Changelog](https://github.com/github/codeql-action/blob/main/CHANGELOG.md)
- [Commits](https://github.com/github/codeql-action/compare/db488ddef3bf6cb639b32c2e9a7c0a7ea8271d28...cdf488f595d80d6e07e03d4674febd5ab45fa938)

---
updated-dependencies:
- dependency-name: github/codeql-action/init
  dependency-version: 4.37.9
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: codeql-action
- dependency-name: github/codeql-action/analyze
  dependency-version: 4.37.9
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: codeql-action
- dependency-name: github/codeql-action/upload-sarif
  dependency-version: 4.37.9
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: codeql-action
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-09-01 21:42:27 +02:00
dependabot[bot] dfff1459b6 ci(deps): bump softprops/action-gh-release from 3.0.2 to 3.0.3
Bumps [softprops/action-gh-release](https://github.com/softprops/action-gh-release) from 3.0.2 to 3.0.3.
- [Release notes](https://github.com/softprops/action-gh-release/releases)
- [Changelog](https://github.com/softprops/action-gh-release/blob/master/CHANGELOG.md)
- [Commits](https://github.com/softprops/action-gh-release/compare/3d0d9888cb7fd7b750713d6e236d1fcb99157228...efb35369e0ad2afab669f228072c1b0d510eae64)

---
updated-dependencies:
- dependency-name: softprops/action-gh-release
  dependency-version: 3.0.3
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-09-01 21:42:06 +02:00
Tobias GesellchenandClaude Sonnet 5 4478863d7c test(service): update router route snapshot for the new zone candidates endpoint
TestPrintRoutes compares against a checked-in route list; the new
GET .../zone/candidates route (added for Zone.js's candidate source
fix) needs to be reflected there too.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-30 22:27:56 +02:00
Tobias GesellchenandClaude Sonnet 5 862ddbb87d fix(player): follow Library's selected device to its pair master
If the device selected in the Library tab disappeared from the
devices map (e.g. it just became a hidden stereo-pair member per
device_projection.go), the sync effect fell back to entries[0][0] --
whichever key happens to sort first in the map -- silently redirecting
the user's Library browsing session to an unrelated speaker.

Now checks first whether the vanished device reappears as a member of
some other device's stereoPair (the pair's master, which now
represents the same physical speaker for control purposes) and
follows it there. Only falls back to an arbitrary device when the
selection is gone for a genuinely unrelated reason (removed, discovery
gap), matching the prior behavior for that case.

No JS unit-test framework exists in this repo for component-level
logic (consistent with the rest of the client-side code), so this is
verified by manual trace rather than an automated regression test.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-30 22:27:56 +02:00
Tobias GesellchenandClaude Sonnet 5 78c6c1bfca fix(player): make Zone.js source zone candidates independently of Group projection
Zone.js derived its "add speaker" candidate list from the `devices`
prop, which is app.js's projected/collapsed device list. Once the
stereo-pair projection (device_projection.go) started hiding a pair's
non-master member from that list, it silently became impossible to
add that physical device to an unrelated multiroom zone, even though
the backend's HandleZoneAdd/HandleZoneRemove already operate on the
raw device registry directly and never cared about pairing at all.
Zone.js's own file wasn't touched by that change; its effective input
just changed underneath it.

Added GET /api/control/devices/{id}/zone/candidates, deliberately
bypassing deviceViewSnapshot's projection and deliberately not
excluding {id} itself -- which candidates to exclude is a caller
concern (Zone.js already does this via the existing zoneIps set,
which includes the zone master's own IP even when standalone, per
models.ZoneInfo.IsStandalone). Zone and Group are separate, unrelated
groupings and should stay that way.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-30 22:27:56 +02:00
Tobias GesellchenandClaude Sonnet 5 522c6b8cb6 fix(models): make group-equality order-insensitive everywhere
sameGroupClaim (device_projection.go, used to validate a member's
claim agrees with the master's) compared roles via a device-ID-keyed
map, making it order-insensitive. webtypes.replaceGroup's change
detection used reflect.DeepEqual on the whole *Group, which is
order-sensitive for Roles.Roles. Both the polled /getGroup response
and the pushed groupUpdated event populate Roles.Roles directly from
XML unmarshaling in wire order, so nothing guarantees a pair's roles
list in the same order across two reads -- DeepEqual could then report
a spurious "changed" for a pair that didn't actually change.

Extracted the order-insensitive comparison into models.SameGroup as
the single shared implementation (also handles the nil/nil case
correctly, unlike the old sameGroupClaim, which mattered for
replaceGroup's existing "no prior group" path). Both call sites now
use it; the duplicate sameGroupClaim is gone.

Added TestApplyGroupEventIgnoresRoleOrder, verified to fail against
the prior DeepEqual-based logic and pass with this fix.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-30 22:27:56 +02:00