feat(ytmusic): support cookie-backed zero-oauth playlist browsing (#314)

This commit is contained in:
Praveen Raj
2026-08-20 21:30:30 +05:30
committed by GitHub
parent 23b3222a62
commit 23685fc568
13 changed files with 983 additions and 57 deletions
+13 -10
View File
@@ -427,25 +427,24 @@ func providers() []providerSpec {
name: "YouTube Music",
section: "ytmusic",
intro: []string{
"Works out of the box with built-in fallback credentials.",
"Provide your own OAuth client to skip the shared pool, and/or",
"a browser name for cookie-based age-gated playback.",
"Browse playlists and liked music with browser cookies (zero OAuth setup),",
"or provide your own Google Cloud OAuth client credentials.",
},
picker: &pickerSpec{
key: keyYTMusicMode,
label: "Mode",
options: []pickerOption{
{value: "default", label: "Use built-in credentials (recommended)"},
{value: "custom", label: "Provide my own OAuth credentials / cookies"},
{value: "cookies", label: "Browser cookies (recommended — zero OAuth setup)"},
{value: "custom", label: "Provide my own OAuth credentials"},
{value: "off", label: "Disable YouTube Music"},
},
},
fields: []fieldSpec{
{key: "client_id", label: "OAuth Client ID",
{key: "cookies_from", label: "Browser for cookies", help: "e.g. chrome, firefox, brave, chromium; blank for chrome",
onlyIf: func(v map[string]string) bool { return v[keyYTMusicMode] == "cookies" || v[keyYTMusicMode] == "" }},
{key: "client_id", label: "OAuth Client ID", required: true,
onlyIf: func(v map[string]string) bool { return v[keyYTMusicMode] == "custom" }},
{key: "client_secret", label: "OAuth Client Secret", secret: true,
onlyIf: func(v map[string]string) bool { return v[keyYTMusicMode] == "custom" }},
{key: "cookies_from", label: "Cookies from browser", help: "e.g. chrome, firefox; blank to skip",
{key: "client_secret", label: "OAuth Client Secret", secret: true, required: true,
onlyIf: func(v map[string]string) bool { return v[keyYTMusicMode] == "custom" }},
},
body: func(v map[string]string) string {
@@ -465,7 +464,11 @@ func providers() []providerSpec {
}
return strings.Join(lines, "\n")
default:
return "enabled = true"
browser := strings.TrimSpace(v["cookies_from"])
if browser == "" {
browser = "chrome"
}
return fmt.Sprintf("enabled = true\ncookies_from = %q", browser)
}
},
},
+7 -6
View File
@@ -162,17 +162,18 @@ eq = [0, 0, 0, 0, 0, 0, 0, 0, 0, 0]
# user_id = "optional-user-id"
# ---
# YouTube Music (optional)
# Built-in fallback credentials work for most users — no config needed.
# Set your own Google Cloud OAuth client to avoid shared rate limits:
# YouTube & YouTube Music (optional)
# Zero OAuth setup: set `cookies_from` to browse your Liked Music, Liked Videos,
# and account playlists using your browser session without Google Cloud setup.
# Alternatively, supply your own Google Cloud OAuth client credentials.
#
# [ytmusic]
# cookies_from = "chrome" # chrome, firefox, brave, edge, safari, opera…
#
# Custom OAuth client (optional, overrides cookie-only mode):
# client_id = "your-google-oauth-client-id"
# client_secret = "your-google-oauth-client-secret"
#
# Browser cookies for age-restricted / private content:
# cookies_from = "chrome"
#
# Resolve full playlists from list= URLs (default true).
# When false, only the single video is resolved and playlist links are stripped.
# expand_playlist = true
+11 -8
View File
@@ -140,13 +140,13 @@ func (y YouTubeMusicConfig) IsSetOrFallback(fallbackFn func() (string, string))
if y.Disabled {
return false
}
if y.Enabled {
if y.Enabled || strings.TrimSpace(y.CookiesFrom) != "" {
return true
}
// Even without a config section, enable if fallback credentials exist.
if fallbackFn != nil {
id, secret := fallbackFn()
return id != "" && secret != ""
return strings.TrimSpace(id) != "" && strings.TrimSpace(secret) != ""
}
return false
}
@@ -154,11 +154,14 @@ func (y YouTubeMusicConfig) IsSetOrFallback(fallbackFn func() (string, string))
// ResolveCredentials returns the user's configured credentials, or falls back
// to the built-in pool. Returns empty strings only when the pool is also empty.
func (y YouTubeMusicConfig) ResolveCredentials(fallbackFn func() (string, string)) (clientID, clientSecret string) {
if y.ClientID != "" && y.ClientSecret != "" {
return y.ClientID, y.ClientSecret
id := strings.TrimSpace(y.ClientID)
secret := strings.TrimSpace(y.ClientSecret)
if id != "" && secret != "" {
return id, secret
}
if fallbackFn != nil {
return fallbackFn()
fbID, fbSecret := fallbackFn()
return strings.TrimSpace(fbID), strings.TrimSpace(fbSecret)
}
return "", ""
}
@@ -422,7 +425,7 @@ func Load() (Config, error) {
case "client_secret":
cfg.YouTubeMusic.ClientSecret = parseString(val)
case "cookies_from":
cfg.YouTubeMusic.CookiesFrom = parseString(val)
cfg.YouTubeMusic.CookiesFrom = strings.TrimSpace(parseString(val))
case "expand_playlist":
v := strings.ToLower(val) != "false"
cfg.YouTubeMusic.ExpandPlaylist = &v
@@ -443,14 +446,14 @@ func Load() (Config, error) {
case "user":
cfg.SoundCloud.User = parseString(val)
case "cookies_from":
cfg.SoundCloud.CookiesFrom = parseString(val)
cfg.SoundCloud.CookiesFrom = strings.TrimSpace(parseString(val))
}
case "netease":
switch key {
case "enabled":
cfg.NetEase.Enabled = strings.ToLower(val) == "true"
case "cookies_from":
cfg.NetEase.CookiesFrom = parseString(val)
cfg.NetEase.CookiesFrom = strings.TrimSpace(parseString(val))
case "user_id":
cfg.NetEase.UserID = parseString(val)
}
+34
View File
@@ -549,6 +549,10 @@ func TestYouTubeMusicIsSetOrFallback(t *testing.T) {
want bool
}{
{"enabled section", YouTubeMusicConfig{Enabled: true}, nil, true},
{"cookies_from set", YouTubeMusicConfig{CookiesFrom: "chrome"}, nil, true},
{"cookies_from whitespace only", YouTubeMusicConfig{CookiesFrom: " "}, nil, false},
{"cookies_from whitespace only with fallback", YouTubeMusicConfig{CookiesFrom: " \t\n"}, hasFallback, true},
{"cookies_from with disabled", YouTubeMusicConfig{Disabled: true, CookiesFrom: "chrome"}, nil, false},
{"disabled", YouTubeMusicConfig{Disabled: true}, hasFallback, false},
{"fallback available", YouTubeMusicConfig{}, hasFallback, true},
{"no fallback", YouTubeMusicConfig{}, noFallback, false},
@@ -574,6 +578,11 @@ func TestYouTubeMusicResolveCredentials(t *testing.T) {
wantSecret string
}{
{"user credentials take priority", YouTubeMusicConfig{ClientID: "my_id", ClientSecret: "my_secret"}, fallback, "my_id", "my_secret"},
{"whitespace credentials fall back", YouTubeMusicConfig{ClientID: " ", ClientSecret: "\t"}, fallback, "fb_id", "fb_secret"},
{"valid configured credentials with whitespace are trimmed", YouTubeMusicConfig{ClientID: " my_id ", ClientSecret: " my_secret \t"}, fallback, "my_id", "my_secret"},
{"incomplete client secret falls back", YouTubeMusicConfig{ClientID: "my_id", ClientSecret: " "}, fallback, "fb_id", "fb_secret"},
{"incomplete client id falls back", YouTubeMusicConfig{ClientID: " ", ClientSecret: "my_secret"}, fallback, "fb_id", "fb_secret"},
{"whitespace in fallback credentials is trimmed", YouTubeMusicConfig{}, func() (string, string) { return " fb_id ", " \tfb_secret\n" }, "fb_id", "fb_secret"},
{"falls back when empty", YouTubeMusicConfig{}, fallback, "fb_id", "fb_secret"},
{"nil fallback returns empty", YouTubeMusicConfig{}, nil, "", ""},
}
@@ -587,6 +596,31 @@ func TestYouTubeMusicResolveCredentials(t *testing.T) {
}
}
func TestLoadYouTubeMusicWhitespaceCookiesFrom(t *testing.T) {
t.Setenv("HOME", t.TempDir())
path := filepath.Join(os.Getenv("HOME"), ".config", "cliamp", "config.toml")
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
t.Fatalf("MkdirAll: %v", err)
}
data := []byte(`
[ytmusic]
cookies_from = " "
`)
if err := os.WriteFile(path, data, 0o644); err != nil {
t.Fatalf("WriteFile: %v", err)
}
cfg, err := Load()
if err != nil {
t.Fatalf("Load: %v", err)
}
if cfg.YouTubeMusic.CookiesFrom != "" {
t.Errorf("YouTubeMusic.CookiesFrom = %q, want empty string", cfg.YouTubeMusic.CookiesFrom)
}
}
func TestOverridesApply(t *testing.T) {
cfg := defaultConfig()
+39 -16
View File
@@ -6,11 +6,33 @@ Your playlists are automatically classified into two providers:
- **YouTube Music**: playlists containing music content
- **YouTube**: playlists containing non-music content (podcasts, vlogs, tutorials, etc.)
> **Quick start:** YouTube Music works out of the box with built-in fallback credentials — just install yt-dlp and select it in the provider browser. Run `cliamp setup` if you want to disable it, supply your own OAuth client, or configure cookie-based age-gated playback. Manual setup steps for the custom path are below.
> **Quick start:**
> - **Cookie-based (zero OAuth):** Set `cookies_from = "your_browser"` in `~/.config/cliamp/config.toml` (or pick a browser via `cliamp setup`). Playlists load via your existing browser session; no OAuth credentials or `ytmusic_credentials.json`.
> - **OAuth-based:** Provide Google Cloud OAuth credentials (below), then press Enter in the provider browser to sign in; credentials are cached at `~/.config/cliamp/ytmusic_credentials.json`.
## Setup
### Creating your client ID
### Option 1: Browser cookies (zero setup)
Add your browser name to `~/.config/cliamp/config.toml` (or run `cliamp setup`):
```toml
[ytmusic]
cookies_from = "chrome"
```
Supported browsers: `chrome`, `firefox`, `brave`, `edge`, `opera`, `safari`, `chromium`.
You can also point at a specific profile or path using yt-dlp's `browser:path` syntax. For example, Zen browser (a Firefox fork) stores its profile outside the default location:
```toml
[ytmusic]
cookies_from = "firefox:~/.config/zen"
```
### Option 2: Custom Google Cloud OAuth client
#### Creating your client ID
1. Go to [console.cloud.google.com](https://console.cloud.google.com/) and log in
2. Create a new project (or select an existing one)
@@ -28,7 +50,7 @@ Your playlists are automatically classified into two providers:
- Name: anything (e.g. "cliamp")
9. Copy the **Client ID** and **Client Secret**
### Configuring cliamp
#### Configuring cliamp with OAuth
Add your client ID and client secret to `~/.config/cliamp/config.toml`:
@@ -56,15 +78,6 @@ expand_playlist = false
When `expand_playlist` is `true` (default), URLs with a `list=` parameter — like auto-generated mixes (RDAMVM, RDMM), album playlists (OLAK), or custom playlists (PL) — are resolved incrementally: the first 20 tracks load instantly so playback starts quickly, while the remaining tracks are fetched in background batches. Set to `false` (or pass `--no-expand-playlist`) to strip the playlist parameter and resolve only the single video.
Supported browsers: `chrome`, `firefox`, `brave`, `edge`, `opera`, `safari`, `chromium`.
You can also point at a specific profile or path using yt-dlp's `browser:path` syntax. For example, Zen browser (a Firefox fork) stores its profile outside the default location:
```toml
[ytmusic]
cookies_from = "firefox:~/.config/zen"
```
Run `cliamp` (or `cliamp --provider ytmusic` / `cliamp --provider youtube`), select a provider, and press Enter to sign in. Credentials are cached at `~/.config/cliamp/ytmusic_credentials.json`. Subsequent launches refresh silently.
## Usage
@@ -85,13 +98,14 @@ When focused on the provider panel:
| `Up` `Down` / `j` `k` | Navigate playlists |
| `Enter` | Load the selected playlist |
| `Tab` | Switch between provider and playlist focus |
| `Ctrl+R` | Refresh playlists from YouTube |
| `Esc` / `b` | Open provider browser |
After loading a playlist you return to the standard playlist view with all the usual controls (seek, volume, EQ, shuffle, repeat, queue, search, lyrics).
## Playlists
Playlists are automatically split between the two providers:
When using OAuth authentication, playlists are automatically split between the two providers:
**YouTube Music** shows:
- **Liked Music**: your liked songs (YouTube Music's special `LM` playlist)
@@ -101,10 +115,19 @@ Playlists are automatically split between the two providers:
- **Liked Videos**: your liked videos (YouTube's special `LL` playlist)
- Playlists containing non-music content
Classification is determined by sampling a video from each playlist and checking its YouTube category. Results are cached at `~/.config/cliamp/ytmusic_classification.json`. Delete this file to reclassify.
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).
## Troubleshooting
- **Linux Keyring / Cookie Decryption (`cannot decrypt v11 cookies: no key found`)**: On Linux desktop environments / window managers (Hyprland, Sway, i3, etc.) where Chromium/Chrome encrypts cookies via GNOME Keyring or KWallet, append the keyring name to `cookies_from`:
```toml
[ytmusic]
cookies_from = "chromium+gnomekeyring" # or "chrome+gnomekeyring", "brave+kwallet"
```
- **"ERR: waiting for audio data: EOF" / playback stops immediately**: yt-dlp couldn't produce a stream. cliamp now surfaces yt-dlp's real message (e.g. "Sign in to confirm you're not a bot") instead of the bare EOF, so read the full error. The common causes:
- **Outdated yt-dlp**: update it (`yt-dlp -U`, or reinstall from the [official repo](https://github.com/yt-dlp/yt-dlp)). Distro and winget builds are frequently stale and break when YouTube changes.
- **Bot detection**: YouTube blocks anonymous requests. Set `cookies_from` (see above) so yt-dlp reuses your logged-in browser session. For Zen browser use `cookies_from = "firefox:~/.config/zen"`.
@@ -112,11 +135,11 @@ Classification is determined by sampling a video from each playlist and checking
- **"OAuth failed"**: Make sure your Google Cloud project has YouTube Data API v3 enabled and your OAuth client type is "Desktop app".
- **"Access blocked"**: While your app is in "Testing" status, only test users you've added can sign in. Add your Google account as a test user in the OAuth consent screen settings.
- **Playlist not showing**: Only playlists in your library are listed. Save/follow a playlist in YouTube Music for it to appear.
- **Re-authenticate**: Delete `~/.config/cliamp/ytmusic_credentials.json` and restart cliamp to trigger a fresh login.
- **Re-authenticate / Reset Cache**: Delete `~/.config/cliamp/ytmusic_credentials.json` (for OAuth) or press `Ctrl+R` in the TUI / remove `~/.config/cliamp/ytmusic_cache.json`.
- **Private/deleted videos**: These are automatically skipped when loading a playlist.
## Requirements
- [yt-dlp](https://github.com/yt-dlp/yt-dlp) installed and on your PATH (for audio playback)
- A Google Cloud project with YouTube Data API v3 enabled
- Either browser cookies (`cookies_from = "browser"`, zero Google Cloud setup required) OR a Google Cloud project with YouTube Data API v3 enabled (OAuth path)
- No Spotify Premium or other paid subscription required. YouTube Music free tier works
+307
View File
@@ -0,0 +1,307 @@
package ytmusic
import (
"context"
"fmt"
"strings"
"sync"
"github.com/bjarneo/cliamp/playlist"
"github.com/bjarneo/cliamp/provider"
"github.com/bjarneo/cliamp/resolve"
)
// Compile-time interface checks.
var (
_ playlist.Provider = (*CookieProvider)(nil)
_ provider.Searcher = (*CookieProvider)(nil)
_ playlist.Refresher = (*CookieProvider)(nil)
_ provider.Closer = (*CookieProvider)(nil)
)
// ProviderKind indicates the flavor of the YouTube provider.
type ProviderKind int
const (
KindMusic ProviderKind = iota
KindVideo
KindAll
)
type cookieBase struct {
browser string
fetchFn func(browser string) ([]playlist.PlaylistInfo, error)
mu sync.Mutex
playlists []playlist.PlaylistInfo
trackCache map[string][]playlist.Track
disk *ytCache
}
func newCookieBase(browser string) *cookieBase {
return &cookieBase{
browser: browser,
fetchFn: resolve.FetchUserPlaylists,
trackCache: make(map[string][]playlist.Track),
}
}
func (b *cookieBase) ensureDiskCache() *ytCache {
if b.disk == nil {
b.disk = loadYTCache()
}
return b.disk
}
func (b *cookieBase) fetchPlaylists() ([]playlist.PlaylistInfo, error) {
b.mu.Lock()
if b.playlists != nil {
res := b.playlists
b.mu.Unlock()
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
if fn == nil {
fn = resolve.FetchUserPlaylists
}
pls, err := fn(b.browser)
if err != nil {
return nil, fmt.Errorf("ytmusic: fetch playlists: %w", err)
}
if pls == nil {
pls = []playlist.PlaylistInfo{}
}
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
}
func (b *cookieBase) fetchTracks(target string) ([]playlist.Track, error) {
b.mu.Lock()
if cached, ok := b.trackCache[target]; ok {
b.mu.Unlock()
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()
tracks, err := resolve.ResolveYTDLBatch(target, 0, 0, b.browser)
if err != nil {
return nil, fmt.Errorf("ytmusic: resolve playlist tracks: %w", err)
}
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
}
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
// browser cookies via yt-dlp, without requiring Google Cloud OAuth credentials.
type CookieProvider struct {
base *cookieBase
kind ProviderKind
}
// CookieProviders holds the YouTube Music, YouTube, and YouTube All cookie-backed providers.
type CookieProviders struct {
Music *CookieProvider
Video *CookieProvider
All *CookieProvider
}
// NewCookieProviders creates cookie-backed YouTube providers for Music, Video, and All.
func NewCookieProviders(browser string) CookieProviders {
browser = strings.TrimSpace(browser)
base := newCookieBase(browser)
return CookieProviders{
Music: &CookieProvider{base: base, kind: KindMusic},
Video: &CookieProvider{base: base, kind: KindVideo},
All: &CookieProvider{base: base, kind: KindAll},
}
}
// NewCookieProvider creates a single cookie-backed YouTube provider of the given kind.
func NewCookieProvider(browser string, kind ProviderKind) *CookieProvider {
browser = strings.TrimSpace(browser)
return &CookieProvider{
base: newCookieBase(browser),
kind: kind,
}
}
// Name returns the display name of this provider.
func (p *CookieProvider) Name() string {
switch p.kind {
case KindMusic:
return "YouTube Music"
case KindVideo:
return "YouTube"
case KindAll:
return "YouTube (All)"
default:
return "YouTube"
}
}
// Playlists returns the available playlists for this provider.
func (p *CookieProvider) Playlists() ([]playlist.PlaylistInfo, error) {
userPls, err := p.base.fetchPlaylists()
if err != nil {
return nil, err
}
// Filter out system playlists from scraped feed to avoid duplicates with pinned entries,
// but preserve their track counts for the pinned rows.
var (
customPls []playlist.PlaylistInfo
lmCount int
llCount int
)
for _, pl := range userPls {
switch pl.ID {
case playlistIDLikedMusic:
lmCount = pl.TrackCount
case playlistIDLikedVideos:
llCount = pl.TrackCount
default:
customPls = append(customPls, pl)
}
}
result := make([]playlist.PlaylistInfo, 0, len(customPls)+2)
switch p.kind {
case KindMusic:
result = append(result, playlist.PlaylistInfo{
ID: playlistIDLikedMusic,
Name: "Liked Music",
TrackCount: lmCount,
})
case KindVideo:
result = append(result, playlist.PlaylistInfo{
ID: playlistIDLikedVideos,
Name: "Liked Videos",
TrackCount: llCount,
})
case KindAll:
result = append(result,
playlist.PlaylistInfo{
ID: playlistIDLikedMusic,
Name: "Liked Music",
TrackCount: lmCount,
},
playlist.PlaylistInfo{
ID: playlistIDLikedVideos,
Name: "Liked Videos",
TrackCount: llCount,
},
)
}
result = append(result, customPls...)
return result, nil
}
func formatPlaylistURL(playlistID string, isMusic bool) string {
if strings.HasPrefix(playlistID, "http://") || strings.HasPrefix(playlistID, "https://") {
return playlistID
}
switch playlistID {
case playlistIDLikedMusic:
return "https://music.youtube.com/playlist?list=LM"
case playlistIDLikedVideos:
return "https://www.youtube.com/playlist?list=LL"
default:
if isMusic {
return "https://music.youtube.com/playlist?list=" + playlistID
}
return "https://www.youtube.com/playlist?list=" + playlistID
}
}
// Tracks resolves tracks in the given playlist via yt-dlp.
func (p *CookieProvider) Tracks(playlistID string) ([]playlist.Track, error) {
if playlistID == "" {
return nil, fmt.Errorf("ytmusic: empty playlist id")
}
target := formatPlaylistURL(playlistID, p.kind == KindMusic)
return p.base.fetchTracks(target)
}
// SearchTracks performs a search query using yt-dlp's ytsearch: protocol.
func (p *CookieProvider) SearchTracks(_ context.Context, query string, limit int) ([]playlist.Track, error) {
q := strings.TrimSpace(query)
if q == "" {
return nil, nil
}
if limit <= 0 {
limit = 10
}
tracks, err := resolve.ResolveYTDLBatch(fmt.Sprintf("ytsearch%d:%s", limit, q), 0, 0, p.base.browser)
if err != nil {
return nil, fmt.Errorf("ytmusic: search tracks: %w", err)
}
return tracks, nil
}
// Refresh clears the cached playlists.
func (p *CookieProvider) Refresh() {
p.base.refresh()
}
// Close releases any held resources.
func (p *CookieProvider) Close() {}
+292
View File
@@ -0,0 +1,292 @@
package ytmusic
import (
"context"
"errors"
"os"
"path/filepath"
"runtime"
"strings"
"testing"
"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")
// 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())
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},
}
dc.setTracks(videoTarget, videoTracks)
saveSnapshot(dc.snapshot())
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 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))
}
}
+25 -8
View File
@@ -148,12 +148,17 @@ func run(overrides config.Overrides, positional []string, daemon bool) error {
}
}
if ytWanted {
ytClientID, ytClientSecret := cfg.YouTubeMusic.ResolveCredentials(ytmusic.FallbackCredentials)
if cfg.YouTubeMusic.CookiesFrom != "" {
explicitOAuth := strings.TrimSpace(cfg.YouTubeMusic.ClientID) != "" && strings.TrimSpace(cfg.YouTubeMusic.ClientSecret) != ""
hasCookies := strings.TrimSpace(cfg.YouTubeMusic.CookiesFrom) != ""
if hasCookies {
player.SetYTDLCookiesFrom(cfg.YouTubeMusic.CookiesFrom)
}
if ytClientID == "" || ytClientSecret == "" {
fmt.Fprintf(os.Stderr, "YouTube: no credentials available (configure client_id/client_secret in config.toml)\n")
ytClientID, ytClientSecret := cfg.YouTubeMusic.ResolveCredentials(ytmusic.FallbackCredentials)
hasFallbackOAuth := !explicitOAuth && ytClientID != "" && ytClientSecret != ""
if !explicitOAuth && !hasCookies && !hasFallbackOAuth {
fmt.Fprintf(os.Stderr, "YouTube: no credentials available (configure client_id/client_secret or cookies_from in config.toml)\n")
} else {
if !player.YTDLPAvailable() {
fmt.Fprintf(os.Stderr, "\nYouTube requires yt-dlp for audio playback.\n")
@@ -169,15 +174,27 @@ func run(overrides config.Overrides, positional []string, daemon bool) error {
}
}
if player.YTDLPAvailable() {
ytProviders = ytmusic.New(nil, ytClientID, ytClientSecret, cfg.YouTubeMusic.CookiesFrom != "")
var all, video, music playlist.Provider
if explicitOAuth {
ytProviders = ytmusic.New(nil, ytClientID, ytClientSecret, hasCookies)
all, video, music = ytProviders.All, ytProviders.Video, ytProviders.Music
} else if hasCookies {
cookieProviders := ytmusic.NewCookieProviders(cfg.YouTubeMusic.CookiesFrom)
all, video, music = cookieProviders.All, cookieProviders.Video, cookieProviders.Music
} else if hasFallbackOAuth {
ytProviders = ytmusic.New(nil, ytClientID, ytClientSecret, false)
all, video, music = ytProviders.All, ytProviders.Video, ytProviders.Music
}
if all != nil {
providers = append(providers,
model.ProviderEntry{Key: "yt", Name: "YouTube (All)", Provider: ytProviders.All},
model.ProviderEntry{Key: "youtube", Name: "YouTube", Provider: ytProviders.Video},
model.ProviderEntry{Key: "ytmusic", Name: "YouTube Music", Provider: ytProviders.Music},
model.ProviderEntry{Key: "yt", Name: "YouTube (All)", Provider: all},
model.ProviderEntry{Key: "youtube", Name: "YouTube", Provider: video},
model.ProviderEntry{Key: "ytmusic", Name: "YouTube Music", Provider: music},
)
}
}
}
}
if spotifyProv != nil {
defer spotifyProv.Close()
+15 -6
View File
@@ -608,12 +608,14 @@ func resolveYouTube(pageURL string) ([]playlist.Track, error) {
// [start, start+count) from the playlist. Exported for UI incremental loading.
// ResolveYTDLBatch fetches tracks starting at offset `start`.
// If count > 0, fetches at most `count` items; if count == 0, fetches all remaining.
func ResolveYTDLBatch(pageURL string, start, count int) ([]playlist.Track, error) {
// If an optional browser is provided, cookies from that browser are used;
// otherwise, the globally configured yt-dlp cookies browser is used.
func ResolveYTDLBatch(pageURL string, start, count int, browser ...string) ([]playlist.Track, error) {
end := 0
if count > 0 {
end = start + count
}
return resolveYTDLRange(pageURL, start, end)
return resolveYTDLRange(pageURL, start, end, browser...)
}
// resolveYTDL uses yt-dlp --flat-playlist to quickly enumerate tracks.
@@ -627,7 +629,7 @@ func resolveYTDL(pageURL string, maxItems ...int) ([]playlist.Track, error) {
return resolveYTDLRange(pageURL, 0, end)
}
func resolveYTDLRange(pageURL string, start, end int) ([]playlist.Track, error) {
func resolveYTDLRange(pageURL string, start, end int, browser ...string) ([]playlist.Track, error) {
if _, err := exec.LookPath("yt-dlp"); err != nil {
return nil, fmt.Errorf("yt-dlp not found in PATH — see https://github.com/yt-dlp/yt-dlp#installation")
}
@@ -636,8 +638,15 @@ func resolveYTDLRange(pageURL string, start, end int) ([]playlist.Track, error)
defer cancel()
args := []string{"--flat-playlist", "-j", "--socket-timeout", "15"}
if browser := ytdlCookiesFrom(); browser != "" {
args = append(args, "--cookies-from-browser", browser)
b := ""
if len(browser) > 0 {
b = strings.TrimSpace(browser[0])
}
if b == "" {
b = ytdlCookiesFrom()
}
if b != "" {
args = append(args, "--cookies-from-browser", b)
}
if start > 0 {
args = append(args, "--playlist-start", strconv.Itoa(start+1)) // yt-dlp is 1-based
@@ -662,7 +671,7 @@ func resolveYTDLRange(pageURL string, start, end int) ([]playlist.Track, error)
}
var tracks []playlist.Track
scanner := bufio.NewScanner(strings.NewReader(string(stdout)))
scanner := bufio.NewScanner(bytes.NewReader(stdout))
// yt-dlp JSON can exceed bufio.Scanner's default 64KB token limit
// (e.g. videos with very long descriptions).
scanner.Buffer(make([]byte, 0, scannerInitBufSize), scannerMaxLineSize)
+44
View File
@@ -8,6 +8,7 @@ import (
"os"
"path/filepath"
"runtime"
"strings"
"testing"
"time"
)
@@ -267,3 +268,46 @@ func TestAudioFilesSkipsUnreadableSubdir(t *testing.T) {
t.Fatalf("non-recursive AudioFiles = %v err=%v, want only a.mp3", files, err)
}
}
func TestResolveYTDLBatchCookieSelection(t *testing.T) {
if runtime.GOOS == "windows" {
t.Skip("skipping Unix shell script test on Windows")
}
t.Cleanup(func() { 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"))
// 1. Fallback to global cookies when explicit browser is empty
SetYTDLCookiesFrom("firefox")
_, _ = ResolveYTDLBatch("https://example.com/playlist", 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' in args, got: %s", string(logged))
}
// 2. Explicit browser overrides global cookies
_, _ = ResolveYTDLBatch("https://example.com/playlist", 0, 0, "chrome")
logged, err = os.ReadFile(logFile)
if err != nil {
t.Fatal(err)
}
if !strings.Contains(string(logged), "--cookies-from-browser chrome") {
t.Errorf("expected explicit browser 'chrome' in args, got: %s", string(logged))
}
if strings.Contains(string(logged), "--cookies-from-browser firefox") {
t.Errorf("did not expect fallback cookies 'firefox' in args, got: %s", string(logged))
}
}
+134
View File
@@ -0,0 +1,134 @@
package resolve
import (
"bufio"
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"net/url"
"os/exec"
"strings"
"time"
"github.com/bjarneo/cliamp/playlist"
)
// ytdlPlaylistFeedEntry holds JSON fields for a single playlist item returned
// by yt-dlp --flat-playlist on a feed/playlist URL.
type ytdlPlaylistFeedEntry struct {
ID string `json:"id"`
URL string `json:"url"`
WebpageURL string `json:"webpage_url"`
Title string `json:"title"`
Type string `json:"_type"`
}
// parseYTDLPlaylistFeed parses newline-delimited JSON output from yt-dlp into
// a slice of playlist.PlaylistInfo entries.
func parseYTDLPlaylistFeed(r io.Reader) ([]playlist.PlaylistInfo, error) {
var playlists []playlist.PlaylistInfo
seen := make(map[string]bool)
scanner := bufio.NewScanner(r)
scanner.Buffer(make([]byte, 0, scannerInitBufSize), scannerMaxLineSize)
for scanner.Scan() {
line := bytes.TrimSpace(scanner.Bytes())
if len(line) == 0 {
continue
}
var entry ytdlPlaylistFeedEntry
if err := json.Unmarshal(line, &entry); err != nil {
continue
}
id := strings.TrimSpace(entry.ID)
if id == "" {
for _, uStr := range []string{entry.WebpageURL, entry.URL} {
if uStr == "" {
continue
}
if u, err := url.Parse(uStr); err == nil && u.Query().Get("list") != "" {
id = u.Query().Get("list")
break
}
if !strings.HasPrefix(uStr, "http://") && !strings.HasPrefix(uStr, "https://") {
id = uStr
break
}
}
}
// YouTube feed responses often prepend "VL" ("View List") to playlist IDs (e.g. VLPL..., VLLM, VLLL).
if strings.HasPrefix(id, "VL") && len(id) > 2 {
id = strings.TrimPrefix(id, "VL")
}
if id == "" || seen[id] {
continue
}
seen[id] = true
title := strings.TrimSpace(entry.Title)
if title == "" {
title = id
}
playlists = append(playlists, playlist.PlaylistInfo{
ID: id,
Name: title,
})
}
if err := scanner.Err(); err != nil {
return nil, fmt.Errorf("parse playlist feed: %w", err)
}
return playlists, nil
}
// FetchUserPlaylists invokes yt-dlp to scrape user playlists from
// https://www.youtube.com/feed/playlists using the specified browser session.
// If browser is empty, the globally configured yt-dlp cookies browser is used.
func FetchUserPlaylists(browser string) ([]playlist.PlaylistInfo, error) {
if _, err := exec.LookPath("yt-dlp"); err != nil {
return nil, fmt.Errorf("yt-dlp not found in PATH — see https://github.com/yt-dlp/yt-dlp#installation")
}
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
args := []string{"--flat-playlist", "-j", "--socket-timeout", "15"}
b := strings.TrimSpace(browser)
if b == "" {
b = ytdlCookiesFrom()
}
if b != "" {
args = append(args, "--cookies-from-browser", b)
}
args = append(args, "https://www.youtube.com/feed/playlists")
cmd := exec.CommandContext(ctx, "yt-dlp", args...)
cmd.WaitDelay = 3 * time.Second
var stderr strings.Builder
cmd.Stderr = &stderr
stdout, err := cmd.Output()
if err != nil {
if ctx.Err() == context.DeadlineExceeded {
return nil, fmt.Errorf("yt-dlp: timed out fetching playlists (30s)")
}
msg := strings.TrimSpace(stderr.String())
if msg != "" {
return nil, fmt.Errorf("yt-dlp: %s", msg)
}
return nil, fmt.Errorf("yt-dlp: %w", err)
}
pls, err := parseYTDLPlaylistFeed(bytes.NewReader(stdout))
if err != nil {
return nil, fmt.Errorf("yt-dlp: %w", err)
}
return pls, nil
}
+59
View File
@@ -0,0 +1,59 @@
package resolve
import (
"strings"
"testing"
)
func TestParseYTDLPlaylistFeed(t *testing.T) {
input := `
{"_type": "url", "id": "PL12345", "title": "My Favorite Tracks", "playlist_count": 25}
{"_type": "url", "id": "VLPL67890", "title": "Chill Vibes", "item_count": 10}
{"_type": "url", "url": "https://www.youtube.com/playlist?list=PLabcde", "title": "Rock Classics", "playlist_count": null, "n_entries": 50}
{"_type": "url", "id": "PL12345", "title": "Duplicate Playlist", "playlist_count": 25}
{"_type": "url", "id": "VLLM", "title": "Liked Music", "playlist_count": 100}
{"_type": "url", "id": "", "title": "No ID"}
invalid json line
`
pls, err := parseYTDLPlaylistFeed(strings.NewReader(input))
if err != nil {
t.Fatalf("parseYTDLPlaylistFeed unexpected error: %v", err)
}
expected := []struct {
id string
name string
count int
}{
{id: "PL12345", name: "My Favorite Tracks", count: 0},
{id: "PL67890", name: "Chill Vibes", count: 0},
{id: "PLabcde", name: "Rock Classics", count: 0},
{id: "LM", name: "Liked Music", count: 0},
}
if len(pls) != len(expected) {
t.Fatalf("got %d playlists, want %d", len(pls), len(expected))
}
for i, exp := range expected {
if pls[i].ID != exp.id {
t.Errorf("[%d] ID = %q, want %q", i, pls[i].ID, exp.id)
}
if pls[i].Name != exp.name {
t.Errorf("[%d] Name = %q, want %q", i, pls[i].Name, exp.name)
}
if pls[i].TrackCount != exp.count {
t.Errorf("[%d] TrackCount = %d, want %d", i, pls[i].TrackCount, exp.count)
}
}
}
func TestParseYTDLPlaylistFeed_Empty(t *testing.T) {
pls, err := parseYTDLPlaylistFeed(strings.NewReader(""))
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if len(pls) != 0 {
t.Fatalf("expected 0 playlists, got %d", len(pls))
}
}
+1 -1
View File
@@ -718,7 +718,7 @@ footer{border-top:1px solid var(--line);background:var(--ink-2);padding:46px 0 5
<div class="source" style="--src-color:#1db954"><div class="source-badge">OAuth · cached</div><div class="source-name">Spotify</div><div class="source-desc">Stream your Premium library. Bring your own developer <code>client_id</code> for a private Web API quota; playback is authorized separately. Search with <kbd>Ctrl+F</kbd>; Development Mode paging is automatic. Currently unavailable on Windows builds.</div></div>
<div class="source" style="--src-color:#c3c9d4"><div class="source-badge">OAuth · lossless</div><div class="source-name">Qobuz</div><div class="source-desc">Opt-in: set <code>[qobuz] enabled = true</code>. Stream lossless FLAC up to 24-bit/192kHz from your subscription. Browse favorites &amp; playlists, search with <kbd>Ctrl+F</kbd>. Sign in once via OAuth in your browser.</div></div>
<div class="source" style="--src-color:#ff0000"><div class="source-badge">yt-dlp</div><div class="source-name">YouTube</div><div class="source-desc">Search videos with <kbd>Ctrl+F</kbd>, then play, append, or queue from a results list.</div></div>
<div class="source" style="--src-color:#ff4444"><div class="source-badge">OAuth · cached</div><div class="source-name">YT Music</div><div class="source-desc">Browse your playlists with auto music/non-music classification. Set <code>cookies_from</code> to play through your browser session and avoid YouTube bot blocks (keep <code>yt-dlp</code> current).</div></div>
<div class="source" style="--src-color:#ff4444"><div class="source-badge">Cookies · OAuth</div><div class="source-name">YT Music</div><div class="source-desc">Browse your playlists and Liked Music directly via browser cookies (zero OAuth setup) or custom Google Cloud credentials. Set <code>cookies_from</code> in your config.</div></div>
<div class="source" style="--src-color:#e5a00d"><div class="source-badge">Media server</div><div class="source-name">Plex</div><div class="source-desc">Browse albums and stream from your Plex Media Server.</div></div>
<div class="source" style="--src-color:#00a4dc"><div class="source-badge">Media server</div><div class="source-name">Jellyfin</div><div class="source-desc">Artists, albums, tracks — buffered gapless playback.</div></div>
<div class="source" style="--src-color:#52b54b"><div class="source-badge">Media server</div><div class="source-name">Emby</div><div class="source-desc">Browse your Emby music library with API key or username/password auth.</div></div>