fix(ytmusic): isolate cache data by account

This commit is contained in:
Bjarne Øverli
2026-08-20 19:01:35 +02:00
parent a4bd338e24
commit 724537d3b4
7 changed files with 40 additions and 75 deletions
+1 -1
View File
@@ -117,7 +117,7 @@ When using OAuth authentication, playlists are automatically split between the t
For OAuth setups, classification is determined by sampling a video from each playlist and checking its YouTube category. Results are cached at `~/.config/cliamp/ytmusic_classification.json` (and `~/.config/cliamp/ytmusic_cache.json`). Delete these files or press `Ctrl+R` in the TUI to reclassify and refresh.
For cookie-backed providers (`cookies_from`), all custom playlists are appended to both YouTube Music and YouTube results without category classification, and `ytmusic_classification.json` is not populated. Results and tracks are cached at `~/.config/cliamp/ytmusic_cache.json` (refresh with `Ctrl+R` or by deleting the cache file).
For cookie-backed providers (`cookies_from`), all custom playlists are appended to both YouTube Music and YouTube results without category classification, and `ytmusic_classification.json` is not populated. Results and tracks are cached in memory for the current session; press `Ctrl+R` to refresh them.
## Troubleshooting
+11
View File
@@ -1,7 +1,9 @@
package ytmusic
import (
"crypto/sha256"
"encoding/json"
"fmt"
"os"
"path/filepath"
"time"
@@ -40,6 +42,15 @@ func newYTCache(scope string) *ytCache {
return &ytCache{Scope: scope, Tracks: make(map[string]cachedTrackList)}
}
func oauthCacheScope(clientID string) string {
identity := "unauthenticated"
if creds, err := loadCreds(); err == nil && creds.RefreshToken != "" {
identity = creds.RefreshToken
}
sum := sha256.Sum256([]byte(clientID + "\x00" + identity))
return fmt.Sprintf("oauth:%x", sum)
}
func loadYTCache(scope string) *ytCache {
data, err := os.ReadFile(ytCachePath())
if err != nil {
+19 -14
View File
@@ -6,28 +6,33 @@ import (
"github.com/bjarneo/cliamp/playlist"
)
func TestYTCacheRejectsDifferentAuthenticationScope(t *testing.T) {
func TestYTCacheRejectsDifferentOAuthAccount(t *testing.T) {
t.Setenv("HOME", t.TempDir())
cache := newYTCache("cookies:chrome")
if err := saveCreds(&storedCreds{RefreshToken: "account-a"}); err != nil {
t.Fatal(err)
}
scopeA := oauthCacheScope("client")
cache := newYTCache(scopeA)
cache.setPlaylists([]playlistEntry{{ID: "private", Name: "Private"}})
cache.setTracks("private", []playlist.Track{{Title: "Secret"}})
saveSnapshot(cache.snapshot())
matching := loadYTCache("cookies:chrome")
matching := loadYTCache(scopeA)
if !matching.playlistsFresh() {
t.Fatal("matching cache scope was not loaded")
}
for _, scope := range []string{"cookies:firefox", "cookies:chrome:Profile 2", "oauth:client-id"} {
t.Run(scope, func(t *testing.T) {
loaded := loadYTCache(scope)
if loaded.playlistsFresh() || len(loaded.Tracks) != 0 {
t.Fatalf("cache for %q reused entries from %q", scope, cache.Scope)
}
if loaded.Scope != scope {
t.Fatalf("cache scope = %q, want %q", loaded.Scope, scope)
}
})
if err := saveCreds(&storedCreds{RefreshToken: "account-b"}); err != nil {
t.Fatal(err)
}
scopeB := oauthCacheScope("client")
loaded := loadYTCache(scopeB)
if loaded.playlistsFresh() || len(loaded.Tracks) != 0 {
t.Fatal("OAuth cache reused entries after the stored account changed")
}
if scopeA == scopeB {
t.Fatal("OAuth cache scope did not change with the refresh token")
}
}
@@ -38,7 +43,7 @@ func TestYTCacheRejectsLegacyUnscopedData(t *testing.T) {
legacy.setPlaylists([]playlistEntry{{ID: "old", Name: "Old"}})
saveSnapshot(legacy.snapshot())
loaded := loadYTCache("cookies:chrome")
loaded := loadYTCache(oauthCacheScope("client"))
if loaded.playlistsFresh() {
t.Fatal("scoped cache reused legacy unscoped playlists")
}
-52
View File
@@ -35,7 +35,6 @@ type cookieBase struct {
mu sync.Mutex
playlists []playlist.PlaylistInfo
trackCache map[string][]playlist.Track
disk *ytCache
}
const cookiePlaylistBatchSize = 100
@@ -48,13 +47,6 @@ func newCookieBase(browser string) *cookieBase {
}
}
func (b *cookieBase) ensureDiskCache() *ytCache {
if b.disk == nil {
b.disk = loadYTCache("cookies:" + b.browser)
}
return b.disk
}
func (b *cookieBase) fetchPlaylists() ([]playlist.PlaylistInfo, error) {
b.mu.Lock()
if b.playlists != nil {
@@ -63,21 +55,6 @@ func (b *cookieBase) fetchPlaylists() ([]playlist.PlaylistInfo, error) {
return res, nil
}
// Try disk cache first
dc := b.ensureDiskCache()
if dc.playlistsFresh() {
var pls []playlist.PlaylistInfo
for _, p := range dc.Playlists {
pls = append(pls, playlist.PlaylistInfo{
ID: p.ID,
Name: p.Name,
TrackCount: p.TrackCount,
})
}
b.playlists = pls
b.mu.Unlock()
return pls, nil
}
b.mu.Unlock()
fn := b.fetchFn
@@ -94,20 +71,7 @@ func (b *cookieBase) fetchPlaylists() ([]playlist.PlaylistInfo, error) {
b.mu.Lock()
b.playlists = pls
dc = b.ensureDiskCache()
var entries []playlistEntry
for _, p := range pls {
entries = append(entries, playlistEntry{
ID: p.ID,
Name: p.Name,
TrackCount: p.TrackCount,
})
}
dc.setPlaylists(entries)
snap := dc.snapshot()
b.mu.Unlock()
saveSnapshot(snap)
return pls, nil
}
@@ -118,12 +82,6 @@ func (b *cookieBase) fetchTracks(target string) ([]playlist.Track, error) {
return cached, nil
}
dc := b.ensureDiskCache()
if tracks, ok := dc.tracksFresh(target); ok {
b.trackCache[target] = tracks
b.mu.Unlock()
return tracks, nil
}
b.mu.Unlock()
resolveBatch := b.resolveFn
@@ -145,12 +103,7 @@ func (b *cookieBase) fetchTracks(target string) ([]playlist.Track, error) {
b.mu.Lock()
b.trackCache[target] = tracks
dc = b.ensureDiskCache()
dc.setTracks(target, tracks)
snap := dc.snapshot()
b.mu.Unlock()
saveSnapshot(snap)
return tracks, nil
}
@@ -158,12 +111,7 @@ func (b *cookieBase) refresh() {
b.mu.Lock()
b.playlists = nil
clear(b.trackCache)
dc := b.ensureDiskCache()
dc.clear()
snap := dc.snapshot()
b.mu.Unlock()
saveSnapshot(snap)
}
// CookieProvider provides YouTube and YouTube Music playlist access using
+5 -6
View File
@@ -215,15 +215,12 @@ func TestCookieProviderTracksCaching(t *testing.T) {
t.Setenv("HOME", t.TempDir())
base := newCookieBase("chrome")
// Pre-populate disk cache with tracks using resolved target URL
dc := base.ensureDiskCache()
mockTracks := []playlist.Track{
{Path: "https://music.youtube.com/watch?v=123", Title: "Song 1", Artist: "Artist 1", DurationSecs: 200},
{Path: "https://music.youtube.com/watch?v=456", Title: "Song 2", Artist: "Artist 2", DurationSecs: 180},
}
musicTarget := formatPlaylistURL("PL123", true)
dc.setTracks(musicTarget, mockTracks)
saveSnapshot(dc.snapshot())
base.trackCache[musicTarget] = mockTracks
prov := &CookieProvider{base: base, kind: KindMusic}
tracks, err := prov.Tracks("PL123")
@@ -242,8 +239,7 @@ func TestCookieProviderTracksCaching(t *testing.T) {
videoTracks := []playlist.Track{
{Path: "https://www.youtube.com/watch?v=789", Title: "Video 1", Artist: "Channel 1", DurationSecs: 300},
}
dc.setTracks(videoTarget, videoTracks)
saveSnapshot(dc.snapshot())
base.trackCache[videoTarget] = videoTracks
videoProv := &CookieProvider{base: base, kind: KindVideo}
vTracks, err := videoProv.Tracks("PL123")
@@ -288,6 +284,9 @@ func TestCookieProviderTracksLoadsInBatches(t *testing.T) {
if !slices.Equal(starts, []int{0, cookiePlaylistBatchSize}) {
t.Fatalf("batch starts = %v, want [0 %d]", starts, cookiePlaylistBatchSize)
}
if _, err := os.Stat(ytCachePath()); !errors.Is(err, os.ErrNotExist) {
t.Fatalf("cookie provider persisted account cache: %v", err)
}
}
func TestCookieProviderSearchTracksHonorsCancellation(t *testing.T) {
+3 -1
View File
@@ -52,7 +52,7 @@ func newBase(session *Session, clientID, clientSecret string, hasCookies bool) *
// ensureDiskCache lazily loads the disk cache. Must be called under mu.
func (b *baseProvider) ensureDiskCache() *ytCache {
if b.disk == nil {
b.disk = loadYTCache("oauth:" + strings.TrimSpace(b.clientID))
b.disk = loadYTCache(oauthCacheScope(strings.TrimSpace(b.clientID)))
}
return b.disk
}
@@ -108,6 +108,8 @@ func (b *baseProvider) initSession(interactive bool) error {
b.mu.Lock()
if b.session == nil {
b.session = sess
// Authentication may have created or rotated the stored refresh token.
b.disk = nil
}
b.mu.Unlock()
return nil
+1 -1
View File
@@ -50,7 +50,7 @@ func TestRefreshInvalidatesAllCaches(t *testing.T) {
t.Errorf("disk Tracks not cleared: %d entries", len(b.disk.Tracks))
}
reloaded := loadYTCache("oauth:client-id")
reloaded := loadYTCache(oauthCacheScope("client-id"))
if reloaded.playlistsFresh() {
t.Error("reloaded disk cache still fresh after refresh")
}