448 lines
13 KiB
Go
448 lines
13 KiB
Go
package ytmusic
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"os"
|
|
"path/filepath"
|
|
"runtime"
|
|
"slices"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/bjarneo/cliamp/playlist"
|
|
"github.com/bjarneo/cliamp/provider"
|
|
"github.com/bjarneo/cliamp/resolve"
|
|
)
|
|
|
|
func TestCookieProviderInterfaces(t *testing.T) {
|
|
provs := NewCookieProviders("chrome")
|
|
var _ playlist.Provider = provs.Music
|
|
var _ playlist.Provider = provs.Video
|
|
var _ playlist.Provider = provs.All
|
|
var _ provider.Searcher = provs.Music
|
|
var _ provider.Searcher = provs.Video
|
|
var _ provider.Searcher = provs.All
|
|
var _ playlist.Refresher = provs.Music
|
|
var _ playlist.Refresher = provs.Video
|
|
var _ playlist.Refresher = provs.All
|
|
var _ provider.Closer = provs.Music
|
|
var _ provider.Closer = provs.Video
|
|
var _ provider.Closer = provs.All
|
|
}
|
|
|
|
func TestCookieProviderNames(t *testing.T) {
|
|
provs := NewCookieProviders("chrome")
|
|
if got := provs.Music.Name(); got != "YouTube Music" {
|
|
t.Errorf("Music.Name() = %q, want %q", got, "YouTube Music")
|
|
}
|
|
if got := provs.Video.Name(); got != "YouTube" {
|
|
t.Errorf("Video.Name() = %q, want %q", got, "YouTube")
|
|
}
|
|
if got := provs.All.Name(); got != "YouTube (All)" {
|
|
t.Errorf("All.Name() = %q, want %q", got, "YouTube (All)")
|
|
}
|
|
}
|
|
|
|
func TestCookieProviderPlaylists(t *testing.T) {
|
|
t.Setenv("HOME", t.TempDir())
|
|
mockPlaylists := []playlist.PlaylistInfo{
|
|
{ID: "LM", Name: "Liked Music", TrackCount: 99},
|
|
{ID: "LL", Name: "Liked Videos", TrackCount: 42},
|
|
{ID: "PL111", Name: "My Playlist 1", TrackCount: 12},
|
|
{ID: "PL222", Name: "My Playlist 2", TrackCount: 34},
|
|
}
|
|
fetchCount := 0
|
|
base := &cookieBase{
|
|
browser: "firefox",
|
|
fetchFn: func(browser string) ([]playlist.PlaylistInfo, error) {
|
|
fetchCount++
|
|
if browser != "firefox" {
|
|
t.Errorf("expected browser firefox, got %q", browser)
|
|
}
|
|
return mockPlaylists, nil
|
|
},
|
|
}
|
|
|
|
musicProv := &CookieProvider{base: base, kind: KindMusic}
|
|
videoProv := &CookieProvider{base: base, kind: KindVideo}
|
|
allProv := &CookieProvider{base: base, kind: KindAll}
|
|
|
|
// 1. Music playlists
|
|
musicPls, err := musicProv.Playlists()
|
|
if err != nil {
|
|
t.Fatalf("musicProv.Playlists() error: %v", err)
|
|
}
|
|
if len(musicPls) != 3 {
|
|
t.Fatalf("musicPls len = %d, want 3", len(musicPls))
|
|
}
|
|
if musicPls[0].ID != "LM" || musicPls[0].Name != "Liked Music" || musicPls[0].TrackCount != 99 {
|
|
t.Errorf("musicPls[0] = %+v, want Liked Music (LM) with TrackCount 99", musicPls[0])
|
|
}
|
|
if musicPls[1].ID != "PL111" || musicPls[2].ID != "PL222" {
|
|
t.Errorf("unexpected user playlists: %+v", musicPls[1:])
|
|
}
|
|
|
|
// 2. Video playlists (should use cached base playlists)
|
|
videoPls, err := videoProv.Playlists()
|
|
if err != nil {
|
|
t.Fatalf("videoProv.Playlists() error: %v", err)
|
|
}
|
|
if len(videoPls) != 3 {
|
|
t.Fatalf("videoPls len = %d, want 3", len(videoPls))
|
|
}
|
|
if videoPls[0].ID != "LL" || videoPls[0].Name != "Liked Videos" || videoPls[0].TrackCount != 42 {
|
|
t.Errorf("videoPls[0] = %+v, want Liked Videos (LL) with TrackCount 42", videoPls[0])
|
|
}
|
|
|
|
// 3. All playlists (should use cached base playlists)
|
|
allPls, err := allProv.Playlists()
|
|
if err != nil {
|
|
t.Fatalf("allProv.Playlists() error: %v", err)
|
|
}
|
|
if len(allPls) != 4 {
|
|
t.Fatalf("allPls len = %d, want 4", len(allPls))
|
|
}
|
|
if allPls[0].ID != "LM" || allPls[0].TrackCount != 99 || allPls[1].ID != "LL" || allPls[1].TrackCount != 42 {
|
|
t.Errorf("allPls pinned = %+v, %+v; want LM (99) and LL (42)", allPls[0], allPls[1])
|
|
}
|
|
|
|
if fetchCount != 1 {
|
|
t.Errorf("fetchCount = %d, want 1 (cache miss only on first call)", fetchCount)
|
|
}
|
|
|
|
// 4. Test Refresh()
|
|
musicProv.Refresh()
|
|
_, _ = musicProv.Playlists()
|
|
if fetchCount != 2 {
|
|
t.Errorf("fetchCount after Refresh() = %d, want 2", fetchCount)
|
|
}
|
|
}
|
|
|
|
func TestCookieProviderPlaylists_NilCaching(t *testing.T) {
|
|
t.Setenv("HOME", t.TempDir())
|
|
fetchCount := 0
|
|
base := &cookieBase{
|
|
browser: "chrome",
|
|
fetchFn: func(browser string) ([]playlist.PlaylistInfo, error) {
|
|
fetchCount++
|
|
return nil, nil
|
|
},
|
|
}
|
|
prov := &CookieProvider{base: base, kind: KindMusic}
|
|
pls1, err := prov.Playlists()
|
|
if err != nil {
|
|
t.Fatalf("first Playlists() unexpected error: %v", err)
|
|
}
|
|
if len(pls1) != 1 { // Only pinned Liked Music
|
|
t.Errorf("len(pls1) = %d, want 1", len(pls1))
|
|
}
|
|
pls2, err := prov.Playlists()
|
|
if err != nil {
|
|
t.Fatalf("second Playlists() unexpected error: %v", err)
|
|
}
|
|
if len(pls2) != 1 {
|
|
t.Errorf("len(pls2) = %d, want 1", len(pls2))
|
|
}
|
|
if fetchCount != 1 {
|
|
t.Errorf("fetchCount = %d, want 1 (nil result should be cached)", fetchCount)
|
|
}
|
|
}
|
|
|
|
func TestCookieProviderPlaylists_Error(t *testing.T) {
|
|
t.Setenv("HOME", t.TempDir())
|
|
base := &cookieBase{
|
|
browser: "chrome",
|
|
fetchFn: func(browser string) ([]playlist.PlaylistInfo, error) {
|
|
return nil, errors.New("yt-dlp failed")
|
|
},
|
|
}
|
|
prov := &CookieProvider{base: base, kind: KindMusic}
|
|
_, err := prov.Playlists()
|
|
if err == nil {
|
|
t.Fatal("expected error, got nil")
|
|
}
|
|
if !strings.Contains(err.Error(), "ytmusic: fetch playlists:") {
|
|
t.Errorf("expected wrapped error containing 'ytmusic: fetch playlists:', got %v", err)
|
|
}
|
|
}
|
|
|
|
func TestFormatPlaylistURL(t *testing.T) {
|
|
tests := []struct {
|
|
id string
|
|
isMusic bool
|
|
want string
|
|
}{
|
|
{"LM", true, "https://music.youtube.com/playlist?list=LM"},
|
|
{"LM", false, "https://music.youtube.com/playlist?list=LM"},
|
|
{"LL", true, "https://www.youtube.com/playlist?list=LL"},
|
|
{"LL", false, "https://www.youtube.com/playlist?list=LL"},
|
|
{"PL12345", true, "https://music.youtube.com/playlist?list=PL12345"},
|
|
{"PL12345", false, "https://www.youtube.com/playlist?list=PL12345"},
|
|
{"https://music.youtube.com/playlist?list=CUSTOM", true, "https://music.youtube.com/playlist?list=CUSTOM"},
|
|
{"http://example.com/stream", false, "http://example.com/stream"},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
got := formatPlaylistURL(tt.id, tt.isMusic)
|
|
if got != tt.want {
|
|
t.Errorf("formatPlaylistURL(%q, %v) = %q, want %q", tt.id, tt.isMusic, got, tt.want)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestCookieProviderSearchTracks_Empty(t *testing.T) {
|
|
prov := NewCookieProvider("chrome", KindMusic)
|
|
tracks, err := prov.SearchTracks(context.Background(), "", 10)
|
|
if err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
if len(tracks) != 0 {
|
|
t.Errorf("expected 0 tracks for empty query, got %d", len(tracks))
|
|
}
|
|
}
|
|
|
|
func TestCookieProviderTracks_EmptyID(t *testing.T) {
|
|
prov := NewCookieProvider("chrome", KindMusic)
|
|
_, err := prov.Tracks("")
|
|
if err == nil {
|
|
t.Fatal("expected error for empty playlist id, got nil")
|
|
}
|
|
}
|
|
|
|
func TestCookieProviderTracksCaching(t *testing.T) {
|
|
t.Setenv("HOME", t.TempDir())
|
|
base := newCookieBase("chrome")
|
|
|
|
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)
|
|
base.trackCache[musicTarget] = mockTracks
|
|
|
|
prov := &CookieProvider{base: base, kind: KindMusic}
|
|
tracks, err := prov.Tracks("PL123")
|
|
if err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
if len(tracks) != 2 {
|
|
t.Fatalf("got %d tracks, want 2", len(tracks))
|
|
}
|
|
if tracks[0].Title != "Song 1" || tracks[1].Artist != "Artist 2" {
|
|
t.Errorf("unexpected tracks from cache: %+v", tracks)
|
|
}
|
|
|
|
// Verify Video provider with the same playlist ID uses distinct cache key
|
|
videoTarget := formatPlaylistURL("PL123", false)
|
|
videoTracks := []playlist.Track{
|
|
{Path: "https://www.youtube.com/watch?v=789", Title: "Video 1", Artist: "Channel 1", DurationSecs: 300},
|
|
}
|
|
base.trackCache[videoTarget] = videoTracks
|
|
|
|
videoProv := &CookieProvider{base: base, kind: KindVideo}
|
|
vTracks, err := videoProv.Tracks("PL123")
|
|
if err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
if len(vTracks) != 1 || vTracks[0].Title != "Video 1" {
|
|
t.Errorf("unexpected video tracks from cache: %+v", vTracks)
|
|
}
|
|
}
|
|
|
|
func TestCookieProviderTracksLoadsInBatches(t *testing.T) {
|
|
t.Setenv("HOME", t.TempDir())
|
|
base := newCookieBase("firefox")
|
|
var starts []int
|
|
base.resolveFn = func(_ context.Context, _ string, start, count int, browser ...string) ([]playlist.Track, int, error) {
|
|
starts = append(starts, start)
|
|
if count != cookiePlaylistBatchSize {
|
|
t.Fatalf("count = %d, want %d", count, cookiePlaylistBatchSize)
|
|
}
|
|
if len(browser) != 1 || browser[0] != "firefox" {
|
|
t.Fatalf("browser = %v, want firefox", browser)
|
|
}
|
|
n := cookiePlaylistBatchSize
|
|
if start > 0 {
|
|
n = 20
|
|
} else {
|
|
n-- // One malformed source entry was omitted from the parsed tracks.
|
|
}
|
|
tracks := make([]playlist.Track, n)
|
|
for i := range tracks {
|
|
tracks[i].Title = fmt.Sprintf("Track %d", start+i)
|
|
}
|
|
entries := n
|
|
if start == 0 {
|
|
entries = cookiePlaylistBatchSize
|
|
}
|
|
return tracks, entries, nil
|
|
}
|
|
|
|
tracks, err := (&CookieProvider{base: base, kind: KindMusic}).Tracks("PL123")
|
|
if err != nil {
|
|
t.Fatalf("Tracks() error: %v", err)
|
|
}
|
|
if len(tracks) != 119 {
|
|
t.Fatalf("tracks = %d, want 119", len(tracks))
|
|
}
|
|
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 TestCookieProviderStopsTrackLoad(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
stop func(*CookieProvider)
|
|
}{
|
|
{name: "refresh", stop: func(p *CookieProvider) { p.Refresh() }},
|
|
{name: "close", stop: func(p *CookieProvider) { p.Close() }},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
base := newCookieBase("firefox")
|
|
started := make(chan struct{})
|
|
base.resolveFn = func(ctx context.Context, _ string, _, _ int, _ ...string) ([]playlist.Track, int, error) {
|
|
close(started)
|
|
<-ctx.Done()
|
|
return nil, 0, ctx.Err()
|
|
}
|
|
prov := &CookieProvider{base: base, kind: KindMusic}
|
|
done := make(chan error, 1)
|
|
go func() {
|
|
_, err := prov.Tracks("PL123")
|
|
done <- err
|
|
}()
|
|
|
|
<-started
|
|
tt.stop(prov)
|
|
select {
|
|
case err := <-done:
|
|
if !errors.Is(err, context.Canceled) {
|
|
t.Fatalf("Tracks() error = %v, want context.Canceled", err)
|
|
}
|
|
case <-time.After(time.Second):
|
|
t.Fatal("track load did not stop")
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestCookieProviderRefreshRejectsStaleResults(t *testing.T) {
|
|
t.Run("playlists", func(t *testing.T) {
|
|
base := newCookieBase("firefox")
|
|
started := make(chan struct{})
|
|
release := make(chan struct{})
|
|
base.fetchFn = func(string) ([]playlist.PlaylistInfo, error) {
|
|
close(started)
|
|
<-release
|
|
return []playlist.PlaylistInfo{{ID: "private"}}, nil
|
|
}
|
|
done := make(chan struct{})
|
|
go func() {
|
|
_, _ = base.fetchPlaylists()
|
|
close(done)
|
|
}()
|
|
|
|
<-started
|
|
base.refresh()
|
|
close(release)
|
|
<-done
|
|
base.mu.Lock()
|
|
defer base.mu.Unlock()
|
|
if base.playlists != nil {
|
|
t.Fatalf("stale playlists cached after refresh: %v", base.playlists)
|
|
}
|
|
})
|
|
|
|
t.Run("tracks", func(t *testing.T) {
|
|
base := newCookieBase("firefox")
|
|
started := make(chan struct{})
|
|
release := make(chan struct{})
|
|
base.resolveFn = func(context.Context, string, int, int, ...string) ([]playlist.Track, int, error) {
|
|
close(started)
|
|
<-release
|
|
return []playlist.Track{{Title: "Private"}}, 1, nil
|
|
}
|
|
done := make(chan struct{})
|
|
go func() {
|
|
_, _ = base.fetchTracks("private")
|
|
close(done)
|
|
}()
|
|
|
|
<-started
|
|
base.refresh()
|
|
close(release)
|
|
<-done
|
|
base.mu.Lock()
|
|
defer base.mu.Unlock()
|
|
if _, ok := base.trackCache["private"]; ok {
|
|
t.Fatal("stale tracks cached after refresh")
|
|
}
|
|
})
|
|
}
|
|
|
|
func TestCookieProviderSearchTracksHonorsCancellation(t *testing.T) {
|
|
if runtime.GOOS == "windows" {
|
|
t.Skip("skipping Unix shell script test on Windows")
|
|
}
|
|
tmpDir := t.TempDir()
|
|
fakeYTDL := filepath.Join(tmpDir, "yt-dlp")
|
|
if err := os.WriteFile(fakeYTDL, []byte("#!/bin/sh\nexec sleep 10\n"), 0o755); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
t.Setenv("PATH", tmpDir+string(os.PathListSeparator)+os.Getenv("PATH"))
|
|
|
|
ctx, cancel := context.WithCancel(context.Background())
|
|
cancel()
|
|
_, err := NewCookieProvider("chrome", KindMusic).SearchTracks(ctx, "query", 10)
|
|
if !errors.Is(err, context.Canceled) {
|
|
t.Fatalf("SearchTracks() error = %v, want context.Canceled", err)
|
|
}
|
|
}
|
|
|
|
func TestNewCookieProvidersDoesNotMutateGlobalCookies(t *testing.T) {
|
|
if runtime.GOOS == "windows" {
|
|
t.Skip("skipping Unix shell script test on Windows")
|
|
}
|
|
t.Cleanup(func() { resolve.SetYTDLCookiesFrom("") })
|
|
|
|
tmpDir := t.TempDir()
|
|
logFile := filepath.Join(tmpDir, "ytdlp_args.log")
|
|
fakeYTDL := filepath.Join(tmpDir, "yt-dlp")
|
|
|
|
script := "#!/bin/sh\necho \"$@\" > \"" + logFile + "\"\n"
|
|
if err := os.WriteFile(fakeYTDL, []byte(script), 0o755); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
t.Setenv("PATH", tmpDir+string(os.PathListSeparator)+os.Getenv("PATH"))
|
|
|
|
// Baseline global cookie configured by another provider (e.g. SoundCloud)
|
|
resolve.SetYTDLCookiesFrom("firefox")
|
|
|
|
// Initializing YouTube Music cookie providers should NOT overwrite global cookies
|
|
_ = NewCookieProviders("chrome")
|
|
_ = NewCookieProvider("chrome", KindMusic)
|
|
|
|
// Caller relying on global cookies (e.g. SoundCloud) should still get firefox
|
|
_, _ = resolve.ResolveYTDLBatch("https://soundcloud.com/user/tracks", 0, 0)
|
|
logged, err := os.ReadFile(logFile)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if !strings.Contains(string(logged), "--cookies-from-browser firefox") {
|
|
t.Errorf("expected global cookies 'firefox' to remain unchanged, got: %s", string(logged))
|
|
}
|
|
if strings.Contains(string(logged), "--cookies-from-browser chrome") {
|
|
t.Errorf("global cookies was corrupted with 'chrome': %s", string(logged))
|
|
}
|
|
}
|