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:
+10
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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
@@ -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 {
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
@@ -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
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user