fix(ytmusic): isolate cache data by account
This commit is contained in:
@@ -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
|
||||
|
||||
|
||||
Vendored
+11
@@ -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 {
|
||||
|
||||
Vendored
+19
-14
@@ -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")
|
||||
}
|
||||
|
||||
Vendored
-52
@@ -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
@@ -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) {
|
||||
|
||||
Vendored
+3
-1
@@ -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
|
||||
|
||||
Vendored
+1
-1
@@ -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")
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user