From 53b2240a7bebe2f3ce03b9c3920c06b657c1c651 Mon Sep 17 00:00:00 2001 From: 82Sam <53128739+Ivyson@users.noreply.github.com> Date: Mon, 27 Jul 2026 22:43:31 +0200 Subject: [PATCH] feat: Progressive playlist loading for youtube music (#287) Fetches first 20 tracks from YT music when list=URL is parsed and plays,remaining tracks are fetched in the background and added in batches of 20. Added the --expand-playlist/--no-expand-playlist CLI flags and the expand_playlist key in the configs. This is switched on by default --- commands.go | 10 ++++++ config.toml.example | 16 ++++++++++ config/config.go | 14 +++++--- config/flags.go | 4 +++ docs/configuration.md | 2 ++ docs/youtube-music.md | 9 ++++++ main.go | 4 +++ resolve/resolve.go | 72 +++++++++++++++++++++++++++++++++++++++--- ui/model/ytdl_batch.go | 22 ++++++++++--- 9 files changed, 139 insertions(+), 14 deletions(-) diff --git a/commands.go b/commands.go index f6fcfca..343395f 100644 --- a/commands.go +++ b/commands.go @@ -44,6 +44,8 @@ func buildApp() *cli.Command { &cli.StringFlag{Name: "audio-device", Usage: "audio output device (use 'list' to show)"}, &cli.StringFlag{Name: "playlist", Usage: "load a local TOML playlist by name and start playing"}, &cli.StringFlag{Name: "log-level", Usage: "log level: debug, info, warn, error"}, + &cli.BoolFlag{Name: "expand-playlist", Usage: "expand YouTube Music playlists from list= URLs"}, + &cli.BoolFlag{Name: "no-expand-playlist", Usage: "disable playlist expansion for YouTube Music URLs"}, &cli.BoolFlag{Name: "low-power", Usage: "low-power mode: reduce CPU by lowering UI cadence and disabling visualization"}, &cli.BoolFlag{Name: "daemon", Aliases: []string{"d"}, Usage: "run headless (no TUI), serving IPC for scripts/Waybar"}, } @@ -205,6 +207,14 @@ func overridesFromFlags(c *cli.Command) (config.Overrides, error) { v := c.Bool("low-power") ov.LowPower = &v } + if c.IsSet("expand-playlist") { + v := true + ov.ExpandPlaylist = &v + } + if c.IsSet("no-expand-playlist") { + v := false + ov.ExpandPlaylist = &v + } return ov, nil } diff --git a/config.toml.example b/config.toml.example index 108ae93..4fa592e 100644 --- a/config.toml.example +++ b/config.toml.example @@ -160,6 +160,22 @@ eq = [0, 0, 0, 0, 0, 0, 0, 0, 0, 0] # token = "optional-api-token" # 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: +# +# [ytmusic] +# 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 + # --- # Emby server (optional) # Authenticate either with an API key or with your username/password. diff --git a/config/config.go b/config/config.go index 5e16d7a..e887624 100644 --- a/config/config.go +++ b/config/config.go @@ -126,11 +126,12 @@ func (q QobuzConfig) IsSet() bool { // If no client_id/client_secret are set, built-in fallback credentials are // used automatically (same pattern as Spotify). type YouTubeMusicConfig struct { - Disabled bool // true only when user explicitly sets enabled = false - Enabled bool // true when [ytmusic] section exists (even without credentials) - ClientID string // Google Cloud OAuth2 client ID (overrides built-in fallback) - ClientSecret string // Google Cloud OAuth2 client secret (overrides built-in fallback) - CookiesFrom string // browser name for yt-dlp --cookies-from-browser (e.g. "chrome", "firefox") + Disabled bool // true only when user explicitly sets enabled = false + Enabled bool // true when [ytmusic] section exists (even without credentials) + ClientID string // Google Cloud OAuth2 client ID (overrides built-in fallback) + ClientSecret string // Google Cloud OAuth2 client secret (overrides built-in fallback) + CookiesFrom string // browser name for yt-dlp --cookies-from-browser (e.g. "chrome", "firefox") + ExpandPlaylist *bool // nil = default (true), controls whether list= URLs expand the full playlist } // IsSetOrFallback returns true when YouTube providers should be enabled, @@ -406,6 +407,9 @@ func Load() (Config, error) { cfg.YouTubeMusic.ClientSecret = parseString(val) case "cookies_from": cfg.YouTubeMusic.CookiesFrom = parseString(val) + case "expand_playlist": + v := strings.ToLower(val) != "false" + cfg.YouTubeMusic.ExpandPlaylist = &v } case "plex": switch key { diff --git a/config/flags.go b/config/flags.go index 0dfef8d..aac5435 100644 --- a/config/flags.go +++ b/config/flags.go @@ -20,6 +20,7 @@ type Overrides struct { Playlist *string LogLevel *string LowPower *bool + ExpandPlaylist *bool } // Apply merges non-nil overrides into cfg and clamps the result. @@ -78,5 +79,8 @@ func (o Overrides) Apply(cfg *Config) { if o.LowPower != nil { cfg.LowPower = *o.LowPower } + if o.ExpandPlaylist != nil { + cfg.YouTubeMusic.ExpandPlaylist = o.ExpandPlaylist + } cfg.clamp() } diff --git a/docs/configuration.md b/docs/configuration.md index be67671..9663d16 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -134,6 +134,8 @@ token = "${EMBY_TOKEN}" [ytmusic] client_id = "${YTMUSIC_CLIENT_ID}" client_secret = "${YTMUSIC_CLIENT_SECRET}" +# Optional: resolve full playlists from list= URLs (default true). Set to false to strip playlist params. +# expand_playlist = true ``` Rules: diff --git a/docs/youtube-music.md b/docs/youtube-music.md index ea50d31..5eaa584 100644 --- a/docs/youtube-music.md +++ b/docs/youtube-music.md @@ -47,6 +47,15 @@ client_secret = "your_client_secret_here" cookies_from = "chrome" ``` +Optional: control whether `list=` URLs expand the full playlist or resolve as a single video: + +```toml +[ytmusic] +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: diff --git a/main.go b/main.go index 54597fc..c66934d 100644 --- a/main.go +++ b/main.go @@ -199,6 +199,10 @@ func run(overrides config.Overrides, positional []string, daemon bool) error { positional = []string{prefix + query} } + if cfg.YouTubeMusic.ExpandPlaylist != nil { + resolve.ExpandYTPlaylist = *cfg.YouTubeMusic.ExpandPlaylist + } + resolved, err := resolve.Args(positional) if err != nil { return err diff --git a/resolve/resolve.go b/resolve/resolve.go index dfb9fd5..603a29a 100644 --- a/resolve/resolve.go +++ b/resolve/resolve.go @@ -30,6 +30,11 @@ import ( "github.com/kkdai/youtube/v2" ) +// ExpandYTPlaylist controls whether YouTube (Music) URLs with a list= +// parameter expand the full playlist or resolve as a single video. +// Default true preserves backward compatibility. +var ExpandYTPlaylist = true + // ytdlCookiesFromVal stores the browser name passed to yt-dlp's // --cookies-from-browser flag. atomic.Value allows lock-free reads on the // resolve hot path while permitting late binding from main.go. @@ -141,13 +146,44 @@ func Remote(urls []string) ([]playlist.Track, error) { case playlist.IsYouTubeMusicURL(u): // YouTube Music requires yt-dlp; the native YouTube API client // does not support music.youtube.com playlists. - t, err := resolveYTDL(u) - if err != nil { - return nil, fmt.Errorf("resolving youtube music %s: %w", u, err) + // + // When the URL has a playlist (list=), fetch only the first + // YTDLRadioInitialItems tracks so the UI can start playing + // quickly. The UI's incremental batch loader will fetch the + // remaining tracks in the background. If the playlist fetch + // fails (e.g. auto-generated mix timed out), fall back to + // the single video. + target := u + if !ExpandYTPlaylist { + target = stripPlaylistParam(u) + t, err := resolveYTDL(target) + if err != nil { + return nil, fmt.Errorf("resolving youtube music %s: %w", u, err) + } + tracks = append(tracks, t...) + } else if hasListParam(u) { + t, err := resolveYTDL(target, YTDLRadioInitialItems) + if err != nil { + target = stripPlaylistParam(u) + t, err = resolveYTDL(target) + } + if err != nil { + return nil, fmt.Errorf("resolving youtube music %s: %w", u, err) + } + tracks = append(tracks, t...) + } else { + t, err := resolveYTDL(target) + if err != nil { + return nil, fmt.Errorf("resolving youtube music %s: %w", u, err) + } + tracks = append(tracks, t...) } - tracks = append(tracks, t...) case playlist.IsYouTubeURL(u): - t, err := resolveYouTube(u) + target := u + if !ExpandYTPlaylist { + target = stripPlaylistParam(u) + } + t, err := resolveYouTube(target) if err != nil { return nil, fmt.Errorf("resolving youtube %s: %w", u, err) } @@ -206,6 +242,32 @@ func URL(rawURL string) ([]playlist.Track, error) { return tracks, nil } +// stripPlaylistParam removes the list= query parameter from a URL, returning +// the original URL unchanged if no list parameter is present. Used when +// ExpandYTPlaylist is false to resolve a single video instead of the playlist. +func stripPlaylistParam(rawURL string) string { + u, err := url.Parse(rawURL) + if err != nil { + return rawURL + } + q := u.Query() + if q.Get("list") == "" { + return rawURL + } + q.Del("list") + u.RawQuery = q.Encode() + return u.String() +} + +// hasListParam reports whether the URL contains a non-empty list= query parameter. +func hasListParam(rawURL string) bool { + u, err := url.Parse(rawURL) + if err != nil { + return false + } + return u.Query().Get("list") != "" +} + // sniffFeedURL does a HEAD request and returns true if the Content-Type // indicates an RSS/Atom feed. Used as a fallback when the URL has no // recognizable file extension (e.g. https://feeds.megaphone.fm/GLT1412515089). diff --git a/ui/model/ytdl_batch.go b/ui/model/ytdl_batch.go index d9b48d1..90d89fd 100644 --- a/ui/model/ytdl_batch.go +++ b/ui/model/ytdl_batch.go @@ -22,9 +22,19 @@ func (m *Model) resetYTDLBatch() { m.ytdlBatch.loading = false } -// initYTDLBatch detects a YouTube Radio URL among the given source URLs and -// kicks off incremental batch loading. The offset is derived from the known -// initial fetch size (resolve.YTDLRadioInitialItems) so it stays correct +// isYTMusicHost reports whether the parsed URL belongs to music.youtube.com. +func isYTMusicHost(parsed *url.URL) bool { + host := strings.ToLower(parsed.Hostname()) + host = strings.TrimPrefix(host, "www.") + host = strings.TrimPrefix(host, "m.") + return host == "music.youtube.com" +} + +// initYTDLBatch detects YouTube URLs with a list= parameter among the given +// source URLs and kicks off incremental batch loading. It triggers for any +// YouTube Music URL with a playlist (list=) and for any URL with an RD-prefix +// playlist (YouTube auto-generated Radio/Mix). The offset is derived from the +// known initial fetch size (resolve.YTDLRadioInitialItems) so it stays correct // regardless of how many tracks other URLs contributed to the same load. func (m *Model) initYTDLBatch(urls []string) tea.Cmd { for _, u := range urls { @@ -32,7 +42,11 @@ func (m *Model) initYTDLBatch(urls []string) tea.Cmd { if err != nil { continue } - if strings.HasPrefix(parsed.Query().Get("list"), "RD") { + list := parsed.Query().Get("list") + if list == "" { + continue + } + if isYTMusicHost(parsed) || strings.HasPrefix(list, "RD") { m.ytdlBatch.gen++ m.ytdlBatch.url = u m.ytdlBatch.offset = resolve.YTDLRadioInitialItems