Skip to content

Commit e4fe4f6

Browse files
committed
ytmusic: invalidate caches on Ctrl+R refresh (#218)
1 parent 4b6ed1c commit e4fe4f6

5 files changed

Lines changed: 98 additions & 0 deletions

File tree

external/ytmusic/cache.go

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -103,3 +103,9 @@ func (c *ytCache) setTracks(playlistID string, tracks []playlist.Track) {
103103
FetchedAt: time.Now(),
104104
}
105105
}
106+
107+
func (c *ytCache) clear() {
108+
c.Playlists = nil
109+
c.PlaylistsAt = time.Time{}
110+
c.Tracks = make(map[string]cachedTrackList)
111+
}

external/ytmusic/provider.go

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -116,6 +116,22 @@ func (b *baseProvider) initSession(interactive bool) error {
116116
func (b *baseProvider) ensureSession() error { return b.initSession(false) }
117117
func (b *baseProvider) authenticate() error { return b.initSession(true) }
118118

119+
// refresh clears playlist/track caches so the next call re-fetches from the
120+
// API. Classification is preserved — it's on disk in a separate file, rarely
121+
// changes, and re-classifying costs API quota.
122+
func (b *baseProvider) refresh() {
123+
b.mu.Lock()
124+
b.allPlaylists = nil
125+
b.classified = nil
126+
clear(b.trackCache)
127+
dc := b.ensureDiskCache()
128+
dc.clear()
129+
snap := dc.snapshot()
130+
b.mu.Unlock()
131+
132+
saveSnapshot(snap)
133+
}
134+
119135
func (b *baseProvider) close() {
120136
b.mu.Lock()
121137
defer b.mu.Unlock()
@@ -445,6 +461,7 @@ type YouTubeMusicProvider struct {
445461
func (p *YouTubeMusicProvider) Name() string { return "YouTube Music" }
446462
func (p *YouTubeMusicProvider) Authenticate() error { return p.base.authenticate() }
447463
func (p *YouTubeMusicProvider) Close() { p.base.close() }
464+
func (p *YouTubeMusicProvider) Refresh() { p.base.refresh() }
448465
func (p *YouTubeMusicProvider) Tracks(id string) ([]playlist.Track, error) {
449466
return p.base.tracks(id)
450467
}
@@ -474,6 +491,7 @@ type YouTubeProvider struct {
474491
func (p *YouTubeProvider) Name() string { return "YouTube" }
475492
func (p *YouTubeProvider) Authenticate() error { return p.base.authenticate() }
476493
func (p *YouTubeProvider) Close() { /* shared base; closed via music provider */ }
494+
func (p *YouTubeProvider) Refresh() { p.base.refresh() }
477495
func (p *YouTubeProvider) Tracks(id string) ([]playlist.Track, error) {
478496
return p.base.tracks(id)
479497
}
@@ -503,6 +521,7 @@ type YouTubeAllProvider struct {
503521
func (p *YouTubeAllProvider) Name() string { return "YouTube (All)" }
504522
func (p *YouTubeAllProvider) Authenticate() error { return p.base.authenticate() }
505523
func (p *YouTubeAllProvider) Close() { /* shared base; closed via music provider */ }
524+
func (p *YouTubeAllProvider) Refresh() { p.base.refresh() }
506525
func (p *YouTubeAllProvider) Tracks(id string) ([]playlist.Track, error) {
507526
return p.base.tracks(id)
508527
}

external/ytmusic/provider_test.go

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
package ytmusic
2+
3+
import (
4+
"testing"
5+
"time"
6+
7+
"cliamp/playlist"
8+
)
9+
10+
func TestRefreshInvalidatesAllCaches(t *testing.T) {
11+
t.Setenv("HOME", t.TempDir())
12+
13+
b := newBase(nil, "client-id", "client-secret", false)
14+
15+
b.allPlaylists = []playlistEntry{{ID: "p1", Name: "One", TrackCount: 5}}
16+
b.classified = map[string]bool{"p1": true}
17+
b.trackCache["p1"] = []playlist.Track{{Path: "https://example/v", Title: "t"}}
18+
19+
dc := b.ensureDiskCache()
20+
dc.setPlaylists(b.allPlaylists)
21+
dc.setTracks("p1", b.trackCache["p1"])
22+
saveSnapshot(dc.snapshot())
23+
24+
if !dc.playlistsFresh() {
25+
t.Fatal("disk cache should be fresh before refresh")
26+
}
27+
28+
b.refresh()
29+
30+
if b.allPlaylists != nil {
31+
t.Error("allPlaylists not cleared")
32+
}
33+
if b.classified != nil {
34+
t.Error("classified not cleared")
35+
}
36+
if len(b.trackCache) != 0 {
37+
t.Errorf("trackCache not cleared: %d entries", len(b.trackCache))
38+
}
39+
40+
if b.disk.playlistsFresh() {
41+
t.Error("disk cache still reports fresh after refresh")
42+
}
43+
if !b.disk.PlaylistsAt.IsZero() {
44+
t.Errorf("PlaylistsAt should be zero, got %v", b.disk.PlaylistsAt)
45+
}
46+
if len(b.disk.Playlists) != 0 {
47+
t.Errorf("disk Playlists not cleared: %d entries", len(b.disk.Playlists))
48+
}
49+
if len(b.disk.Tracks) != 0 {
50+
t.Errorf("disk Tracks not cleared: %d entries", len(b.disk.Tracks))
51+
}
52+
53+
reloaded := loadYTCache()
54+
if reloaded.playlistsFresh() {
55+
t.Error("reloaded disk cache still fresh after refresh")
56+
}
57+
if !reloaded.PlaylistsAt.Equal(time.Time{}) {
58+
t.Errorf("reloaded PlaylistsAt should be zero, got %v", reloaded.PlaylistsAt)
59+
}
60+
if len(reloaded.Tracks) != 0 {
61+
t.Errorf("reloaded disk Tracks not cleared: %d entries", len(reloaded.Tracks))
62+
}
63+
}

playlist/provider.go

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,3 +40,10 @@ type Provider interface {
4040
type Authenticator interface {
4141
Authenticate() error
4242
}
43+
44+
// Refresher is optionally implemented by providers that cache playlist or
45+
// track data and support invalidating that cache so the next Playlists() /
46+
// Tracks() call re-fetches from the source.
47+
type Refresher interface {
48+
Refresh()
49+
}

ui/model/keys.go

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -321,6 +321,9 @@ func (m *Model) handleKey(msg tea.KeyPressMsg) tea.Cmd {
321321
m.provSearch.cursor = 0
322322
case "ctrl+r":
323323
if m.provider != nil && !m.provLoading {
324+
if r, ok := m.provider.(playlist.Refresher); ok {
325+
r.Refresh()
326+
}
324327
m.providerLists = nil
325328
m.provLoading = true
326329
m.activeProviderPlaylistID = ""

0 commit comments

Comments
 (0)