fix(ui): cancel abandoned Spotify album loads

This commit is contained in:
Bjarne Øverli
2026-08-20 19:29:48 +02:00
parent edc9399254
commit 5e3fc47e58
6 changed files with 51 additions and 6 deletions
+10 -2
View File
@@ -452,9 +452,17 @@ type spotAlbumTracksMsg struct {
// fetchSpotAlbumTracksCmd expands an album placeholder from the search results
// into its tracks. Album entries carry no streamable path of their own, so this
// runs before the album can reach the player.
func fetchSpotAlbumTracksCmd(loader provider.AlbumTrackLoader, album playlist.Track, action spotAlbumAction, gen uint64) tea.Cmd {
func fetchSpotAlbumTracksCmd(ctx context.Context, loader provider.AlbumTrackLoader, album playlist.Track, action spotAlbumAction, gen uint64) tea.Cmd {
return func() tea.Msg {
tracks, err := loader.AlbumTracks(album.AlbumID())
var tracks []playlist.Track
var err error
if contextual, ok := loader.(interface {
AlbumTracksContext(context.Context, string) ([]playlist.Track, error)
}); ok {
tracks, err = contextual.AlbumTracksContext(ctx, album.AlbumID())
} else {
tracks, err = loader.AlbumTracks(album.AlbumID())
}
return spotAlbumTracksMsg{tracks: tracks, album: album, action: action, err: err, gen: gen}
}
}
+2 -1
View File
@@ -184,7 +184,8 @@ func (m *Model) expandSpotAlbum(album playlist.Track, action spotAlbumAction) te
}
m.spotSearch.albumLoading = true
m.spotSearch.err = ""
return fetchSpotAlbumTracksCmd(loader, album, action, nextRequest(&m.requests.spotAlbum))
ctx := m.newSpotRequestContext(30 * time.Second)
return fetchSpotAlbumTracksCmd(ctx, loader, album, action, nextRequest(&m.requests.spotAlbum))
}
func (m *Model) spotSearchPlaylistMaybeAdjustScroll(visible int) {
+1
View File
@@ -217,6 +217,7 @@ func (m *Model) closeSpotSearch() {
}
func (m *Model) invalidateSpotAlbumRequest() {
m.cancelSpotRequest()
nextRequest(&m.requests.spotAlbum)
m.spotSearch.albumLoading = false
}
+30
View File
@@ -1,6 +1,8 @@
package model
import (
"context"
"errors"
"testing"
tea "charm.land/bubbletea/v2"
@@ -29,18 +31,25 @@ func TestCanceledSpotAlbumResponseIsIgnored(t *testing.T) {
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
canceled := make(chan struct{})
m := Model{
playlist: playlist.New(),
spotSearch: spotSearchState{
visible: true,
screen: spotSearchResults,
albumLoading: true,
cancel: func() { close(canceled) },
},
}
const gen = 7
m.requests.spotAlbum = gen
tt.cancel(&m)
select {
case <-canceled:
default:
t.Fatal("album request context was not canceled")
}
if m.requests.spotAlbum == gen {
t.Fatal("album request generation was not invalidated")
}
@@ -61,3 +70,24 @@ func TestCanceledSpotAlbumResponseIsIgnored(t *testing.T) {
})
}
}
type contextAlbumLoader struct{}
func (contextAlbumLoader) AlbumTracks(string) ([]playlist.Track, error) {
return nil, errors.New("legacy album loader called")
}
func (contextAlbumLoader) AlbumTracksContext(ctx context.Context, _ string) ([]playlist.Track, error) {
<-ctx.Done()
return nil, ctx.Err()
}
func TestFetchSpotAlbumTracksCmdUsesContextLoader(t *testing.T) {
ctx, cancel := context.WithCancel(context.Background())
cancel()
msg := fetchSpotAlbumTracksCmd(ctx, contextAlbumLoader{}, albumResult("Album"), spotAlbumPlay, 1)().(spotAlbumTracksMsg)
if !errors.Is(msg.err, context.Canceled) {
t.Fatalf("album load error = %v, want context.Canceled", msg.err)
}
}
+1
View File
@@ -737,6 +737,7 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
if msg.gen != m.requests.spotAlbum {
return m, nil
}
m.cancelSpotRequest()
m.spotSearch.albumLoading = false
if msg.err != nil {
m.setSpotSearchError(msg.err.Error())