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) <noreply@anthropic.com>
This commit is contained in:
Tobias Gesellchen
2026-09-05 20:36:58 +02:00
co-authored by Claude Opus 5
parent 2bf171e8e8
commit c7319b383c
2 changed files with 60 additions and 0 deletions
@@ -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
@@ -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) {