From 5ea96d0cfa4d9666582b05ac7c5bf83e5a15605d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Luk=C3=A1=C5=A1=20Lipinsk=C3=BD?= <6032558+Mr-Tao@users.noreply.github.com> Date: Sun, 30 Aug 2026 23:53:41 +0200 Subject: [PATCH] Harden player source selection reconciliation Use a single POST command with bounded delayed readback, revision-order REST and WebSocket state, and explicit pending, confirmed, unverified, or failed outcomes. Mark stale source inventories unusable until a successful refresh and preserve legacy API error handling outside this command path. Rebased onto main. One conflict could not be resolved by picking a side: this change introduced its own per-field fencing (fieldRevision, deviceStatusFieldRevisions, MergeNowPlaying/MergeVolume/MergePresets/ MergeSources/MergeBass/MergeIsConnected) over the same six fields that main's StatusField mechanism (BeginFieldPoll/CompleteFieldPoll/ ApplyFieldEvent, added by the #654/#668 stack) already orders. Landing both would leave two independent generation counters guarding the same state, which is the divergent-fencing bug class that stack already had to fix three times. Resolved in favour of main's mechanism: - the Merge* methods and their counters are dropped; the WebSocket handlers and updateDeviceStatus use main's calls; - NowPlayingRevision is derived from the existing FieldNowPlaying generation via recordFieldRevision, not a second counter; - SetStatus derives Revision from the stored status and supersedes all field generations, so a replacement cannot reset the revision a browser is ordering on; - updateSourcesCache records a failed /sources read through CompleteFieldPoll, keeping the newer-failure-fences-older-success property the original had via MergeSourcesFailure; - the three tests that called the dropped API were ported to it. Everything else in this change is unmodified. Co-Authored-By: Claude Opus 5 (1M context) --- Makefile | 2 + .../browser_compatibility_test.go | 827 ++++++++++++++++++ pkg/service/soundtouchweb/handler.go | 46 +- pkg/service/soundtouchweb/handler_test.go | 73 +- pkg/service/soundtouchweb/mount.go | 4 +- .../soundtouchweb/source_cache_test.go | 145 +++ pkg/service/soundtouchweb/static/css/app.css | 14 +- pkg/service/soundtouchweb/static/js/api.js | 20 +- .../soundtouchweb/static/js/api.test.mjs | 51 ++ pkg/service/soundtouchweb/static/js/app.js | 102 ++- .../static/js/components/Sources.js | 217 ++++- pkg/service/soundtouchweb/websocket.go | 36 +- .../soundtouchweb/webtypes/revision_test.go | 120 +++ .../webtypes/source_cache_test.go | 113 +++ pkg/service/soundtouchweb/webtypes/types.go | 101 ++- 15 files changed, 1820 insertions(+), 51 deletions(-) create mode 100644 pkg/service/soundtouchweb/source_cache_test.go create mode 100644 pkg/service/soundtouchweb/static/js/api.test.mjs create mode 100644 pkg/service/soundtouchweb/webtypes/revision_test.go create mode 100644 pkg/service/soundtouchweb/webtypes/source_cache_test.go diff --git a/Makefile b/Makefile index 38814782..0b576639 100644 --- a/Makefile +++ b/Makefile @@ -11,6 +11,7 @@ GOTEST=$(GOCMD) test GOGET=$(GOCMD) get GOMOD=$(GOCMD) mod GOFMT=gofmt +NODE?=node # Build parameters BINARY_NAME=soundtouch-cli @@ -155,6 +156,7 @@ test-coverage: test-browser: @echo "Running browser-level compatibility tests..." $(GOTEST) -tags browsertest -v ./pkg/service/soundtouchweb/... + $(NODE) --test pkg/service/soundtouchweb/static/js/api.test.mjs # Unit tests for the embedded player's static JS modules (see # pkg/service/soundtouchweb/frontend_test/), run via Node's built-in test diff --git a/pkg/service/soundtouchweb/browser_compatibility_test.go b/pkg/service/soundtouchweb/browser_compatibility_test.go index 860b78fc..0d6e38dc 100644 --- a/pkg/service/soundtouchweb/browser_compatibility_test.go +++ b/pkg/service/soundtouchweb/browser_compatibility_test.go @@ -11,15 +11,69 @@ package soundtouchweb import ( "context" + "encoding/json" + "fmt" + "io/fs" "net/http" "net/http/httptest" + "strings" + "sync" "testing" "time" "github.com/chromedp/chromedp" + "github.com/gesellix/bose-soundtouch/pkg/service/soundtouchweb/webtypes" "github.com/go-chi/chi/v5" ) +func newPlayerFixtureServer(t *testing.T, moduleScript string, configure func(chi.Router)) *httptest.Server { + t.Helper() + + r := chi.NewRouter() + staticFS, err := fs.Sub(StaticFS, "static") + if err != nil { + t.Fatalf("open static fixture filesystem: %v", err) + } + r.Get("/app/static/*", http.StripPrefix("/app/static", http.FileServer(http.FS(staticFS))).ServeHTTP) + configure(r) + r.Get("/fixture", func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "text/html") + _, _ = fmt.Fprintf(w, ` + + + + + +
+`, moduleScript) + }) + + server := httptest.NewServer(r) + t.Cleanup(server.Close) + + return server +} + +const sourceFixtureScript = ` +import { h, render } from 'preact'; +import { Sources } from '/app/static/js/components/Sources.js'; +const sources = [ + { Source: 'AUX', SourceAccount: 'AUX1', DisplayName: 'Aux 1', Status: 'READY' }, + { Source: 'PRODUCT', SourceAccount: '', DisplayName: 'Product', Status: 'READY' }, + { Source: 'SPOTIFY', SourceAccount: 'spotify-user', DisplayName: 'Spotify', Status: 'READY' }, +]; +let revision = 0; +window.renderStatus = (source = 'STANDBY', account = '', sourcesStale = false, deviceId = 'speaker', revisionOverride = null, sourceItems = sources) => { + const nextRevision = revisionOverride ?? ++revision; + return render(h(Sources, { + deviceId, + status: { revision: nextRevision, nowPlayingRevision: nextRevision, sources: { SourceItem: sourceItems }, sourcesStale, nowPlaying: { Source: source, SourceAccount: account } }, + readbackDelays: [100, 250, 500], + }), document.getElementById('fixture')); +}; +window.renderStatus(); +` + // newHeadlessChromeContext returns a context bound to a fresh headless // Chrome instance, torn down automatically at the end of the test. func newHeadlessChromeContext(t *testing.T) context.Context { @@ -141,3 +195,776 @@ func TestPlayerRendersUnderForcedShimMode(t *testing.T) { t.Error("app did not render under forced es-module-shims shim mode") } } + +func TestFrontendDeviceStateRejectsNonNewerStatusRevisions(t *testing.T) { + const revisionFixture = ` +import { mergeDevicesSnapshot, mergeStatusUpdate } from '/app/static/js/app.js'; +const current = { + speaker: { info: { name: 'Current' }, status: { revision: 5, sourcesStale: false, nowPlaying: { Track: 'new' } } }, +}; +const snapshot = mergeDevicesSnapshot(current, { + speaker: { info: { name: 'Renamed' }, status: { revision: 4, nowPlaying: { Track: 'old' } } }, + added: { info: { name: 'Added' }, status: { revision: 1 } }, +}); +const equal = mergeStatusUpdate(snapshot, 'speaker', { revision: 5, nowPlaying: { Track: 'equal' } }); +const older = mergeStatusUpdate(equal, 'speaker', { revision: 3, nowPlaying: { Track: 'older' } }); +const newer = mergeStatusUpdate(older, 'speaker', { revision: 6, nowPlaying: { Track: 'newest' } }); +const stale = mergeStatusUpdate(newer, 'speaker', { + revision: 6, + sourcesStale: true, + nowPlaying: { Track: 'must not replace canonical state' }, +}); +const adversarial = Object.fromEntries([ + ['__proto__', { status: { revision: 1, nowPlaying: { Track: 'old proto' } } }], + ['constructor', { status: { revision: 1, nowPlaying: { Track: 'old constructor' } } }], +]); +const protoUpdated = mergeStatusUpdate(adversarial, '__proto__', { + revision: 2, + nowPlaying: { Track: 'new proto' }, +}); +const constructorUpdated = mergeStatusUpdate(protoUpdated, 'constructor', { + revision: 2, + nowPlaying: { Track: 'new constructor' }, +}); +window.revisionChecks = { + snapshotKeptStatus: snapshot.speaker.status.revision === 5 && snapshot.speaker.status.nowPlaying.Track === 'new', + snapshotUpdatedInfo: snapshot.speaker.info.name === 'Renamed' && snapshot.added.status.revision === 1, + equalRejected: equal === snapshot, + olderRejected: older === equal, + newerAccepted: newer !== older && newer.speaker.status.revision === 6 && newer.speaker.status.nowPlaying.Track === 'newest', + derivedStaleAccepted: stale !== newer && stale.speaker.status.sourcesStale === true && + stale.speaker.status.nowPlaying.Track === 'newest', + staleCannotClearAtEqualRevision: mergeStatusUpdate(stale, 'speaker', { + revision: 6, + sourcesStale: false, + }) === stale, + unknownRejected: mergeStatusUpdate(newer, 'unknown', { revision: 99 }) === newer, + adversarialIDsAreOwnDataProperties: + Object.prototype.hasOwnProperty.call(constructorUpdated, '__proto__') && + Object.prototype.hasOwnProperty.call(constructorUpdated, 'constructor'), + adversarialIDsKeepObjectPrototype: Object.getPrototypeOf(constructorUpdated) === Object.prototype, + adversarialIDsUpdateOnlyTheirRecords: + constructorUpdated.__proto__.status.nowPlaying.Track === 'new proto' && + constructorUpdated.constructor.status.nowPlaying.Track === 'new constructor' && + Object.prototype.polluted === undefined, +}; +` + server := newPlayerFixtureServer(t, revisionFixture, func(chi.Router) {}) + ctx := newHeadlessChromeContext(t) + + var checks map[string]bool + if err := chromedp.Run(ctx, + chromedp.Navigate(server.URL+"/fixture"), + chromedp.Poll(`window.revisionChecks !== undefined`, nil), + chromedp.Evaluate(`window.revisionChecks`, &checks), + ); err != nil { + t.Fatalf("exercise frontend revision state owner: %v", err) + } + for name, passed := range checks { + if !passed { + t.Errorf("revision check %s failed", name) + } + } +} + +func TestDerivedSourceExpiryDisablesCommandsAtEqualRevision(t *testing.T) { + const sourceExpiryFixture = ` +import { h, render } from 'preact'; +import { mergeStatusUpdate } from '/app/static/js/app.js'; +import { Sources } from '/app/static/js/components/Sources.js'; +const ready = [{ Source: 'AUX', SourceAccount: 'AUX1', DisplayName: 'Aux 1', Status: 'READY' }]; +let devices = { + speaker: { + status: { + revision: 5, + nowPlayingRevision: 5, + sourcesStale: false, + sources: { SourceItem: ready }, + nowPlaying: { Source: 'STANDBY', SourceAccount: '' }, + }, + }, +}; +function redraw() { + render(h(Sources, { + deviceId: 'speaker', + status: devices.speaker.status, + readbackDelays: [100], + }), document.getElementById('fixture')); +} +window.expireSources = () => { + devices = mergeStatusUpdate(devices, 'speaker', { + revision: 5, + nowPlayingRevision: 5, + sourcesStale: true, + sources: { SourceItem: ready }, + nowPlaying: { Source: 'AUX', SourceAccount: 'AUX1' }, + }); + redraw(); +}; +redraw(); +` + + var mu sync.Mutex + writes := 0 + server := newPlayerFixtureServer(t, sourceExpiryFixture, func(r chi.Router) { + r.Post("/api/control/devices/speaker/action/source", func(w http.ResponseWriter, _ *http.Request) { + mu.Lock() + writes++ + mu.Unlock() + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"success":true}`)) + }) + }) + + ctx := newHeadlessChromeContext(t) + var trackSource string + if err := chromedp.Run(ctx, + chromedp.Navigate(server.URL+"/fixture"), + chromedp.WaitVisible(`.source-btn`, chromedp.ByQuery), + chromedp.Evaluate(`window.expireSources()`, nil), + chromedp.Poll(`document.querySelector('.source-btn')?.disabled === true`, nil), + chromedp.Evaluate(`document.querySelector('.source-btn').click()`, nil), + chromedp.Sleep(50*time.Millisecond), + chromedp.Evaluate(`document.querySelector('.source-btn').classList.contains('active') ? 'AUX' : 'STANDBY'`, &trackSource), + ); err != nil { + t.Fatalf("apply derived source expiry: %v", err) + } + + mu.Lock() + defer mu.Unlock() + if writes != 0 || trackSource != "STANDBY" { + t.Fatalf("derived expiry writes=%d projected source=%q, want 0 and STANDBY", writes, trackSource) + } +} + +func TestSourceSelectionUsesOneWriteAndAbsoluteReadbacks(t *testing.T) { + type sourceRun struct { + body webtypes.SourceRequest + startedAt time.Time + readTimes []time.Duration + } + + var mu sync.Mutex + var runs []sourceRun + server := newPlayerFixtureServer(t, sourceFixtureScript, func(r chi.Router) { + r.Post("/api/control/devices/speaker/action/source", func(w http.ResponseWriter, req *http.Request) { + var body webtypes.SourceRequest + if err := json.NewDecoder(req.Body).Decode(&body); err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + + mu.Lock() + runs = append(runs, sourceRun{body: body, startedAt: time.Now()}) + mu.Unlock() + + w.Header().Set("Content-Type", "application/json") + if body.Source == "SPOTIFY" { + _ = json.NewEncoder(w).Encode(webtypes.APIResponse{Success: false, Error: "source rejected"}) + return + } + _ = json.NewEncoder(w).Encode(webtypes.APIResponse{Success: true}) + }) + r.Get("/api/control/devices/speaker", func(w http.ResponseWriter, _ *http.Request) { + mu.Lock() + run := &runs[len(runs)-1] + run.readTimes = append(run.readTimes, time.Since(run.startedAt)) + readCount := len(run.readTimes) + body := run.body + mu.Unlock() + + nowPlaying := map[string]string{"Source": "STANDBY", "SourceAccount": ""} + if body.Source == "AUX" && readCount >= 2 { + nowPlaying = map[string]string{"Source": body.Source, "SourceAccount": body.Account} + } + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(webtypes.APIResponse{Success: true, Data: map[string]any{ + "status": map[string]any{"revision": readCount + 1, "nowPlayingRevision": readCount + 1, "nowPlaying": nowPlaying}, + }}) + }) + }) + + ctx := newHeadlessChromeContext(t) + var immediatelyPending bool + if err := chromedp.Run(ctx, + chromedp.Navigate(server.URL+"/fixture"), + chromedp.WaitVisible(`.source-btn`, chromedp.ByQuery), + chromedp.Click(`.source-btn:nth-child(1)`, chromedp.ByQuery), + chromedp.Evaluate(`document.querySelector('.source-btn:nth-child(1)').getAttribute('aria-busy') === 'true'`, &immediatelyPending), + chromedp.Poll(`document.querySelector('.source-command-status').textContent === 'Source selected'`, nil), + chromedp.Click(`.source-btn:nth-child(2)`, chromedp.ByQuery), + chromedp.Poll(`document.querySelector('.source-command-status').textContent === 'Source selection unverified'`, nil), + chromedp.Click(`.source-btn:nth-child(3)`, chromedp.ByQuery), + chromedp.Poll(`document.querySelector('.source-btn:nth-child(3)').classList.contains('unverified')`, nil), + ); err != nil { + t.Fatalf("exercise source commands: %v", err) + } + if !immediatelyPending { + t.Error("source command did not expose pending state immediately") + } + + mu.Lock() + defer mu.Unlock() + if len(runs) != 3 { + t.Fatalf("source writes = %d, want exactly one for each of 3 commands", len(runs)) + } + wantBodies := []webtypes.SourceRequest{ + {Source: "AUX", Account: "AUX1"}, + {Source: "PRODUCT", Account: ""}, + {Source: "SPOTIFY", Account: "spotify-user"}, + } + for i, want := range wantBodies { + if runs[i].body != want { + t.Errorf("write %d body = %+v, want %+v", i, runs[i].body, want) + } + } + if got := len(runs[0].readTimes); got != 3 { + t.Errorf("confirmed command readbacks = %d, want 3", got) + } + if got := len(runs[1].readTimes); got != 3 { + t.Errorf("unverified command readbacks = %d, want 3", got) + } + if got := len(runs[2].readTimes); got != 3 { + t.Errorf("transport-uncertain write readbacks = %d, want 3", got) + } + for i, want := range []time.Duration{100 * time.Millisecond, 250 * time.Millisecond, 500 * time.Millisecond} { + got := runs[0].readTimes[i] + if got < want-40*time.Millisecond || got > want+150*time.Millisecond { + t.Errorf("confirmed readback %d at %s, want absolute deadline near %s", i, got, want) + } + } + for i, want := range []time.Duration{100 * time.Millisecond, 250 * time.Millisecond, 500 * time.Millisecond} { + got := runs[1].readTimes[i] + if got < want-40*time.Millisecond || got > want+150*time.Millisecond { + t.Errorf("unverified readback %d at %s, want absolute deadline near %s", i, got, want) + } + } +} + +func TestSourceSelectionTreatsSelfAccountAsOmitted(t *testing.T) { + const fixture = ` +import { h, render } from 'preact'; +import { Sources } from '/app/static/js/components/Sources.js'; +const sourceItems = [{ Source: 'AUX', SourceAccount: 'AUX', DisplayName: 'AUX IN', Status: 'READY' }]; +render(h(Sources, { + deviceId: 'speaker', + status: { + revision: 1, + nowPlayingRevision: 1, + nowPlaying: { Source: 'STANDBY', SourceAccount: '' }, + sources: { SourceItem: sourceItems }, + }, + readbackDelays: [100, 250, 500], +}), document.getElementById('fixture')); +` + + var mu sync.Mutex + writes := 0 + var request webtypes.SourceRequest + server := newPlayerFixtureServer(t, fixture, func(r chi.Router) { + r.Post("/api/control/devices/speaker/action/source", func(w http.ResponseWriter, req *http.Request) { + mu.Lock() + writes++ + _ = json.NewDecoder(req.Body).Decode(&request) + mu.Unlock() + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"success":true}`)) + }) + r.Get("/api/control/devices/speaker", func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"success":true,"data":{"status":{"revision":2,"nowPlayingRevision":2,"nowPlaying":{"Source":"AUX","SourceAccount":""}}}}`)) + }) + }) + + ctx := newHeadlessChromeContext(t) + var active bool + if err := chromedp.Run(ctx, + chromedp.Navigate(server.URL+"/fixture"), + chromedp.WaitVisible(`.source-btn`, chromedp.ByQuery), + chromedp.Click(`.source-btn`, chromedp.ByQuery), + chromedp.Poll(`document.querySelector('.source-command-status').textContent === 'Source selected'`, nil), + chromedp.Evaluate(`document.querySelector('.source-btn').classList.contains('active')`, &active), + ); err != nil { + t.Fatalf("select source whose self-account is omitted by now_playing: %v", err) + } + + mu.Lock() + defer mu.Unlock() + if writes != 1 || request != (webtypes.SourceRequest{Source: "AUX", Account: "AUX"}) { + t.Fatalf("writes=%d request=%+v, want one AUX/AUX write", writes, request) + } + if !active { + t.Fatal("source with omitted self-account was not projected active") + } +} + +func TestSourceSelectionReadbacksDoNotWaitForSlowWriteResponse(t *testing.T) { + var mu sync.Mutex + startedAt := time.Time{} + var readTimes []time.Duration + server := newPlayerFixtureServer(t, sourceFixtureScript, func(r chi.Router) { + r.Post("/api/control/devices/speaker/action/source", func(w http.ResponseWriter, _ *http.Request) { + mu.Lock() + startedAt = time.Now() + mu.Unlock() + time.Sleep(700 * time.Millisecond) + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"success":true}`)) + }) + r.Get("/api/control/devices/speaker", func(w http.ResponseWriter, _ *http.Request) { + mu.Lock() + readTimes = append(readTimes, time.Since(startedAt)) + readCount := len(readTimes) + mu.Unlock() + w.Header().Set("Content-Type", "application/json") + _, _ = fmt.Fprintf(w, `{"success":true,"data":{"status":{"revision":%d,"nowPlayingRevision":%d,"nowPlaying":{"Source":"STANDBY","SourceAccount":""}}}}`, readCount+1, readCount+1) + }) + }) + + ctx := newHeadlessChromeContext(t) + if err := chromedp.Run(ctx, + chromedp.Navigate(server.URL+"/fixture"), + chromedp.WaitVisible(`.source-btn`, chromedp.ByQuery), + chromedp.Click(`.source-btn:nth-child(1)`, chromedp.ByQuery), + chromedp.Poll(`document.querySelector('.source-command-status').textContent === 'Source selection unverified'`, nil), + ); err != nil { + t.Fatalf("exercise slow source response: %v", err) + } + + mu.Lock() + defer mu.Unlock() + if len(readTimes) != 3 { + t.Fatalf("readbacks = %d, want 3", len(readTimes)) + } + for i, want := range []time.Duration{100 * time.Millisecond, 250 * time.Millisecond, 500 * time.Millisecond} { + if got := readTimes[i]; got < want-40*time.Millisecond || got > want+150*time.Millisecond { + t.Errorf("readback %d at %s, want absolute deadline near %s", i, got, want) + } + } +} + +func TestSourceSelectionLaterFirmwareErrorOverridesProvisionalConfirmation(t *testing.T) { + var mu sync.Mutex + reads := 0 + server := newPlayerFixtureServer(t, sourceFixtureScript, func(r chi.Router) { + r.Post("/api/control/devices/speaker/action/source", func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"success":true}`)) + }) + r.Get("/api/control/devices/speaker", func(w http.ResponseWriter, _ *http.Request) { + mu.Lock() + reads++ + read := reads + mu.Unlock() + + source := "AUX" + account := "AUX1" + if read == 2 { + source = "INVALID_SOURCE" + account = "" + } + w.Header().Set("Content-Type", "application/json") + _, _ = fmt.Fprintf(w, `{"success":true,"data":{"status":{"revision":%d,"nowPlayingRevision":%d,"nowPlaying":{"Source":%q,"SourceAccount":%q}}}}`, read+1, read+1, source, account) + }) + }) + + ctx := newHeadlessChromeContext(t) + var provisional bool + if err := chromedp.Run(ctx, + chromedp.Navigate(server.URL+"/fixture"), + chromedp.WaitVisible(`.source-btn`, chromedp.ByQuery), + chromedp.Click(`.source-btn:nth-child(1)`, chromedp.ByQuery), + chromedp.Poll(`document.querySelector('.source-command-status').textContent === 'Source selected, confirming'`, nil), + chromedp.Evaluate(`document.querySelector('.source-btn:nth-child(1)').classList.contains('provisional-confirmed')`, &provisional), + chromedp.Poll(`document.querySelector('.source-command-status').textContent === 'Source selection failed'`, nil), + ); err != nil { + t.Fatalf("exercise provisional source rejection: %v", err) + } + if !provisional { + t.Error("early matching readback did not expose provisional confirmation") + } + + mu.Lock() + defer mu.Unlock() + if reads != 2 { + t.Errorf("readbacks = %d, want provisional match followed by authoritative rejection", reads) + } +} + +func TestSourceSelectionRejectsReadbackWithOnlyNewerAggregateRevision(t *testing.T) { + var mu sync.Mutex + reads := 0 + server := newPlayerFixtureServer(t, sourceFixtureScript, func(r chi.Router) { + r.Post("/api/control/devices/speaker/action/source", func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"success":true}`)) + }) + r.Get("/api/control/devices/speaker", func(w http.ResponseWriter, _ *http.Request) { + mu.Lock() + reads++ + mu.Unlock() + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"success":true,"data":{"status":{"revision":99,"nowPlayingRevision":1,"nowPlaying":{"Source":"AUX","SourceAccount":"AUX1"}}}}`)) + }) + }) + + ctx := newHeadlessChromeContext(t) + if err := chromedp.Run(ctx, + chromedp.Navigate(server.URL+"/fixture"), + chromedp.WaitVisible(`.source-btn`, chromedp.ByQuery), + chromedp.Click(`.source-btn:nth-child(1)`, chromedp.ByQuery), + chromedp.Poll(`document.querySelector('.source-command-status').textContent === 'Source selection unverified'`, nil), + ); err != nil { + t.Fatalf("exercise stale source readback: %v", err) + } + + mu.Lock() + defer mu.Unlock() + if reads != 3 { + t.Errorf("readbacks = %d, want all 3 after stale matching responses", reads) + } +} + +func TestSourceSelectionTreatsFirmwareErrorSourceAsFailed(t *testing.T) { + var mu sync.Mutex + reads := 0 + server := newPlayerFixtureServer(t, sourceFixtureScript, func(r chi.Router) { + r.Post("/api/control/devices/speaker/action/source", func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"success":true}`)) + }) + r.Get("/api/control/devices/speaker", func(w http.ResponseWriter, _ *http.Request) { + mu.Lock() + reads++ + read := reads + mu.Unlock() + w.Header().Set("Content-Type", "application/json") + _, _ = fmt.Fprintf(w, `{"success":true,"data":{"status":{"revision":%d,"nowPlayingRevision":%d,"nowPlaying":{"Source":"INVALID_SOURCE","SourceAccount":""}}}}`, read+1, read+1) + }) + }) + + ctx := newHeadlessChromeContext(t) + if err := chromedp.Run(ctx, + chromedp.Navigate(server.URL+"/fixture"), + chromedp.WaitVisible(`.source-btn`, chromedp.ByQuery), + chromedp.Click(`.source-btn:nth-child(1)`, chromedp.ByQuery), + chromedp.Poll(`document.querySelector('.source-command-status').textContent === 'Source selection failed'`, nil), + ); err != nil { + t.Fatalf("exercise firmware source rejection: %v", err) + } + + mu.Lock() + defer mu.Unlock() + if reads != 1 { + t.Errorf("readbacks = %d, want 1 after authoritative firmware rejection", reads) + } +} + +func TestSourceSelectionLaterAuthoritativeSourceClearsFinalProjection(t *testing.T) { + transitions := []struct { + name, source, account string + activeButtons int + }{ + {name: "airplay", source: "AIRPLAY"}, + {name: "spotify", source: "SPOTIFY", account: "spotify-user", activeButtons: 1}, + {name: "standby", source: "STANDBY"}, + {name: "invalid source", source: "INVALID_SOURCE"}, + } + + for _, transition := range transitions { + t.Run(transition.name, func(t *testing.T) { + var mu sync.Mutex + reads := 0 + server := newPlayerFixtureServer(t, sourceFixtureScript, func(r chi.Router) { + r.Post("/api/control/devices/speaker/action/source", func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"success":true}`)) + }) + r.Get("/api/control/devices/speaker", func(w http.ResponseWriter, _ *http.Request) { + mu.Lock() + reads++ + revision := reads + 1 + mu.Unlock() + w.Header().Set("Content-Type", "application/json") + _, _ = fmt.Fprintf(w, `{"success":true,"data":{"status":{"revision":%d,"nowPlayingRevision":%d,"nowPlaying":{"Source":"AUX","SourceAccount":"AUX1"}}}}`, revision, revision) + }) + }) + + ctx := newHeadlessChromeContext(t) + var commandCleared, auxInactive bool + var activeButtons int + if err := chromedp.Run(ctx, + chromedp.Navigate(server.URL+"/fixture"), + chromedp.WaitVisible(`.source-btn`, chromedp.ByQuery), + chromedp.Click(`.source-btn:nth-child(1)`, chromedp.ByQuery), + chromedp.Poll(`document.querySelector('.source-command-status').textContent === 'Source selected'`, nil), + chromedp.Evaluate(fmt.Sprintf(`window.renderStatus(%q, %q, false, 'speaker', 100)`, transition.source, transition.account), nil), + chromedp.Poll(`document.querySelector('.source-command-status').textContent === ''`, nil), + chromedp.Evaluate(`document.querySelector('.source-command-status').textContent === ''`, &commandCleared), + chromedp.Evaluate(`!document.querySelector('.source-btn:nth-child(1)').classList.contains('active')`, &auxInactive), + chromedp.Evaluate(`document.querySelectorAll('.source-btn.active').length`, &activeButtons), + ); err != nil { + t.Fatalf("apply later authoritative source: %v", err) + } + if !commandCleared || !auxInactive || activeButtons != transition.activeButtons { + t.Errorf("projection after %s: cleared=%v auxInactive=%v activeButtons=%d want=%d", + transition.source, commandCleared, auxInactive, activeButtons, transition.activeButtons) + } + }) + } +} + +func TestSourceReadbackPublishesThroughAppDeviceState(t *testing.T) { + const fixture = ` +import { h, render } from 'preact'; +import { useState } from 'preact/hooks'; +import { mergeStatusUpdate } from '/app/static/js/app.js'; +import { NowPlaying } from '/app/static/js/components/NowPlaying.js'; +import { Sources } from '/app/static/js/components/Sources.js'; +const sourceItems = [{ Source: 'AUX', SourceAccount: 'AUX1', DisplayName: 'Aux 1', Status: 'READY' }]; +function Fixture() { + const [devices, setDevices] = useState({ speaker: { status: { + revision: 1, + nowPlayingRevision: 1, + nowPlaying: { Source: 'STANDBY' }, + sources: { SourceItem: sourceItems }, + } } }); + const status = devices.speaker.status; + return h('div', {}, + h(NowPlaying, { nowPlaying: status.nowPlaying }), + h(Sources, { + deviceId: 'speaker', + status, + readbackDelays: [100, 250, 500], + onStatusReadback: next => setDevices(previous => mergeStatusUpdate(previous, 'speaker', next)), + }), + ); +} +render(h(Fixture), document.getElementById('fixture')); +` + + var mu sync.Mutex + reads := 0 + server := newPlayerFixtureServer(t, fixture, func(r chi.Router) { + r.Post("/api/control/devices/speaker/action/source", func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"success":true}`)) + }) + r.Get("/api/control/devices/speaker", func(w http.ResponseWriter, _ *http.Request) { + mu.Lock() + reads++ + revision := reads + 1 + mu.Unlock() + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(webtypes.APIResponse{Success: true, Data: map[string]any{ + "status": map[string]any{ + "revision": revision, + "nowPlayingRevision": revision, + "nowPlaying": map[string]string{ + "Source": "AUX", "SourceAccount": "AUX1", "Track": "Confirmed track", + }, + "sources": map[string]any{"SourceItem": []map[string]string{ + {"Source": "AUX", "SourceAccount": "AUX1", "DisplayName": "Aux 1", "Status": "READY"}, + }}, + }, + }}) + }) + }) + + ctx := newHeadlessChromeContext(t) + var title string + if err := chromedp.Run(ctx, + chromedp.Navigate(server.URL+"/fixture"), + chromedp.WaitVisible(`.source-btn`, chromedp.ByQuery), + chromedp.Click(`.source-btn`, chromedp.ByQuery), + chromedp.Poll(`document.querySelector('.track-title')?.textContent === 'Confirmed track'`, nil), + chromedp.Text(`.track-title`, &title, chromedp.ByQuery), + chromedp.Poll(`document.querySelector('.source-command-status').textContent === 'Source selected'`, nil), + ); err != nil { + t.Fatalf("publish source readback through app state: %v", err) + } + if title != "Confirmed track" { + t.Fatalf("NowPlaying title = %q, want confirmed readback", title) + } +} + +func TestSourceSelectionResetsWhenDeviceChanges(t *testing.T) { + var mu sync.Mutex + writes := 0 + server := newPlayerFixtureServer(t, sourceFixtureScript, func(r chi.Router) { + r.Post("/api/control/devices/speaker/action/source", func(w http.ResponseWriter, _ *http.Request) { + mu.Lock() + writes++ + mu.Unlock() + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"success":true}`)) + }) + r.Get("/api/control/devices/speaker", func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"success":true}`)) + }) + }) + + ctx := newHeadlessChromeContext(t) + var statusText string + if err := chromedp.Run(ctx, + chromedp.Navigate(server.URL+"/fixture"), + chromedp.WaitVisible(`.source-btn`, chromedp.ByQuery), + chromedp.Click(`.source-btn:nth-child(1)`, chromedp.ByQuery), + chromedp.Evaluate(`window.renderStatus('STANDBY', '', false, 'other-speaker')`, nil), + chromedp.Sleep(600*time.Millisecond), + chromedp.Text(`.source-command-status`, &statusText, chromedp.ByQuery), + ); err != nil { + t.Fatalf("switch source component device: %v", err) + } + + mu.Lock() + defer mu.Unlock() + if statusText != "" || writes != 1 { + t.Errorf("device switch left command state=%q or writes=%d, want cleared state and one original write", statusText, writes) + } +} + +func TestSourceSelectionFencesOlderReadbackAndStatus(t *testing.T) { + var mu sync.Mutex + writes := map[string]int{} + currentSource := "" + readRevision := 10 + server := newPlayerFixtureServer(t, sourceFixtureScript, func(r chi.Router) { + r.Post("/api/control/devices/speaker/action/source", func(w http.ResponseWriter, req *http.Request) { + var body webtypes.SourceRequest + _ = json.NewDecoder(req.Body).Decode(&body) + mu.Lock() + writes[body.Source]++ + currentSource = body.Source + mu.Unlock() + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(webtypes.APIResponse{Success: true}) + }) + r.Get("/api/control/devices/speaker", func(w http.ResponseWriter, _ *http.Request) { + mu.Lock() + source := currentSource + readRevision++ + revision := readRevision + mu.Unlock() + if source == "AUX" { + time.Sleep(400 * time.Millisecond) + } + w.Header().Set("Content-Type", "application/json") + responseSource := "STANDBY" + responseAccount := "" + if source == "AUX" { + responseSource = source + responseAccount = "AUX1" + } + _ = json.NewEncoder(w).Encode(webtypes.APIResponse{Success: true, Data: map[string]any{ + "status": map[string]any{ + "revision": revision, + "nowPlayingRevision": revision, + "nowPlaying": map[string]string{ + "Source": responseSource, "SourceAccount": responseAccount, + }, + }, + }}) + }) + }) + + ctx := newHeadlessChromeContext(t) + var outcome, productClass string + if err := chromedp.Run(ctx, + chromedp.Navigate(server.URL+"/fixture"), + chromedp.WaitVisible(`.source-btn`, chromedp.ByQuery), + chromedp.Click(`.source-btn:nth-child(1)`, chromedp.ByQuery), + chromedp.Sleep(140*time.Millisecond), + chromedp.Click(`.source-btn:nth-child(2)`, chromedp.ByQuery), + chromedp.Evaluate(`window.renderStatus('AUX', 'AUX1')`, nil), + chromedp.Poll(`document.querySelector('.source-command-status').textContent === 'Source selection unverified'`, nil), + chromedp.Text(`.source-command-status`, &outcome, chromedp.ByQuery), + chromedp.AttributeValue(`.source-btn:nth-child(2)`, "class", &productClass, nil, chromedp.ByQuery), + ); err != nil { + t.Fatalf("exercise source generation fence: %v", err) + } + + if outcome != "Source selection unverified" || !strings.Contains(productClass, "unverified") { + t.Errorf("newer outcome overwritten: status=%q class=%q", outcome, productClass) + } + mu.Lock() + defer mu.Unlock() + if writes["AUX"] != 1 || writes["PRODUCT"] != 1 { + t.Errorf("writes = %#v, want one AUX and one PRODUCT write", writes) + } +} + +func TestStaleSourcesRemainVisibleButCannotBeSelected(t *testing.T) { + var mu sync.Mutex + writes := 0 + server := newPlayerFixtureServer(t, sourceFixtureScript, func(r chi.Router) { + r.Post("/api/control/devices/speaker/action/source", func(w http.ResponseWriter, _ *http.Request) { + mu.Lock() + writes++ + mu.Unlock() + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"success":true}`)) + }) + }) + + ctx := newHeadlessChromeContext(t) + var sourceCount, disabledCount int + var staleText, staleRole string + var described bool + if err := chromedp.Run(ctx, + chromedp.Navigate(server.URL+"/fixture"), + chromedp.WaitVisible(`.source-btn`, chromedp.ByQuery), + chromedp.Evaluate(`window.renderStatus('STANDBY', '', true)`, nil), + chromedp.WaitVisible(`#source-stale-status`, chromedp.ByQuery), + chromedp.Evaluate(`document.querySelectorAll('.source-btn').length`, &sourceCount), + chromedp.Evaluate(`document.querySelectorAll('.source-btn:disabled').length`, &disabledCount), + chromedp.Text(`#source-stale-status`, &staleText, chromedp.ByQuery), + chromedp.AttributeValue(`#source-stale-status`, "role", &staleRole, nil, chromedp.ByQuery), + chromedp.Evaluate(`[...document.querySelectorAll('.source-btn')].every(button => button.getAttribute('aria-describedby') === 'source-stale-status')`, &described), + chromedp.Evaluate(`document.querySelector('.source-btn').click()`, nil), + chromedp.Sleep(50*time.Millisecond), + ); err != nil { + t.Fatalf("render stale source cache: %v", err) + } + + mu.Lock() + staleWrites := writes + mu.Unlock() + if sourceCount != 3 || disabledCount != sourceCount { + t.Errorf("stale sources: rendered=%d disabled=%d, want all 3 retained and disabled", sourceCount, disabledCount) + } + if staleText != "Source list out of date" || staleRole != "status" || !described { + t.Errorf("stale indication: text=%q role=%q described=%v", staleText, staleRole, described) + } + if staleWrites != 0 { + t.Errorf("stale source selection issued %d writes, want 0", staleWrites) + } + + var enabledCount int + var staleIndicatorMissing bool + if err := chromedp.Run(ctx, + chromedp.Evaluate(`window.renderStatus('STANDBY', '', false)`, nil), + chromedp.Poll(`document.querySelectorAll('.source-btn:disabled').length === 0`, nil), + chromedp.Evaluate(`document.querySelectorAll('.source-btn:not(:disabled)').length`, &enabledCount), + chromedp.Evaluate(`document.querySelector('#source-stale-status') === null`, &staleIndicatorMissing), + ); err != nil { + t.Fatalf("render refreshed source cache: %v", err) + } + if enabledCount != sourceCount || !staleIndicatorMissing { + t.Errorf("fresh sources: enabled=%d want=%d staleIndicatorMissing=%v", enabledCount, sourceCount, staleIndicatorMissing) + } + + var noInventoryText, noInventoryRole string + if err := chromedp.Run(ctx, + chromedp.Evaluate(`window.renderStatus('STANDBY', '', false, 'speaker', null, [])`, nil), + chromedp.WaitVisible(`#source-inventory-status`, chromedp.ByQuery), + chromedp.Text(`#source-inventory-status`, &noInventoryText, chromedp.ByQuery), + chromedp.AttributeValue(`#source-inventory-status`, "role", &noInventoryRole, nil, chromedp.ByQuery), + ); err != nil { + t.Fatalf("render missing source inventory: %v", err) + } + if noInventoryText != "Source list unavailable" || noInventoryRole != "status" { + t.Errorf("missing source inventory: text=%q role=%q", noInventoryText, noInventoryRole) + } +} diff --git a/pkg/service/soundtouchweb/handler.go b/pkg/service/soundtouchweb/handler.go index b60d914b..53ceae77 100644 --- a/pkg/service/soundtouchweb/handler.go +++ b/pkg/service/soundtouchweb/handler.go @@ -6,6 +6,7 @@ import ( "encoding/json" "errors" "fmt" + "io" "log" "net/http" "strconv" @@ -697,19 +698,50 @@ func (app *WebApp) handleBassControl(w http.ResponseWriter, r *http.Request, dev app.sendControlResponse(w, err, fmt.Sprintf("Bass set to %d", bassReq.Level)) } -// handleSourceControl processes source control requests +// handleSourceControl processes source control requests. POST with an exact +// {source, account} JSON body is canonical. GET query parameters remain as a +// temporary compatibility surface and are explicitly marked deprecated. func (app *WebApp) handleSourceControl(w http.ResponseWriter, r *http.Request, device *webtypes.DeviceConnection) { - sourceParam := r.URL.Query().Get("name") + var sourceParam, accountParam string + + switch r.Method { + case http.MethodPost: + r.Body = http.MaxBytesReader(w, r.Body, 8<<10) + decoder := json.NewDecoder(r.Body) + decoder.DisallowUnknownFields() + + var sourceReq webtypes.SourceRequest + if err := decoder.Decode(&sourceReq); err != nil { + app.sendError(w, "Invalid source data", http.StatusBadRequest) + return + } + + if err := decoder.Decode(&struct{}{}); err != io.EOF { + app.sendError(w, "Invalid source data", http.StatusBadRequest) + return + } + + sourceParam = sourceReq.Source + accountParam = sourceReq.Account + case http.MethodGet: + w.Header().Set("Deprecation", "true") + w.Header().Set("Warning", `299 - "GET source control is deprecated; use POST with a JSON body"`) + + sourceParam = r.URL.Query().Get("name") + accountParam = r.URL.Query().Get("account") + default: + app.sendError(w, "POST required for source control", http.StatusMethodNotAllowed) + return + } + if sourceParam == "" { app.sendError(w, "Source name required", http.StatusBadRequest) return } - // Forward the optional account parameter as sourceAccount. Devices with - // multiple jacks that share source="AUX" (e.g. ST-5 CD/Aux inputs) - // disambiguate them via distinct sourceAccount values (AUX, AUX1, …). - accountParam := r.URL.Query().Get("account") - + // Forward account verbatim as sourceAccount. Devices with multiple jacks + // that share source="AUX" (e.g. ST-5 CD/Aux inputs) disambiguate them via + // distinct sourceAccount values (AUX, AUX1, …). if device.Client == nil { app.sendError(w, "Device client not available", http.StatusInternalServerError) return diff --git a/pkg/service/soundtouchweb/handler_test.go b/pkg/service/soundtouchweb/handler_test.go index e961e90c..d6ea30c5 100644 --- a/pkg/service/soundtouchweb/handler_test.go +++ b/pkg/service/soundtouchweb/handler_test.go @@ -850,12 +850,10 @@ func TestHandleDevicePlay_SourceAccountFiltering(t *testing.T) { } } -// TestHandleSourceControl_ForwardsAccount verifies that the account query -// parameter is forwarded as sourceAccount in the /select XML. Devices like -// the ST-5 expose multiple AUX jacks that share source="AUX" and are only -// disambiguated by distinct sourceAccount values (AUX, AUX1, …). Regression -// test for issue #444, where the handler dropped the account parameter. -func TestHandleSourceControl_ForwardsAccount(t *testing.T) { +// TestHandleSourceControl_LegacyGETForwardsAccount verifies that the temporary +// GET compatibility route still forwards sourceAccount while clearly marking +// the response deprecated. +func TestHandleSourceControl_LegacyGETForwardsAccount(t *testing.T) { tests := []struct { name string query string @@ -904,6 +902,12 @@ func TestHandleSourceControl_ForwardsAccount(t *testing.T) { if w.Code != http.StatusOK { t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String()) } + if got := w.Header().Get("Deprecation"); got != "true" { + t.Errorf("Deprecation header = %q, want true", got) + } + if got := w.Header().Get("Warning"); !strings.Contains(got, "use POST") { + t.Errorf("Warning header = %q, want POST migration guidance", got) + } if want := `source="AUX"`; !strings.Contains(capturedBody, want) { t.Errorf("XML should contain %q, got: %s", want, capturedBody) @@ -1060,6 +1064,63 @@ func TestHandleZoneAddRejectsStandbyMaster(t *testing.T) { } } +func TestHandleSourceControl_CanonicalPOSTForwardsExactBody(t *testing.T) { + var capturedBody string + speaker := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path == "/select" { + b, _ := io.ReadAll(r.Body) + capturedBody = string(b) + } + w.WriteHeader(http.StatusOK) + })) + defer speaker.Close() + + app := NewWebApp() + conn := webtypes.NewDeviceConnection( + client.NewClient(&client.Config{Host: speaker.URL}), + &models.DeviceInfo{Name: "Test Speaker"}, + ) + conn.SetStatus(&webtypes.DeviceStatus{IsConnected: true, LastActivity: time.Now()}) + app.AddDevice("source-device", conn) + + body := strings.NewReader(`{"source":"AUX","account":"AUX1"}`) + req := httptest.NewRequest(http.MethodPost, "/api/control/devices/source-device/action/source", body) + req.Header.Set("Content-Type", "application/json") + req = withChiParams(req, map[string]string{"id": "source-device", "action": "source"}) + w := httptest.NewRecorder() + + app.HandleAPIControl(w, req) + + if w.Code != http.StatusOK { + t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String()) + } + if got := w.Header().Get("Deprecation"); got != "" { + t.Errorf("canonical POST unexpectedly marked deprecated: %q", got) + } + for _, want := range []string{`source="AUX"`, `sourceAccount="AUX1"`} { + if !strings.Contains(capturedBody, want) { + t.Errorf("XML should contain %q, got: %s", want, capturedBody) + } + } +} + +func TestHandleSourceControl_CanonicalPOSTRejectsUnknownFields(t *testing.T) { + app := NewWebApp() + conn := webtypes.NewDeviceConnection(nil, &models.DeviceInfo{Name: "Test Speaker"}) + app.AddDevice("source-device", conn) + + body := strings.NewReader(`{"source":"AUX","account":"AUX1","name":"legacy"}`) + req := httptest.NewRequest(http.MethodPost, "/api/control/devices/source-device/action/source", body) + req = withChiParams(req, map[string]string{"id": "source-device", "action": "source"}) + w := httptest.NewRecorder() + + app.HandleAPIControl(w, req) + + if w.Code != http.StatusBadRequest { + t.Fatalf("expected 400, got %d: %s", w.Code, w.Body.String()) + } +} + // TestHandleZoneRemove_UsesRemoveZoneSlave is the #511 regression: removing one // member from a multi-member zone must target that member via /removeZoneSlave. // The previous implementation rebuilt the zone with /setZone and the remaining diff --git a/pkg/service/soundtouchweb/mount.go b/pkg/service/soundtouchweb/mount.go index 9508d6f4..7cc185f2 100644 --- a/pkg/service/soundtouchweb/mount.go +++ b/pkg/service/soundtouchweb/mount.go @@ -71,7 +71,9 @@ func (app *WebApp) MountWeb(r chi.Router, discoveryService *discovery.UnifiedDis r.Get("/recents", app.HandleDeviceRecents) // Low-level "play this ContentItem" primitive (not a provider). r.Post("/play", app.HandleDevicePlay) - // Generic key / preset / source / bass actions. + // Generic key / preset / source / bass actions. Source selection is + // canonically POSTed as JSON; its GET form remains temporarily for + // compatibility and marks every response as deprecated. r.Get("/action/{action}", app.HandleAPIControl) r.Post("/action/{action}", app.HandleAPIControl) r.Get("/ws", app.HandleDeviceWebSocket) diff --git a/pkg/service/soundtouchweb/source_cache_test.go b/pkg/service/soundtouchweb/source_cache_test.go new file mode 100644 index 00000000..a2ab86aa --- /dev/null +++ b/pkg/service/soundtouchweb/source_cache_test.go @@ -0,0 +1,145 @@ +package soundtouchweb + +import ( + "encoding/json" + "errors" + "net/http" + "net/http/httptest" + "testing" + "time" + + "github.com/gesellix/bose-soundtouch/pkg/client" + "github.com/gesellix/bose-soundtouch/pkg/models" + "github.com/gesellix/bose-soundtouch/pkg/service/soundtouchweb/webtypes" +) + +// TestUpdateDeviceStatusDoesNotRefreshNowPlayingRevisionOnFailure: a poll round +// where /now_playing failed but /volume succeeded must merge the volume and +// leave now-playing authority untouched. A source selection waiting for +// confirmation reads NowPlayingRevision, so advancing it on a failed read +// would confirm a selection nothing actually verified. +func TestUpdateDeviceStatusDoesNotRefreshNowPlayingRevisionOnFailure(t *testing.T) { + speaker := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path == "/volume" { + _, _ = w.Write([]byte(`3535false`)) + + return + } + http.Error(w, "unavailable", http.StatusServiceUnavailable) + })) + defer speaker.Close() + + conn := webtypes.NewDeviceConnection(client.NewClient(&client.Config{Host: speaker.URL}), nil) + conn.SetStatus(&webtypes.DeviceStatus{NowPlaying: &models.NowPlaying{Source: "SPOTIFY"}}) + baseline := conn.Status() + + NewWebApp().UpdateDeviceStatus("speaker", conn) + updated := conn.Status() + + if updated.Revision <= baseline.Revision || updated.Volume == nil || updated.Volume.ActualVolume != 35 { + t.Fatalf("unrelated successful field was not merged: %+v", updated) + } + if updated.NowPlaying.Source != "SPOTIFY" || updated.NowPlayingRevision != baseline.NowPlayingRevision { + t.Fatalf("failed now-playing read advanced source authority: %+v", updated) + } +} + +// TestUpdateSourcesCacheRetainsFailureAndRefreshesSuccess: a failed refresh +// keeps the previous inventory and its read time but marks it stale; the next +// success replaces both and restores actionability. +func TestUpdateSourcesCacheRetainsFailureAndRefreshesSuccess(t *testing.T) { + conn := webtypes.NewDeviceConnection(nil, nil) + // Read times must sit inside sourceCacheTTL of now: Status() derives + // staleness against the wall clock, so fixed calendar dates would read + // back as expired no matter what this test does. + firstRead := time.Now() + oldSources := &models.Sources{SourceItem: []models.SourceItem{{Source: "AUX"}}} + + updateSourcesCache(conn, conn.BeginFieldPoll(webtypes.FieldSources), oldSources, nil, firstRead) + + if !updateSourcesCache(conn, conn.BeginFieldPoll(webtypes.FieldSources), nil, + errors.New("temporary read failure"), firstRead.Add(time.Second)) { + t.Fatal("failed source readback was not recorded") + } + + status := conn.Status() + if status.Sources != oldSources || !status.SourcesReadAt.Equal(firstRead) || !status.SourcesStale { + t.Fatalf("failed source readback changed the cache: %+v", status) + } + + secondRead := time.Now() + newSources := &models.Sources{SourceItem: []models.SourceItem{{Source: "PRODUCT"}}} + if !updateSourcesCache(conn, conn.BeginFieldPoll(webtypes.FieldSources), newSources, nil, secondRead) { + t.Fatal("successful source readback was not reported as an update") + } + + status = conn.Status() + if status.Sources != newSources || !status.SourcesReadAt.Equal(secondRead) || status.SourcesStale { + t.Fatalf("successful source readback did not refresh the cache: %+v", status) + } +} + +// TestHandleAPIDevicePublishesCanonicalReadback: the player's bounded readback +// after a source selection reads GET /devices/{id}, so that response must +// carry the same revisions the connection now holds -- not a snapshot taken +// before its own refresh. +func TestHandleAPIDevicePublishesCanonicalReadback(t *testing.T) { + speaker := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + responses := map[string]string{ + "/now_playing": `Confirmed trackPLAY_STATE`, + "/volume": `3535false`, + "/presets": ``, + "/sources": `Aux 1`, + "/bass": `00`, + } + body, ok := responses[r.URL.Path] + if !ok { + http.NotFound(w, r) + + return + } + w.Header().Set("Content-Type", "application/xml") + _, _ = w.Write([]byte(body)) + })) + defer speaker.Close() + + app := NewWebApp() + conn := webtypes.NewDeviceConnection( + client.NewClient(&client.Config{Host: speaker.URL}), + &models.DeviceInfo{Name: "Speaker"}, + ) + conn.SetStatus(&webtypes.DeviceStatus{NowPlaying: &models.NowPlaying{Source: "STANDBY"}}) + baselineRevision := conn.Status().NowPlayingRevision + conn.WebSocket = &client.WebSocketClient{} + app.AddDevice("speaker", conn) + + req := httptest.NewRequest(http.MethodGet, "/api/control/devices/speaker", nil) + req = withChiParams(req, map[string]string{"id": "speaker"}) + w := httptest.NewRecorder() + app.HandleAPIDevice(w, req) + + if w.Code != http.StatusOK { + t.Fatalf("GET device status = %d: %s", w.Code, w.Body.String()) + } + + var response struct { + Success bool `json:"success"` + Data struct { + Status webtypes.DeviceStatus `json:"status"` + } `json:"data"` + } + if err := json.NewDecoder(w.Body).Decode(&response); err != nil { + t.Fatalf("decode device readback: %v", err) + } + + canonical := conn.Status() + if !response.Success || canonical.NowPlaying.Source != "AUX" || canonical.NowPlaying.Track != "Confirmed track" { + t.Fatalf("canonical readback not merged: response=%+v status=%+v", response, canonical) + } + if response.Data.Status.NowPlaying.Source != canonical.NowPlaying.Source || + response.Data.Status.Revision != canonical.Revision || + response.Data.Status.NowPlayingRevision != canonical.NowPlayingRevision || + canonical.NowPlayingRevision <= baselineRevision { + t.Fatalf("response did not publish canonical status: response=%+v status=%+v", response.Data.Status, canonical) + } +} diff --git a/pkg/service/soundtouchweb/static/css/app.css b/pkg/service/soundtouchweb/static/css/app.css index a1dc4748..c426cb51 100644 --- a/pkg/service/soundtouchweb/static/css/app.css +++ b/pkg/service/soundtouchweb/static/css/app.css @@ -700,11 +700,23 @@ img { display: block; max-width: 100%; } font-size: .8rem; transition: background .1s, border-color .1s; } -.source-btn:hover { background: var(--bg); } +.source-btn:hover:not(:disabled) { background: var(--bg); } +.source-btn:disabled { cursor: not-allowed; opacity: .55; } .source-btn.active { border-color: var(--accent); background: var(--accent); color: var(--accent-fg); } +.source-btn.pending { cursor: wait; } +.source-btn.provisional-confirmed { border-style: dashed; cursor: progress; } +.source-btn.unverified { border-color: #a76b00; } +.source-btn.failed { border-color: var(--offline); } .source-btn.local { border-style: dashed; } .source-icon { font-size: .9rem; line-height: 1; } .source-name { font-weight: 500; } +.source-command-status { + min-height: 1.2em; + margin-top: .35rem; + color: var(--text-dim); + font-size: .75rem; +} +.source-command-status.availability { color: #8a5a00; } .stereo-pair-note { display: flex; diff --git a/pkg/service/soundtouchweb/static/js/api.js b/pkg/service/soundtouchweb/static/js/api.js index bb0f320c..747b075c 100644 --- a/pkg/service/soundtouchweb/static/js/api.js +++ b/pkg/service/soundtouchweb/static/js/api.js @@ -5,6 +5,20 @@ async function req(url, opts = {}) { return r.json(); } +async function checkedReq(url, opts = {}) { + const r = await fetch(url, opts); + let response; + try { + response = await r.json(); + } catch (_) { + throw new Error(`Request failed (${r.status})`); + } + if (!r.ok || response?.success === false) { + throw new Error(response?.error || `Request failed (${r.status})`); + } + return response; +} + export const api = { devices: () => req('/api/control/devices'), device: (id) => req(`/api/control/devices/${id}`), @@ -51,7 +65,11 @@ export const api = { tuneInSearchNext: (cursor) => req(`/api/control/providers/tunein/search/next?cursor=${encodeURIComponent(cursor)}`), control: (id, action, presetId) => req(`/api/control/devices/${id}/action/${action}?id=${presetId}`), storePreset: (id, slotId) => req(`/api/control/devices/${id}/action/storepreset?id=${slotId}`), - selectSource: (id, source, account) => req(`/api/control/devices/${id}/action/source?name=${encodeURIComponent(source)}&account=${encodeURIComponent(account || '')}`), + selectSource: (id, source, account) => checkedReq(`/api/control/devices/${id}/action/source`, { + method: 'POST', + headers: JSON_HEADERS, + body: JSON.stringify({ source, account: account ?? '' }), + }), tuneInPlay: (deviceId, item) => req(`/api/control/devices/${deviceId}/providers/tunein/play`, { method: 'POST', headers: JSON_HEADERS, diff --git a/pkg/service/soundtouchweb/static/js/api.test.mjs b/pkg/service/soundtouchweb/static/js/api.test.mjs new file mode 100644 index 00000000..2753f835 --- /dev/null +++ b/pkg/service/soundtouchweb/static/js/api.test.mjs @@ -0,0 +1,51 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; + +import { api } from './api.js'; + +test('selectSource posts the exact source and account body', async () => { + const requests = []; + globalThis.fetch = async (url, options) => { + requests.push({ url, options }); + return new Response('{"success":true}', { + status: 200, + headers: { 'Content-Type': 'application/json' }, + }); + }; + + await api.selectSource('speaker', 'AUX', 'AUX1'); + await api.selectSource('speaker', 'PRODUCT'); + + const request = requests[0]; + assert.equal(request.url, '/api/control/devices/speaker/action/source'); + assert.equal(request.options.method, 'POST'); + assert.deepEqual(JSON.parse(request.options.body), { source: 'AUX', account: 'AUX1' }); + assert.deepEqual(JSON.parse(requests[1].options.body), { source: 'PRODUCT', account: '' }); +}); + +test('source selection rejects non-2xx responses', async () => { + globalThis.fetch = async () => new Response('{"success":false,"error":"offline"}', { + status: 503, + headers: { 'Content-Type': 'application/json' }, + }); + + await assert.rejects(api.selectSource('speaker', 'AUX', 'AUX1'), /offline/); +}); + +test('source selection rejects application failures on 2xx responses', async () => { + globalThis.fetch = async () => new Response('{"success":false,"error":"rejected"}', { + status: 200, + headers: { 'Content-Type': 'application/json' }, + }); + + await assert.rejects(api.selectSource('speaker', 'AUX', 'AUX1'), /rejected/); +}); + +test('legacy API consumers retain response-level error handling', async () => { + globalThis.fetch = async () => new Response('{"success":false,"error":"offline"}', { + status: 503, + headers: { 'Content-Type': 'application/json' }, + }); + + assert.deepEqual(await api.devices(), { success: false, error: 'offline' }); +}); diff --git a/pkg/service/soundtouchweb/static/js/app.js b/pkg/service/soundtouchweb/static/js/app.js index ff453b8d..3308ab8b 100644 --- a/pkg/service/soundtouchweb/static/js/app.js +++ b/pkg/service/soundtouchweb/static/js/app.js @@ -21,7 +21,79 @@ import { removeDeviceAndRefresh } from './deviceRemoval.js'; const html = htm.bind(h); -function DeviceDetail({ deviceId, devices, onBack, onDevicesChanged, notify, onRemove }) { +function statusRevision(status) { + const revision = status?.revision; + return Number.isSafeInteger(revision) && revision >= 0 ? revision : null; +} + +// The server advances DeviceStatus.revision on every projection, so a frame +// carrying a revision no newer than what we already hold is stale -- a slow +// `devices` snapshot overtaken by a `status_update` delta, or a REST refresh +// overtaken by either. A device we have never seen is always accepted. +function acceptsNewerStatus(current, incoming) { + const currentRevision = statusRevision(current); + const incomingRevision = statusRevision(incoming); + if (currentRevision === null) return true; + return incomingRevision !== null && incomingRevision > currentRevision; +} + +// The server derives sourcesStale at read time, so two reads at the same +// revision can disagree about it. Carry that one derived bit forward without +// letting an otherwise-stale frame replace the canonical state. +function mergeDerivedStatus(current, incoming) { + const currentRevision = statusRevision(current); + const incomingRevision = statusRevision(incoming); + if (currentRevision === null || incomingRevision !== currentRevision || + current?.sourcesStale === true || incoming?.sourcesStale !== true) { + return current; + } + + return { ...current, sourcesStale: true }; +} + +export function mergeDevicesSnapshot(previous, snapshot) { + return Object.fromEntries(Object.entries(snapshot || {}).map(([deviceId, incoming]) => { + const current = Object.prototype.hasOwnProperty.call(previous, deviceId) + ? previous[deviceId] : null; + if (!current || acceptsNewerStatus(current.status, incoming?.status)) { + return [deviceId, incoming]; + } + return [deviceId, { + ...incoming, + status: mergeDerivedStatus(current.status, incoming?.status), + }]; + })); +} + +function replaceDevice(previous, deviceId, device) { + return Object.fromEntries([ + ...Object.entries(previous), + [deviceId, device], + ]); +} + +export function mergeStatusUpdate(previous, deviceId, status) { + // Object.prototype.hasOwnProperty, not a plain previous[deviceId] truthy + // check: a deviceId of "__proto__" or "constructor" would otherwise + // resolve through the prototype chain to a truthy value and pass the + // check despite not being a real, known device. + if (!Object.prototype.hasOwnProperty.call(previous, deviceId) || + !acceptsNewerStatus(previous[deviceId]?.status, status)) { + const current = previous[deviceId]?.status; + const merged = mergeDerivedStatus(current, status); + if (merged === current) return previous; + return replaceDevice(previous, deviceId, { + ...previous[deviceId], + status: merged, + }); + } + return replaceDevice(previous, deviceId, { + ...previous[deviceId], + status, + }); +} + +function DeviceDetail({ deviceId, devices, onBack, onDevicesChanged, notify, onRemove, onStatusReadback }) { const device = devices[deviceId]; if (!device) { @@ -47,7 +119,11 @@ function DeviceDetail({ deviceId, devices, onBack, onDevicesChanged, notify, onR <${NowPlaying} nowPlaying=${device.status?.nowPlaying} deviceId=${deviceId} presets=${device.status?.presets} /> <${Controls} deviceId=${deviceId} status=${device.status} /> <${Presets} deviceId=${deviceId} status=${device.status} /> - <${Sources} deviceId=${deviceId} status=${device.status} /> + <${Sources} + deviceId=${deviceId} + status=${device.status} + onStatusReadback=${status => onStatusReadback(deviceId, status)} + /> <${StereoPair} deviceId=${deviceId} device=${device} @@ -136,7 +212,7 @@ function App() { ws.onmessage = (event) => { const msg = JSON.parse(event.data); if (msg.type === 'devices') { - setDevices(msg.data || {}); + setDevices(previous => mergeDevicesSnapshot(previous, msg.data)); } else if (msg.type === 'discovery_status') { if (msg.data?.isDiscovering !== undefined) { setIsDiscovering(msg.data.isDiscovering); @@ -150,17 +226,7 @@ function App() { showToast(`Found ${msg.data.deviceCount} device(s)`); } } else if (msg.type === 'status_update' && msg.deviceId) { - setDevices(prev => { - // Object.prototype.hasOwnProperty, not a plain prev[msg.deviceId] - // truthy check: a deviceId of "__proto__" or "constructor" would - // otherwise resolve through the prototype chain to a truthy value - // and pass the check despite not being a real, known device. - if (!Object.prototype.hasOwnProperty.call(prev, msg.deviceId)) return prev; - return { - ...prev, - [msg.deviceId]: { ...prev[msg.deviceId], status: msg.data }, - }; - }); + setDevices(previous => mergeStatusUpdate(previous, msg.deviceId, msg.data)); } }; @@ -203,6 +269,10 @@ function App() { setDevices(resp.data || {}); } + function mergeDeviceReadback(deviceId, status) { + setDevices(previous => mergeStatusUpdate(previous, deviceId, status)); + } + async function removeDevice(id) { const name = devices[id]?.info?.name || id; if (!confirm(`Remove "${name}" from AfterTouch?\n\nThis does not reset the speaker. A device still online may reappear after the next discovery scan.`)) { @@ -302,6 +372,7 @@ function App() { onDevicesChanged=${refreshDevices} notify=${showToast} onRemove=${removeDevice} + onStatusReadback=${mergeDeviceReadback} /> ` : page === 'tunein' ? html` <${TuneInBrowser} key="tunein-browser" devices=${devices} /> @@ -334,4 +405,5 @@ function App() { `; } -render(html`<${App} />`, document.getElementById('app')); +const appRoot = document.getElementById('app'); +if (appRoot) render(html`<${App} />`, appRoot); diff --git a/pkg/service/soundtouchweb/static/js/components/Sources.js b/pkg/service/soundtouchweb/static/js/components/Sources.js index 864cfe45..2e4ccbc1 100644 --- a/pkg/service/soundtouchweb/static/js/components/Sources.js +++ b/pkg/service/soundtouchweb/static/js/components/Sources.js @@ -1,4 +1,5 @@ import { h } from 'preact'; +import { useEffect, useRef, useState } from 'preact/hooks'; import htm from 'htm'; import { api } from '../api.js'; @@ -11,31 +12,219 @@ const SOURCE_ICONS = { AIRPLAY: '📡', PRODUCT: '🔊', }; -export function Sources({ deviceId, status }) { +const SOURCE_READBACK_DELAYS_MS = [2000, 5000, 10000]; + +function isErrorSource(source) { + return source === 'INVALID_SOURCE' || source?.endsWith('_ERROR'); +} + +function sourceAccountIdentity(source, account) { + return account && account !== source ? account : ''; +} + +function sourceAccountsMatch(source, left, right) { + return sourceAccountIdentity(source, left) === sourceAccountIdentity(source, right); +} + +export function Sources({ + deviceId, + status, + onStatusReadback, + readbackDelays = SOURCE_READBACK_DELAYS_MS, +}) { + const [command, setCommand] = useState(null); + const commandRef = useRef({ generation: 0, active: null, timers: [] }); + const mountedRef = useRef(false); const items = status?.sources?.SourceItem ?? []; const currentSource = status?.nowPlaying?.Source; const currentAccount = status?.nowPlaying?.SourceAccount; + const nowPlayingRevision = Number.isSafeInteger(status?.nowPlayingRevision) + ? status.nowPlayingRevision : null; + const sourcesStale = status?.sourcesStale === true; + + function clearReadbacks() { + commandRef.current.timers.forEach(clearTimeout); + commandRef.current.timers = []; + } + + useEffect(() => { + if (!mountedRef.current) { + mountedRef.current = true; + return; + } + + commandRef.current.generation += 1; + commandRef.current.active = null; + clearReadbacks(); + setCommand(null); + }, [deviceId]); + + useEffect(() => { + return () => { + commandRef.current.generation += 1; + commandRef.current.active = null; + clearReadbacks(); + }; + }, []); + + useEffect(() => { + if (!command || nowPlayingRevision === null || command.startNowPlayingRevision === null || + nowPlayingRevision <= command.startNowPlayingRevision || + command.outcome === 'failed' || command.outcome === 'unverified') return; + + const matches = currentSource === command.source && + sourceAccountsMatch(command.source, currentAccount, command.account); + if (command.outcome === 'final-confirmed') { + if (nowPlayingRevision > command.confirmedRevision && !matches) { + setCommand(previous => previous?.generation === command.generation + ? null : previous); + } + return; + } + if (isErrorSource(currentSource)) { + clearReadbacks(); + commandRef.current.active = null; + setCommand(previous => previous?.generation === command.generation + ? { ...previous, outcome: 'failed', error: currentSource } + : previous); + } else if (matches && command.outcome === 'pending') { + setCommand(previous => previous?.generation === command.generation + ? { ...previous, outcome: 'provisional-confirmed' } + : previous); + } + }, [command, currentSource, currentAccount, nowPlayingRevision]); const ready = items.filter(s => s.Status === 'READY'); - if (ready.length === 0) return null; + const availabilityMessage = sourcesStale + ? 'Source list out of date' + : (ready.length === 0 ? 'Source list unavailable' : ''); + const availabilityId = sourcesStale + ? 'source-stale-status' + : (ready.length === 0 ? 'source-inventory-status' : null); - function select(src) { - api.selectSource(deviceId, src.Source, src.SourceAccount ?? ''); + async function select(src) { + clearReadbacks(); + const generation = commandRef.current.generation + 1; + const target = { source: src.Source, account: src.SourceAccount ?? '' }; + commandRef.current.generation = generation; + const active = { generation, latestReadback: -1, writeError: null }; + commandRef.current.active = active; + setCommand({ + ...target, + generation, + outcome: 'pending', + startNowPlayingRevision: nowPlayingRevision, + }); + const startedAt = Date.now(); + + readbackDelays.forEach((delay, index) => { + const timer = setTimeout(async () => { + if (commandRef.current.active !== active) return; + active.latestReadback = index; + + try { + const response = await api.device(deviceId); + if (commandRef.current.active !== active || active.latestReadback !== index) return; + + const readbackStatus = response?.data?.status; + const nowPlaying = readbackStatus?.nowPlaying; + const readbackRevision = readbackStatus?.nowPlayingRevision; + const revisionIsNewer = nowPlayingRevision !== null && + Number.isSafeInteger(readbackRevision) && + readbackRevision > nowPlayingRevision; + onStatusReadback?.(readbackStatus); + if (revisionIsNewer && isErrorSource(nowPlaying?.Source)) { + clearReadbacks(); + commandRef.current.active = null; + setCommand({ + ...target, + generation, + outcome: 'failed', + error: nowPlaying.Source, + startNowPlayingRevision: nowPlayingRevision, + }); + } else if (revisionIsNewer && nowPlaying?.Source === target.source && + sourceAccountsMatch(target.source, nowPlaying?.SourceAccount, target.account)) { + const isFinalReadback = index === readbackDelays.length - 1; + setCommand({ + ...target, + generation, + outcome: isFinalReadback ? 'final-confirmed' : 'provisional-confirmed', + startNowPlayingRevision: nowPlayingRevision, + confirmedRevision: readbackRevision, + }); + if (isFinalReadback) { + clearReadbacks(); + commandRef.current.active = null; + } + } else if (index === readbackDelays.length - 1) { + commandRef.current.active = null; + setCommand({ + ...target, + generation, + outcome: 'unverified', + error: active.writeError?.message, + startNowPlayingRevision: nowPlayingRevision, + }); + } + } catch (_) { + if (commandRef.current.active === active && active.latestReadback === index && + index === readbackDelays.length - 1) { + commandRef.current.active = null; + setCommand({ + ...target, + generation, + outcome: 'unverified', + error: active.writeError?.message, + startNowPlayingRevision: nowPlayingRevision, + }); + } + } + }, Math.max(0, delay - (Date.now() - startedAt))); + commandRef.current.timers.push(timer); + }); + + try { + await api.selectSource(deviceId, target.source, target.account); + } catch (error) { + if (commandRef.current.active === active) active.writeError = error; + } } + const projectsTarget = command && (command.outcome === 'pending' || + command.outcome === 'provisional-confirmed' || command.outcome === 'final-confirmed'); + const projectedSource = projectsTarget + ? command.source : currentSource; + const projectedAccount = projectsTarget + ? command.account : (currentAccount ?? ''); + + const outcomeText = { + pending: 'Selecting source', + 'provisional-confirmed': 'Source selected, confirming', + 'final-confirmed': 'Source selected', + unverified: 'Source selection unverified', + failed: 'Source selection failed', + }; + return html`

Sources

${ready.map(src => { - const isActive = src.Source === currentSource && - (!src.SourceAccount || src.SourceAccount === currentAccount); + const account = src.SourceAccount ?? ''; + const isTarget = command?.source === src.Source && command?.account === account; + const isActive = src.Source === projectedSource && + sourceAccountsMatch(src.Source, account, projectedAccount); + const outcome = isTarget ? command.outcome : ''; return html`
+
+ ${availabilityMessage || (command ? outcomeText[command.outcome] : '')} +
`; -} \ No newline at end of file +} diff --git a/pkg/service/soundtouchweb/websocket.go b/pkg/service/soundtouchweb/websocket.go index b0ad54aa..50dcba96 100644 --- a/pkg/service/soundtouchweb/websocket.go +++ b/pkg/service/soundtouchweb/websocket.go @@ -752,6 +752,7 @@ func (app *WebApp) updateDeviceStatus(_ string, conn *webtypes.DeviceConnection, volume, volumeErr := conn.Client.GetVolume() presets, presetsErr := conn.Client.GetPresets() sources, sourcesErr := conn.Client.GetSources() + sourcesReadAt := time.Now() bass, bassErr := conn.Client.GetBass() var ( @@ -797,13 +798,14 @@ func (app *WebApp) updateDeviceStatus(_ string, conn *webtypes.DeviceConnection, if sourcesErr == nil { anyFetchSucceeded = true - - conn.CompleteFieldPoll(webtypes.FieldSources, sourcesGen, func(s *webtypes.DeviceStatus) { - s.Sources = sources - s.LastActivity = time.Now() - }) } + // Unlike the other fields, a FAILED /sources read is merged too: the last + // known inventory stays visible but is marked stale so it cannot be acted + // on. Running through CompleteFieldPoll is what makes a newer failure + // fence an older, still-in-flight success. + updateSourcesCache(conn, sourcesGen, sources, sourcesErr, sourcesReadAt) + if bassErr == nil { anyFetchSucceeded = true @@ -849,6 +851,30 @@ func (app *WebApp) updateDeviceStatus(_ string, conn *webtypes.DeviceConnection, } } +// updateSourcesCache records a source refresh, successful or not. A failure +// keeps the previous inventory and its read time, but marks it stale so the +// player stops offering it until a read succeeds again. +func updateSourcesCache( + conn *webtypes.DeviceConnection, + generation uint64, + sources *models.Sources, + err error, + readAt time.Time, +) bool { + return conn.CompleteFieldPoll(webtypes.FieldSources, generation, func(s *webtypes.DeviceStatus) { + if err != nil { + s.SourcesStale = true + + return + } + + s.Sources = sources + s.SourcesReadAt = readAt + s.SourcesStale = false + s.LastActivity = time.Now() + }) +} + func (app *WebApp) applyGroupUpdatedEvent( conn *webtypes.DeviceConnection, event *models.GroupUpdatedEvent, diff --git a/pkg/service/soundtouchweb/webtypes/revision_test.go b/pkg/service/soundtouchweb/webtypes/revision_test.go new file mode 100644 index 00000000..4b5d4f52 --- /dev/null +++ b/pkg/service/soundtouchweb/webtypes/revision_test.go @@ -0,0 +1,120 @@ +package webtypes + +import ( + "encoding/json" + "strings" + "sync" + "testing" + + "github.com/gesellix/bose-soundtouch/pkg/models" +) + +// TestNowPlayingRejectsPollOlderThanEvent is the core ordering property the +// player's source confirmation relies on: a /now_playing poll that started +// before a nowPlayingUpdated event must not overwrite the event's result when +// it finishes late. +func TestNowPlayingRejectsPollOlderThanEvent(t *testing.T) { + conn := NewDeviceConnection(nil, nil) + pollGeneration := conn.BeginFieldPoll(FieldNowPlaying) + eventNowPlaying := &models.NowPlaying{Track: "new event"} + + conn.ApplyFieldEvent(FieldNowPlaying, func(status *DeviceStatus) { + status.NowPlaying = eventNowPlaying + }) + + if conn.CompleteFieldPoll(FieldNowPlaying, pollGeneration, func(status *DeviceStatus) { + status.NowPlaying = &models.NowPlaying{Track: "old poll"} + }) { + t.Fatal("older NowPlaying poll was accepted after the event") + } + + if got := conn.Status().NowPlaying; got != eventNowPlaying { + t.Fatalf("older poll replaced NowPlaying event: %+v", got) + } +} + +func TestDeviceStatusRevisionAdvancesForEveryProjection(t *testing.T) { + conn := NewDeviceConnection(nil, nil) + if got := conn.Status().Revision; got != 0 { + t.Fatalf("initial revision = %d, want 0", got) + } + + conn.UpdateStatus(func(*DeviceStatus) {}) + if got := conn.Status().Revision; got != 1 { + t.Fatalf("revision after UpdateStatus = %d, want 1", got) + } + + // A caller-supplied Revision is never trusted: resetting it would make a + // browser holding a higher revision reject every later update. + conn.SetStatus(&DeviceStatus{Revision: 99}) + if got := conn.Status().Revision; got != 2 { + t.Fatalf("revision after SetStatus = %d, want 2", got) + } +} + +func TestUnrelatedProjectionDoesNotAdvanceNowPlayingRevision(t *testing.T) { + conn := NewDeviceConnection(nil, nil) + conn.SetStatus(&DeviceStatus{NowPlaying: &models.NowPlaying{Source: "SPOTIFY"}}) + baseline := conn.Status() + + volumeGeneration := conn.BeginFieldPoll(FieldVolume) + conn.CompleteFieldPoll(FieldVolume, volumeGeneration, func(status *DeviceStatus) { + status.Volume = &models.Volume{ActualVolume: 35} + }) + updated := conn.Status() + + if updated.Revision <= baseline.Revision { + t.Fatalf("aggregate revision = %d, want newer than %d", updated.Revision, baseline.Revision) + } + if updated.NowPlayingRevision != baseline.NowPlayingRevision { + t.Fatalf("now-playing revision = %d, want unchanged %d after volume update", + updated.NowPlayingRevision, baseline.NowPlayingRevision) + } +} + +func TestDeviceStatusRevisionIsMonotonicWithConcurrentProjections(t *testing.T) { + conn := NewDeviceConnection(nil, nil) + const projections = 64 + + var wg sync.WaitGroup + wg.Add(projections) + for i := range projections { + go func() { + defer wg.Done() + if i%2 == 0 { + conn.UpdateStatus(func(*DeviceStatus) {}) + + return + } + conn.SetStatus(&DeviceStatus{}) + }() + } + wg.Wait() + + if got := conn.Status().Revision; got != projections { + t.Fatalf("final revision = %d, want %d", got, projections) + } +} + +func TestDeviceStatusJSONExposesPublicRevisionsOnly(t *testing.T) { + conn := NewDeviceConnection(nil, nil) + conn.ApplyFieldEvent(FieldNowPlaying, func(status *DeviceStatus) { + status.NowPlaying = &models.NowPlaying{Track: "test"} + }) + + encoded, err := json.Marshal(conn.Status()) + if err != nil { + t.Fatalf("marshal DeviceStatus: %v", err) + } + + jsonStatus := string(encoded) + if !strings.Contains(jsonStatus, `"revision":1`) { + t.Fatalf("public revision missing from JSON: %s", jsonStatus) + } + if !strings.Contains(jsonStatus, `"nowPlayingRevision":1`) { + t.Fatalf("now-playing revision missing from JSON: %s", jsonStatus) + } + if strings.Contains(jsonStatus, "fieldGen") { + t.Fatalf("internal field generation leaked into JSON: %s", jsonStatus) + } +} diff --git a/pkg/service/soundtouchweb/webtypes/source_cache_test.go b/pkg/service/soundtouchweb/webtypes/source_cache_test.go new file mode 100644 index 00000000..39a1a569 --- /dev/null +++ b/pkg/service/soundtouchweb/webtypes/source_cache_test.go @@ -0,0 +1,113 @@ +package webtypes + +import ( + "encoding/json" + "strings" + "testing" + "time" + + "github.com/gesellix/bose-soundtouch/pkg/models" +) + +func TestSourceCacheStatusAtTTLBoundary(t *testing.T) { + readAt := time.Date(2026, time.August, 30, 12, 0, 0, 0, time.UTC) + sources := &models.Sources{SourceItem: []models.SourceItem{{Source: "AUX"}}} + status := &DeviceStatus{ + Sources: sources, + SourcesReadAt: readAt, + } + + fresh := sourceCacheStatusAt(status, readAt.Add(sourceCacheTTL-time.Nanosecond), sourceCacheTTL) + if fresh.SourcesStale { + t.Fatal("source cache became stale before its TTL elapsed") + } + + stale := sourceCacheStatusAt(status, readAt.Add(sourceCacheTTL), sourceCacheTTL) + if !stale.SourcesStale { + t.Fatal("source cache was not stale at its TTL boundary") + } + if stale.Sources != sources { + t.Fatal("stale projection did not retain the last successful source list") + } + + refreshed := *stale + refreshed.SourcesReadAt = readAt.Add(sourceCacheTTL + time.Second) + refreshed.SourcesStale = false + + got := sourceCacheStatusAt(&refreshed, refreshed.SourcesReadAt, sourceCacheTTL) + if got.SourcesStale { + t.Fatal("successful source readback did not clear staleness immediately") + } +} + +func TestSourceCacheWithoutSuccessfulReadIsNotStale(t *testing.T) { + status := &DeviceStatus{Sources: &models.Sources{}} + + got := sourceCacheStatusAt(status, time.Now().Add(time.Hour), time.Nanosecond) + if got.SourcesStale { + t.Fatal("source cache without a recorded successful read was marked stale") + } +} + +func TestSourceCacheFailureWithoutInventoryIsExplicit(t *testing.T) { + conn := NewDeviceConnection(nil, nil) + conn.CompleteFieldPoll(FieldSources, conn.BeginFieldPoll(FieldSources), func(status *DeviceStatus) { + status.SourcesStale = true + }) + + status := conn.Status() + if status.Sources != nil || !status.SourcesStale { + t.Fatalf("initial source failure was not represented without inventory: %+v", status) + } + + encoded, err := json.Marshal(status) + if err != nil { + t.Fatalf("marshal initial source failure: %v", err) + } + if got := string(encoded); !strings.Contains(got, `"sourcesStale":true`) { + t.Fatalf("initial source failure omitted stale state: %s", got) + } +} + +// TestSourceCacheFailureFencesOlderOverlappingSuccess: a /sources read that +// FAILED must not be undone by an older, still-in-flight read that happens to +// succeed after it. Both go through CompleteFieldPoll(FieldSources, ...), so +// the failure's newer generation wins. +func TestSourceCacheFailureFencesOlderOverlappingSuccess(t *testing.T) { + conn := NewDeviceConnection(nil, nil) + readAt := time.Date(2026, time.August, 30, 12, 0, 0, 0, time.UTC) + retained := &models.Sources{SourceItem: []models.SourceItem{{Source: "AUX"}}} + + firstGeneration := conn.BeginFieldPoll(FieldSources) + secondGeneration := conn.BeginFieldPoll(FieldSources) + + conn.CompleteFieldPoll(FieldSources, secondGeneration, func(status *DeviceStatus) { + status.Sources = retained + status.SourcesReadAt = readAt + status.SourcesStale = true + }) + + older := &models.Sources{SourceItem: []models.SourceItem{{Source: "PRODUCT"}}} + if conn.CompleteFieldPoll(FieldSources, firstGeneration, func(status *DeviceStatus) { + status.Sources = older + status.SourcesStale = false + }) { + t.Fatal("older success was accepted after newer failure") + } + + if status := conn.Status(); !status.SourcesStale || status.Sources != retained { + t.Fatalf("older success cleared newer failure: %+v", status) + } + + newer := &models.Sources{SourceItem: []models.SourceItem{{Source: "BLUETOOTH"}}} + newerRead := time.Now() + conn.CompleteFieldPoll(FieldSources, conn.BeginFieldPoll(FieldSources), func(status *DeviceStatus) { + status.Sources = newer + status.SourcesReadAt = newerRead + status.SourcesStale = false + }) + + if status := conn.Status(); status.SourcesStale || status.Sources != newer { + t.Fatalf("newer source success did not restore actionability: %+v", status) + } +} diff --git a/pkg/service/soundtouchweb/webtypes/types.go b/pkg/service/soundtouchweb/webtypes/types.go index 90df2a8a..eef637d8 100644 --- a/pkg/service/soundtouchweb/webtypes/types.go +++ b/pkg/service/soundtouchweb/webtypes/types.go @@ -111,6 +111,8 @@ type DeviceStatus struct { Volume *models.Volume `json:"volume,omitempty"` Presets *models.Presets `json:"presets,omitempty"` Sources *models.Sources `json:"sources,omitempty"` + SourcesStale bool `json:"sourcesStale,omitempty"` + SourcesReadAt time.Time `json:"-"` Bass *models.Bass `json:"bass,omitempty"` Group *models.Group `json:"group,omitempty"` Connectivity Connectivity `json:"connectivity"` @@ -119,6 +121,18 @@ type DeviceStatus struct { SpeakerConnectionState *SpeakerConnectionState `json:"speakerConnectionState,omitempty"` IsConnected bool `json:"isConnected"` LastActivity time.Time `json:"lastActivity"` + + // Revision is a per-connection monotonic counter advanced by every + // successful UpdateStatus. It lets the browser order a full `devices` + // snapshot against a `status_update` delta -- without it the two frames + // carry no sequence at all and a slow snapshot can clobber a newer delta. + Revision uint64 `json:"revision"` + // NowPlayingRevision is the FieldNowPlaying generation that last wrote + // NowPlaying. Revision alone cannot answer "did now-playing actually + // change?", because an unrelated field's merge advances it too; a source + // selection waiting for authoritative confirmation needs exactly that + // distinction. + NowPlayingRevision uint64 `json:"nowPlayingRevision"` } // Connectivity is the player's aggregate view of HTTP and event-stream @@ -138,6 +152,10 @@ const ( offlineGracePeriod = 60 * time.Second ) +// sourceCacheTTL bounds how long a successfully-read source inventory stays +// actionable without a fresh confirmation from the speaker. +const sourceCacheTTL = 30 * time.Second + // SpeakerConnectionState is the network state reported by the speaker. type SpeakerConnectionState struct { State string `json:"state"` @@ -247,7 +265,20 @@ func (c *DeviceConnection) Info() *models.DeviceInfo { // mutated. Use UpdateStatus or SetStatus to apply changes. Never returns // nil for connections built via NewDeviceConnection. func (c *DeviceConnection) Status() *DeviceStatus { - return c.status.Load() + return sourceCacheStatusAt(c.status.Load(), time.Now(), sourceCacheTTL) +} + +func sourceCacheStatusAt(status *DeviceStatus, now time.Time, ttl time.Duration) *DeviceStatus { + stale := status.SourcesStale || status.Sources != nil && !status.SourcesReadAt.IsZero() && + !now.Before(status.SourcesReadAt.Add(ttl)) + if stale == status.SourcesStale { + return status + } + + next := *status + next.SourcesStale = stale + + return &next } // Done returns a channel that is closed when the connection is removed @@ -334,8 +365,40 @@ func (c *DeviceConnection) FinishWebSocketLoop() { // SetStatus atomically replaces the entire status. Use sparingly — // UpdateStatus is the preferred entry point because it preserves // concurrent changes from other goroutines. +// +// Revision is derived from the currently stored status rather than trusted +// from the caller: a replacement that reset it to the caller's zero value +// would make every browser holding a higher revision reject this device's +// subsequent updates outright. Replacing the whole status also supersedes +// every field, so each StatusField generation is advanced past any poll +// still in flight. func (c *DeviceConnection) SetStatus(s *DeviceStatus) { - c.status.Store(s) + nowPlayingGeneration := c.supersedeAllFields() + + for { + old := c.status.Load() + next := *s + next.Revision = old.Revision + 1 + next.NowPlayingRevision = nowPlayingGeneration + + if c.status.CompareAndSwap(old, &next) { + return + } + } +} + +// supersedeAllFields advances every StatusField generation past whatever is +// currently in flight and returns FieldNowPlaying's new generation. +func (c *DeviceConnection) supersedeAllFields() uint64 { + c.fieldGenMu.Lock() + defer c.fieldGenMu.Unlock() + + for field := range c.fieldGen { + c.fieldGen[field].issued++ + c.fieldGen[field].applied = c.fieldGen[field].issued + } + + return c.fieldGen[FieldNowPlaying].applied } // BeginFieldPoll reserves a generation for an asynchronous fetch of field, @@ -367,7 +430,10 @@ func (c *DeviceConnection) CompleteFieldPoll(field StatusField, generation uint6 c.fieldGen[field].applied = generation c.fieldGenMu.Unlock() - c.UpdateStatus(mut) + c.UpdateStatus(func(status *DeviceStatus) { + mut(status) + recordFieldRevision(status, field, generation) + }) return true } @@ -378,10 +444,26 @@ func (c *DeviceConnection) CompleteFieldPoll(field StatusField, generation uint6 func (c *DeviceConnection) ApplyFieldEvent(field StatusField, mut func(*DeviceStatus)) { c.fieldGenMu.Lock() c.fieldGen[field].issued++ - c.fieldGen[field].applied = c.fieldGen[field].issued + generation := c.fieldGen[field].issued + c.fieldGen[field].applied = generation c.fieldGenMu.Unlock() - c.UpdateStatus(mut) + c.UpdateStatus(func(status *DeviceStatus) { + mut(status) + recordFieldRevision(status, field, generation) + }) +} + +// recordFieldRevision publishes the generation that just wrote field, for the +// fields whose ordering a client needs to observe. Only FieldNowPlaying is +// published today: a source selection confirms itself by waiting for a +// now-playing write strictly newer than the one it started from, and the +// aggregate Revision cannot express that, because any other field's merge +// advances it too. +func recordFieldRevision(status *DeviceStatus, field StatusField, generation uint64) { + if field == FieldNowPlaying { + status.NowPlayingRevision = generation + } } // UpdateStatus atomically applies mut to a copy of the current status @@ -397,11 +479,14 @@ func (c *DeviceConnection) ApplyFieldEvent(field StatusField, mut func(*DeviceSt // reader still holding the previous snapshot). Production callers // receive these values fresh from the device API, so this is the // natural shape. +// +// Every successful store advances Revision exactly once. func (c *DeviceConnection) UpdateStatus(mut func(*DeviceStatus)) { for { old := c.status.Load() next := *old mut(&next) + next.Revision = old.Revision + 1 if c.status.CompareAndSwap(old, &next) { return @@ -735,6 +820,12 @@ type BassRequest struct { Level int `json:"level"` } +// SourceRequest represents an exact source selection request. +type SourceRequest struct { + Source string `json:"source"` + Account string `json:"account"` +} + // WebSocketMessage represents messages sent over WebSocket type WebSocketMessage struct { Type string `json:"type"`