From c7319b383c508773c94dfd28ff8a23833eef6c53 Mon Sep 17 00:00:00 2001 From: Tobias Gesellchen Date: Sat, 5 Sep 2026 20:19:43 +0200 Subject: [PATCH] fix(player): open the Library for STORED_MUSIC instead of selecting it A STORED_MUSIC entry names a media server, not something to play: its sourceAccount is a server UDN, and there is one entry per server. Selecting it identifies no track or container, so browsing is the only meaningful action. Clicking one now opens the Library. It never resumes from Recents either: even a resumable album there is a worse guess than showing the user what is on the server. The clicked server is not carried over, matching how the other browser pages let you pick a device rather than inheriting one. Servers that are offline never reach this path anyway: the speaker reports them status="UNAVAILABLE" and the source list only renders READY entries. Co-Authored-By: Claude Opus 5 (1M context) --- .../browser_compatibility_test.go | 52 +++++++++++++++++++ .../static/js/components/Sources.js | 8 +++ 2 files changed, 60 insertions(+) diff --git a/pkg/service/soundtouchweb/browser_compatibility_test.go b/pkg/service/soundtouchweb/browser_compatibility_test.go index 58e75779..0e2ef098 100644 --- a/pkg/service/soundtouchweb/browser_compatibility_test.go +++ b/pkg/service/soundtouchweb/browser_compatibility_test.go @@ -68,6 +68,7 @@ render(h(Sources, { sources: { SourceItem: [ { Source: 'RADIO_BROWSER', SourceAccount: '', DisplayName: 'RadioBrowser', Status: 'READY' }, { Source: 'LOCAL_INTERNET_RADIO', SourceAccount: '', DisplayName: 'Local Radio', Status: 'READY' }, + { Source: 'STORED_MUSIC', SourceAccount: 'fa095ecc-e13e-40e7-8e6c-e0286d5bc000/0', DisplayName: 'fritz', Status: 'READY' }, ] }, nowPlaying: { Source: 'SPOTIFY', SourceAccount: 'someone' }, }, @@ -827,6 +828,57 @@ func TestLocalInternetRadioNeverResumes(t *testing.T) { } } +// TestStoredMusicOpensTheLibrary: a STORED_MUSIC entry names a media server, +// not something to play, so selecting it identifies no track or container. +// Browsing is the only meaningful action. +func TestStoredMusicOpensTheLibrary(t *testing.T) { + var mu sync.Mutex + writes := 0 + server := newPlayerFixtureServer(t, providerFixtureScript, func(r chi.Router) { + r.Get("/api/control/devices/speaker/recents", func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"success":true,"data":{"Items":[ + {"ID":1,"ContentItem":{"Source":"STORED_MUSIC","Type":"dir", + "SourceAccount":"fa095ecc-e13e-40e7-8e6c-e0286d5bc000/0", + "Location":"/music/album/1","ItemName":"Some Album","IsPresetable":true}} + ]}}`)) + }) + r.Post("/api/control/devices/speaker/play", func(w http.ResponseWriter, _ *http.Request) { + mu.Lock() + writes++ + mu.Unlock() + }) + r.Post("/api/control/devices/speaker/action/source", func(w http.ResponseWriter, _ *http.Request) { + mu.Lock() + writes++ + mu.Unlock() + }) + }) + + ctx := newHeadlessChromeContext(t) + var navigated []string + if err := chromedp.Run(ctx, + chromedp.Navigate(server.URL+"/fixture"), + chromedp.WaitVisible(`.source-btn`, chromedp.ByQuery), + chromedp.Click(`.source-btn:nth-child(3)`, chromedp.ByQuery), + chromedp.Poll(`window.navigated.length === 1`, nil), + chromedp.Evaluate(`window.navigated`, &navigated), + ); err != nil { + t.Fatalf("exercise STORED_MUSIC source: %v", err) + } + + if len(navigated) != 1 || navigated[0] != "library" { + t.Errorf("navigated = %v, want [library]", navigated) + } + + mu.Lock() + defer mu.Unlock() + // Even with a resumable album in Recents, this source only ever browses. + if writes != 0 { + t.Errorf("issued %d writes, want 0", writes) + } +} + func TestSourceSelectionStopsReadbacksOnceTheEventStreamConfirms(t *testing.T) { var mu sync.Mutex reads := 0 diff --git a/pkg/service/soundtouchweb/static/js/components/Sources.js b/pkg/service/soundtouchweb/static/js/components/Sources.js index 7a52f028..9c4bb468 100644 --- a/pkg/service/soundtouchweb/static/js/components/Sources.js +++ b/pkg/service/soundtouchweb/static/js/components/Sources.js @@ -43,10 +43,18 @@ const SOURCE_READBACK_DELAYS_MS = [2000, 5000, 10000]; // which is what HandlePlayURL emits content for, is predictable instead of // guessing. RADIO_BROWSER and TUNEIN have no such problem: nothing writes // one-shot audio to them, and their Recents hold real stations. +// +// STORED_MUSIC does not resume either, for a different reason: it is not one +// source but one entry per media server (its sourceAccount is a server UDN), +// and selecting it identifies no track or container to play. Browsing is the +// only meaningful action, so a click opens the Library. The clicked server is +// not carried over -- the Library lists the servers itself, the same way the +// other browser pages let you pick a device. const PROVIDER_SOURCES = { RADIO_BROWSER: { page: 'radiobrowser', resume: true }, TUNEIN: { page: 'tunein', resume: true }, LOCAL_INTERNET_RADIO: { page: 'playurl', resume: false }, + STORED_MUSIC: { page: 'library', resume: false }, }; function isErrorSource(source) {