feat(ytmusic): support cookie-backed zero-oauth playlist browsing (#314)
This commit is contained in:
+13
-10
@@ -427,25 +427,24 @@ func providers() []providerSpec {
|
|||||||
name: "YouTube Music",
|
name: "YouTube Music",
|
||||||
section: "ytmusic",
|
section: "ytmusic",
|
||||||
intro: []string{
|
intro: []string{
|
||||||
"Works out of the box with built-in fallback credentials.",
|
"Browse playlists and liked music with browser cookies (zero OAuth setup),",
|
||||||
"Provide your own OAuth client to skip the shared pool, and/or",
|
"or provide your own Google Cloud OAuth client credentials.",
|
||||||
"a browser name for cookie-based age-gated playback.",
|
|
||||||
},
|
},
|
||||||
picker: &pickerSpec{
|
picker: &pickerSpec{
|
||||||
key: keyYTMusicMode,
|
key: keyYTMusicMode,
|
||||||
label: "Mode",
|
label: "Mode",
|
||||||
options: []pickerOption{
|
options: []pickerOption{
|
||||||
{value: "default", label: "Use built-in credentials (recommended)"},
|
{value: "cookies", label: "Browser cookies (recommended — zero OAuth setup)"},
|
||||||
{value: "custom", label: "Provide my own OAuth credentials / cookies"},
|
{value: "custom", label: "Provide my own OAuth credentials"},
|
||||||
{value: "off", label: "Disable YouTube Music"},
|
{value: "off", label: "Disable YouTube Music"},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
fields: []fieldSpec{
|
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" }},
|
onlyIf: func(v map[string]string) bool { return v[keyYTMusicMode] == "custom" }},
|
||||||
{key: "client_secret", label: "OAuth Client Secret", secret: true,
|
{key: "client_secret", label: "OAuth Client Secret", secret: true, required: 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",
|
|
||||||
onlyIf: func(v map[string]string) bool { return v[keyYTMusicMode] == "custom" }},
|
onlyIf: func(v map[string]string) bool { return v[keyYTMusicMode] == "custom" }},
|
||||||
},
|
},
|
||||||
body: func(v map[string]string) string {
|
body: func(v map[string]string) string {
|
||||||
@@ -465,7 +464,11 @@ func providers() []providerSpec {
|
|||||||
}
|
}
|
||||||
return strings.Join(lines, "\n")
|
return strings.Join(lines, "\n")
|
||||||
default:
|
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
@@ -162,17 +162,18 @@ eq = [0, 0, 0, 0, 0, 0, 0, 0, 0, 0]
|
|||||||
# user_id = "optional-user-id"
|
# user_id = "optional-user-id"
|
||||||
|
|
||||||
# ---
|
# ---
|
||||||
# YouTube Music (optional)
|
# YouTube & YouTube Music (optional)
|
||||||
# Built-in fallback credentials work for most users — no config needed.
|
# Zero OAuth setup: set `cookies_from` to browse your Liked Music, Liked Videos,
|
||||||
# Set your own Google Cloud OAuth client to avoid shared rate limits:
|
# and account playlists using your browser session without Google Cloud setup.
|
||||||
|
# Alternatively, supply your own Google Cloud OAuth client credentials.
|
||||||
#
|
#
|
||||||
# [ytmusic]
|
# [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_id = "your-google-oauth-client-id"
|
||||||
# client_secret = "your-google-oauth-client-secret"
|
# 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).
|
# Resolve full playlists from list= URLs (default true).
|
||||||
# When false, only the single video is resolved and playlist links are stripped.
|
# When false, only the single video is resolved and playlist links are stripped.
|
||||||
# expand_playlist = true
|
# expand_playlist = true
|
||||||
|
|||||||
+11
-8
@@ -140,13 +140,13 @@ func (y YouTubeMusicConfig) IsSetOrFallback(fallbackFn func() (string, string))
|
|||||||
if y.Disabled {
|
if y.Disabled {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
if y.Enabled {
|
if y.Enabled || strings.TrimSpace(y.CookiesFrom) != "" {
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
// Even without a config section, enable if fallback credentials exist.
|
// Even without a config section, enable if fallback credentials exist.
|
||||||
if fallbackFn != nil {
|
if fallbackFn != nil {
|
||||||
id, secret := fallbackFn()
|
id, secret := fallbackFn()
|
||||||
return id != "" && secret != ""
|
return strings.TrimSpace(id) != "" && strings.TrimSpace(secret) != ""
|
||||||
}
|
}
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
@@ -154,11 +154,14 @@ func (y YouTubeMusicConfig) IsSetOrFallback(fallbackFn func() (string, string))
|
|||||||
// ResolveCredentials returns the user's configured credentials, or falls back
|
// 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.
|
// 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) {
|
func (y YouTubeMusicConfig) ResolveCredentials(fallbackFn func() (string, string)) (clientID, clientSecret string) {
|
||||||
if y.ClientID != "" && y.ClientSecret != "" {
|
id := strings.TrimSpace(y.ClientID)
|
||||||
return y.ClientID, y.ClientSecret
|
secret := strings.TrimSpace(y.ClientSecret)
|
||||||
|
if id != "" && secret != "" {
|
||||||
|
return id, secret
|
||||||
}
|
}
|
||||||
if fallbackFn != nil {
|
if fallbackFn != nil {
|
||||||
return fallbackFn()
|
fbID, fbSecret := fallbackFn()
|
||||||
|
return strings.TrimSpace(fbID), strings.TrimSpace(fbSecret)
|
||||||
}
|
}
|
||||||
return "", ""
|
return "", ""
|
||||||
}
|
}
|
||||||
@@ -422,7 +425,7 @@ func Load() (Config, error) {
|
|||||||
case "client_secret":
|
case "client_secret":
|
||||||
cfg.YouTubeMusic.ClientSecret = parseString(val)
|
cfg.YouTubeMusic.ClientSecret = parseString(val)
|
||||||
case "cookies_from":
|
case "cookies_from":
|
||||||
cfg.YouTubeMusic.CookiesFrom = parseString(val)
|
cfg.YouTubeMusic.CookiesFrom = strings.TrimSpace(parseString(val))
|
||||||
case "expand_playlist":
|
case "expand_playlist":
|
||||||
v := strings.ToLower(val) != "false"
|
v := strings.ToLower(val) != "false"
|
||||||
cfg.YouTubeMusic.ExpandPlaylist = &v
|
cfg.YouTubeMusic.ExpandPlaylist = &v
|
||||||
@@ -443,14 +446,14 @@ func Load() (Config, error) {
|
|||||||
case "user":
|
case "user":
|
||||||
cfg.SoundCloud.User = parseString(val)
|
cfg.SoundCloud.User = parseString(val)
|
||||||
case "cookies_from":
|
case "cookies_from":
|
||||||
cfg.SoundCloud.CookiesFrom = parseString(val)
|
cfg.SoundCloud.CookiesFrom = strings.TrimSpace(parseString(val))
|
||||||
}
|
}
|
||||||
case "netease":
|
case "netease":
|
||||||
switch key {
|
switch key {
|
||||||
case "enabled":
|
case "enabled":
|
||||||
cfg.NetEase.Enabled = strings.ToLower(val) == "true"
|
cfg.NetEase.Enabled = strings.ToLower(val) == "true"
|
||||||
case "cookies_from":
|
case "cookies_from":
|
||||||
cfg.NetEase.CookiesFrom = parseString(val)
|
cfg.NetEase.CookiesFrom = strings.TrimSpace(parseString(val))
|
||||||
case "user_id":
|
case "user_id":
|
||||||
cfg.NetEase.UserID = parseString(val)
|
cfg.NetEase.UserID = parseString(val)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -549,6 +549,10 @@ func TestYouTubeMusicIsSetOrFallback(t *testing.T) {
|
|||||||
want bool
|
want bool
|
||||||
}{
|
}{
|
||||||
{"enabled section", YouTubeMusicConfig{Enabled: true}, nil, true},
|
{"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},
|
{"disabled", YouTubeMusicConfig{Disabled: true}, hasFallback, false},
|
||||||
{"fallback available", YouTubeMusicConfig{}, hasFallback, true},
|
{"fallback available", YouTubeMusicConfig{}, hasFallback, true},
|
||||||
{"no fallback", YouTubeMusicConfig{}, noFallback, false},
|
{"no fallback", YouTubeMusicConfig{}, noFallback, false},
|
||||||
@@ -574,6 +578,11 @@ func TestYouTubeMusicResolveCredentials(t *testing.T) {
|
|||||||
wantSecret string
|
wantSecret string
|
||||||
}{
|
}{
|
||||||
{"user credentials take priority", YouTubeMusicConfig{ClientID: "my_id", ClientSecret: "my_secret"}, fallback, "my_id", "my_secret"},
|
{"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"},
|
{"falls back when empty", YouTubeMusicConfig{}, fallback, "fb_id", "fb_secret"},
|
||||||
{"nil fallback returns empty", YouTubeMusicConfig{}, nil, "", ""},
|
{"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) {
|
func TestOverridesApply(t *testing.T) {
|
||||||
cfg := defaultConfig()
|
cfg := defaultConfig()
|
||||||
|
|
||||||
|
|||||||
+39
-16
@@ -6,11 +6,33 @@ Your playlists are automatically classified into two providers:
|
|||||||
- **YouTube Music**: playlists containing music content
|
- **YouTube Music**: playlists containing music content
|
||||||
- **YouTube**: playlists containing non-music content (podcasts, vlogs, tutorials, etc.)
|
- **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
|
## 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
|
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)
|
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")
|
- Name: anything (e.g. "cliamp")
|
||||||
9. Copy the **Client ID** and **Client Secret**
|
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`:
|
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.
|
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.
|
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
|
## Usage
|
||||||
@@ -85,13 +98,14 @@ When focused on the provider panel:
|
|||||||
| `Up` `Down` / `j` `k` | Navigate playlists |
|
| `Up` `Down` / `j` `k` | Navigate playlists |
|
||||||
| `Enter` | Load the selected playlist |
|
| `Enter` | Load the selected playlist |
|
||||||
| `Tab` | Switch between provider and playlist focus |
|
| `Tab` | Switch between provider and playlist focus |
|
||||||
|
| `Ctrl+R` | Refresh playlists from YouTube |
|
||||||
| `Esc` / `b` | Open provider browser |
|
| `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).
|
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
|
||||||
|
|
||||||
Playlists are automatically split between the two providers:
|
When using OAuth authentication, playlists are automatically split between the two providers:
|
||||||
|
|
||||||
**YouTube Music** shows:
|
**YouTube Music** shows:
|
||||||
- **Liked Music**: your liked songs (YouTube Music's special `LM` playlist)
|
- **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)
|
- **Liked Videos**: your liked videos (YouTube's special `LL` playlist)
|
||||||
- Playlists containing non-music content
|
- 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
|
## 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:
|
- **"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.
|
- **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"`.
|
- **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".
|
- **"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.
|
- **"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.
|
- **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.
|
- **Private/deleted videos**: These are automatically skipped when loading a playlist.
|
||||||
|
|
||||||
## Requirements
|
## Requirements
|
||||||
|
|
||||||
- [yt-dlp](https://github.com/yt-dlp/yt-dlp) installed and on your PATH (for audio playback)
|
- [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
|
- No Spotify Premium or other paid subscription required. YouTube Music free tier works
|
||||||
|
|||||||
Vendored
+307
@@ -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
@@ -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))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -148,12 +148,17 @@ func run(overrides config.Overrides, positional []string, daemon bool) error {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
if ytWanted {
|
if ytWanted {
|
||||||
ytClientID, ytClientSecret := cfg.YouTubeMusic.ResolveCredentials(ytmusic.FallbackCredentials)
|
explicitOAuth := strings.TrimSpace(cfg.YouTubeMusic.ClientID) != "" && strings.TrimSpace(cfg.YouTubeMusic.ClientSecret) != ""
|
||||||
if cfg.YouTubeMusic.CookiesFrom != "" {
|
hasCookies := strings.TrimSpace(cfg.YouTubeMusic.CookiesFrom) != ""
|
||||||
|
if hasCookies {
|
||||||
player.SetYTDLCookiesFrom(cfg.YouTubeMusic.CookiesFrom)
|
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 {
|
} else {
|
||||||
if !player.YTDLPAvailable() {
|
if !player.YTDLPAvailable() {
|
||||||
fmt.Fprintf(os.Stderr, "\nYouTube requires yt-dlp for audio playback.\n")
|
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() {
|
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,
|
providers = append(providers,
|
||||||
model.ProviderEntry{Key: "yt", Name: "YouTube (All)", Provider: ytProviders.All},
|
model.ProviderEntry{Key: "yt", Name: "YouTube (All)", Provider: all},
|
||||||
model.ProviderEntry{Key: "youtube", Name: "YouTube", Provider: ytProviders.Video},
|
model.ProviderEntry{Key: "youtube", Name: "YouTube", Provider: video},
|
||||||
model.ProviderEntry{Key: "ytmusic", Name: "YouTube Music", Provider: ytProviders.Music},
|
model.ProviderEntry{Key: "ytmusic", Name: "YouTube Music", Provider: music},
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if spotifyProv != nil {
|
if spotifyProv != nil {
|
||||||
defer spotifyProv.Close()
|
defer spotifyProv.Close()
|
||||||
|
|||||||
+15
-6
@@ -608,12 +608,14 @@ func resolveYouTube(pageURL string) ([]playlist.Track, error) {
|
|||||||
// [start, start+count) from the playlist. Exported for UI incremental loading.
|
// [start, start+count) from the playlist. Exported for UI incremental loading.
|
||||||
// ResolveYTDLBatch fetches tracks starting at offset `start`.
|
// ResolveYTDLBatch fetches tracks starting at offset `start`.
|
||||||
// If count > 0, fetches at most `count` items; if count == 0, fetches all remaining.
|
// 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
|
end := 0
|
||||||
if count > 0 {
|
if count > 0 {
|
||||||
end = start + count
|
end = start + count
|
||||||
}
|
}
|
||||||
return resolveYTDLRange(pageURL, start, end)
|
return resolveYTDLRange(pageURL, start, end, browser...)
|
||||||
}
|
}
|
||||||
|
|
||||||
// resolveYTDL uses yt-dlp --flat-playlist to quickly enumerate tracks.
|
// 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)
|
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 {
|
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")
|
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()
|
defer cancel()
|
||||||
|
|
||||||
args := []string{"--flat-playlist", "-j", "--socket-timeout", "15"}
|
args := []string{"--flat-playlist", "-j", "--socket-timeout", "15"}
|
||||||
if browser := ytdlCookiesFrom(); browser != "" {
|
b := ""
|
||||||
args = append(args, "--cookies-from-browser", browser)
|
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 {
|
if start > 0 {
|
||||||
args = append(args, "--playlist-start", strconv.Itoa(start+1)) // yt-dlp is 1-based
|
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
|
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
|
// yt-dlp JSON can exceed bufio.Scanner's default 64KB token limit
|
||||||
// (e.g. videos with very long descriptions).
|
// (e.g. videos with very long descriptions).
|
||||||
scanner.Buffer(make([]byte, 0, scannerInitBufSize), scannerMaxLineSize)
|
scanner.Buffer(make([]byte, 0, scannerInitBufSize), scannerMaxLineSize)
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import (
|
|||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"runtime"
|
"runtime"
|
||||||
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
@@ -267,3 +268,46 @@ func TestAudioFilesSkipsUnreadableSubdir(t *testing.T) {
|
|||||||
t.Fatalf("non-recursive AudioFiles = %v err=%v, want only a.mp3", files, err)
|
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))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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
@@ -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:#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 & playlists, search with <kbd>Ctrl+F</kbd>. Sign in once via OAuth in your browser.</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 & 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:#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:#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:#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>
|
<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>
|
||||||
|
|||||||
Reference in New Issue
Block a user