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
This commit is contained in:
82Sam
2026-07-27 22:43:31 +02:00
committed by GitHub
parent adc26e9c6d
commit 53b2240a7b
9 changed files with 139 additions and 14 deletions
+10
View File
@@ -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
}
+16
View File
@@ -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.
+9 -5
View File
@@ -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 {
+4
View File
@@ -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()
}
+2
View File
@@ -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:
+9
View File
@@ -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:
+4
View File
@@ -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
+67 -5
View File
@@ -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).
+18 -4
View File
@@ -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