From 019a34d2fa338657da1924e343378ab07a93fa74 Mon Sep 17 00:00:00 2001 From: Ivyson Date: Mon, 27 Jul 2026 00:04:10 +0200 Subject: [PATCH 1/3] feat: Progressive playlist loading for youtube music 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 f6fcfcabe..343395fc2 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 108ae9391..4fa592e1d 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 5e16d7ae5..e88762476 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 0dfef8d36..aac5435a9 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 be67671e2..9663d164a 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 ea50d3118..5eaa584de 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 54597fc93..c66934dd2 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 dfb9fd575..603a29a54 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 d9b48d12c..90d89fdd7 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 From d7009a0a71af63253928e4ddef8a09217cbeee9a Mon Sep 17 00:00:00 2001 From: Ivyson Date: Sun, 2 Aug 2026 17:33:26 +0200 Subject: [PATCH 2/3] added the test suite for the player vs audio race condition --- commands.go | 8 +-- player/gapless.go | 14 +++-- player/gapless_test.go | 115 +++++++++++++++++++++++++++++++++++++++++ player/player.go | 49 ++++++++++++------ 4 files changed, 162 insertions(+), 24 deletions(-) create mode 100644 player/gapless_test.go diff --git a/commands.go b/commands.go index fa56493bf..24c1575ed 100644 --- a/commands.go +++ b/commands.go @@ -51,11 +51,11 @@ func buildApp() *cli.Command { } return &cli.Command{ - Name: "cliamp", - Usage: "retro terminal music player", - Version: version, + Name: "cliamp", + Usage: "retro terminal music player", + Version: version, EnableShellCompletion: true, - Flags: rootFlags, + Flags: rootFlags, Action: func(ctx context.Context, c *cli.Command) error { if strings.EqualFold(c.String("audio-device"), "list") { return listAudioDevices() diff --git a/player/gapless.go b/player/gapless.go index de81152fc..d3dbf7ef4 100644 --- a/player/gapless.go +++ b/player/gapless.go @@ -11,14 +11,14 @@ import ( // transitions. It sits at the bottom of the audio pipeline and manages // track sources while the EQ/volume/tap/ctrl chain above it lives forever. // -// It always returns (len(samples), true) — it never stops the speaker. +// It always returns (len(samples), true) -- it never stops the speaker. // When no audio is available, it fills silence. type gaplessStreamer struct { mu sync.Mutex current beep.Streamer // active track (decoded + resampled) next beep.Streamer // preloaded next track drained atomic.Bool // true when current exhausts with no next - onSwap func() // called (in goroutine) on gapless transition + onSwap func() // called on gapless transition (under the speaker lock) } // Stream reads samples from the current track. On exhaustion, it seamlessly @@ -52,9 +52,15 @@ func (g *gaplessStreamer) Stream(samples [][2]float64) (int, bool) { filled, _ := next.Stream(samples[n:]) n += filled } - // Notify about the transition (non-blocking) + // Commit the transition bookkeeping synchronously. Stream runs on + // the audio thread under the speaker lock, so running swapFn here + // (rather than on a detached goroutine) makes the player-side swap + // of current ← next atomic from the UI thread's perspective: any + // concurrent playPipeline/preloadPipeline/Stop/Seek is blocked on + // the speaker lock until this returns. swapFn must not block — the + // player defers the pipeline close itself. if swapFn != nil { - go swapFn() + swapFn() } g.drained.Store(false) } else { diff --git a/player/gapless_test.go b/player/gapless_test.go new file mode 100644 index 000000000..be7f2d68e --- /dev/null +++ b/player/gapless_test.go @@ -0,0 +1,115 @@ +package player + +// Regression test for the gapless-swap race (issue 1.1). +// +// The bug: gaplessStreamer.Stream used to fire the onSwap callback on a +// detached goroutine (`go swapFn()`). The detached swap ran with no +// coordination against the UI thread, which could land a new track via +// playPipeline/preloadPipeline in the window between the audio thread +// promoting the next stream and the detached swap committing p.current. The +// late swap then clobbered p.current and wrongly closed the freshly-selected +// track. +// +// This test forces exactly that interleaving: track A drains (transition +// fires, swap scheduled), then a new track C is installed before the swap runs. +// If the swap ever runs asynchronously again, it commits after the UI thread's +// change and clobbers p.current — the test fails. With the synchronous swap +// (committed inside Stream, under the speaker lock) the bookkeeping lands +// before the UI thread can act, so nothing is clobbered. +// +// The bookkeeping itself is driven through the real Player.gaplessSwap method; +// the completion channel only tells the test when a (possibly detached) swap +// has finished running, so the assertions see a quiesced player in both cases. + +import ( + "sync/atomic" + "testing" + + "github.com/gopxl/beep/v2" +) + +// gaplessTestStreamer is a beep.StreamSeekCloser that drains immediately +// (drain=true) to trigger a gapless transition, and counts Close() calls so we +// can detect a decoder closed while it should still be in use. +type gaplessTestStreamer struct { + drain bool + closes atomic.Int32 +} + +func (s *gaplessTestStreamer) Stream(samples [][2]float64) (int, bool) { + if s.drain { + return 0, false + } + return len(samples), true +} + +func (s *gaplessTestStreamer) Err() error { return nil } +func (s *gaplessTestStreamer) Len() int { return 0 } +func (s *gaplessTestStreamer) Position() int { return 0 } +func (s *gaplessTestStreamer) Seek(p int) error { return nil } +func (s *gaplessTestStreamer) Close() error { s.closes.Add(1); return nil } + +var _ beep.StreamSeekCloser = (*gaplessTestStreamer)(nil) + +func gaplessTestPipe(s *gaplessTestStreamer) *trackPipeline { + return &trackPipeline{decoder: s, stream: s} +} + +func TestGaplessSwapDoesNotClobberNewTrack(t *testing.T) { + p := newTestPlayer() + p.gapless = &gaplessStreamer{} + + const iterations = 25 + for i := 0; i < iterations; i++ { + p.gaplessAdvance.Store(false) + + a := &gaplessTestStreamer{drain: true} // currently playing, ends immediately + b := &gaplessTestStreamer{} // preloaded next track + c := &gaplessTestStreamer{} // track the UI just selected + + swapDone := make(chan struct{}) + p.gapless.onSwap = func() { + p.gaplessSwap() + close(swapDone) + } + + p.gapless.Replace(a) + p.gapless.SetNext(b) + p.mu.Lock() + p.current = gaplessTestPipe(a) + p.nextPipeline = gaplessTestPipe(b) + p.mu.Unlock() + + // Audio thread: track A ends, gapless promotes B and commits the swap. + p.gapless.Stream(make([][2]float64, 1024)) + + // UI thread (mirrors playPipeline, player.go:179-216): the main + // goroutine runs this without yielding. If the swap is synchronous it + // has already committed and this is a clean supersede; if the swap is + // (re)introduced asynchronously it lands in the gap and clobbers below. + p.mu.Lock() + oldCur, oldNext := p.current, p.nextPipeline + p.current = gaplessTestPipe(c) + p.nextPipeline = nil + p.mu.Unlock() + go closePipelines(oldCur, oldNext) + + // Park until any swap has finished, so the assertions see a quiesced + // player in both the synchronous and asynchronous cases. + <-swapDone + + if p.current == nil || p.current.decoder != c { + got := "" + if p.current != nil { + got = "some other pipeline" + } + t.Fatalf( + "iteration %d: p.current clobbered by late gapless swap: got %s (want the freshly selected track c)", + i, got, + ) + } + if n := c.closes.Load(); n != 0 { + t.Fatalf("iteration %d: freshly-selected track c was closed %d time(s) by the stale swap", i, n) + } + } +} diff --git a/player/player.go b/player/player.go index c09fd8527..b0ae4178a 100644 --- a/player/player.go +++ b/player/player.go @@ -87,29 +87,46 @@ func New(q Quality) (*Player, error) { p.volMin.Store(math.Float64bits(-50)) p.speed.Store(math.Float64bits(1.0)) p.gapless = &gaplessStreamer{} + // gaplessSwap is invoked synchronously by gaplessStreamer.Stream on the + // audio thread (under the speaker lock) when a gapless transition occurs. + p.gapless.onSwap = p.gaplessSwap // Suspend the speaker immediately; the ALSA audio callback goroutine // burns ~2% CPU even on silence. Resume is called on every Play(). _ = speaker.Suspend() p.suspended = true - p.gapless.onSwap = func() { - // Called from audio thread (goroutine) when gapless transition occurs. - // Swap current ← nextPipeline and close the old one. The API metadata - // poller is intentionally not restarted here: gapless advance is for - // finite tracks, while resolver-backed streams (NTS, FIP) are infinite - // live radio that is never preloaded as a gapless next track. - p.mu.Lock() - old := p.current - p.current = p.nextPipeline - p.nextPipeline = nil - p.mu.Unlock() - if old != nil { - old.close() - } - p.gaplessAdvance.Store(true) - } return p, nil } +// gaplessSwap commits the player-side bookkeeping when a gapless transition +// fires. It runs synchronously from gaplessStreamer.Stream, on the audio thread +// under the speaker lock. Running inline rather than on a detached goroutine +// makes the promotion of current ← nextPipeline atomic with respect to the UI +// thread: any concurrent playPipeline/preloadPipeline/Stop/Seek is blocked on +// the speaker lock until the swap commits, so the late bookkeeping can never +// clobber a track the user just selected or close a pipeline the audio thread +// is still streaming. +// +// Only the potentially-blocking pipeline close is deferred to a goroutine so +// the audio thread never stalls on an ffmpeg process Wait. That deferred close +// is safe because after the synchronous swap the retired pipeline is +// unreachable from p.current/p.nextPipeline — any racing playPipeline would +// have already replaced the gapless source, cancelling the transition. +// +// The API metadata poller is intentionally not restarted here: gapless advance +// is for finite tracks, while resolver-backed streams (NTS, FIP) are infinite +// live radio that is never preloaded as a gapless next track. +func (p *Player) gaplessSwap() { + p.mu.Lock() + old := p.current + p.current = p.nextPipeline + p.nextPipeline = nil + p.mu.Unlock() + p.gaplessAdvance.Store(true) + if old != nil { + go old.close() + } +} + // Play opens and starts playing an audio file. On the first call it builds // the long-lived EQ → volume → tap → ctrl chain and starts the speaker. // Subsequent calls swap only the track source via the gapless streamer. From 33b25af0de25fe7c362415843c7be21a96212a63 Mon Sep 17 00:00:00 2001 From: Ivyson Date: Sun, 2 Aug 2026 18:01:08 +0200 Subject: [PATCH 3/3] Added the Support for lyrics on yt music --- docs/lyrics.md | 4 +-- ui/model/lyrics.go | 16 +++++++---- ui/model/lyrics_test.go | 62 +++++++++++++++++++++++++++++++++++++++++ 3 files changed, 74 insertions(+), 8 deletions(-) create mode 100644 ui/model/lyrics_test.go diff --git a/docs/lyrics.md b/docs/lyrics.md index 928303d6e..bb51a6b8e 100644 --- a/docs/lyrics.md +++ b/docs/lyrics.md @@ -4,8 +4,8 @@ Press `y` to show lyrics for the current track. For local files, cliamp uses emb ## Modes -- **Synced lyrics**: for local files and Navidrome tracks, lyrics auto scroll and highlight the active line in time with playback. -- **Scroll mode**: for streams and plain lyrics without timestamps, use `j`/`k` or arrow keys to scroll manually. +- **Synced lyrics**: for local files, Navidrome tracks, and YouTube/yt-dlp tracks with a known duration, lyrics auto scroll and highlight the active line in time with playback. +- **Scroll mode**: for plain lyrics without timestamps, live radio (ICY), and YouTube Live (position is not song-relative), use `j`/`k` or arrow keys to scroll manually. Embedded LRC lyrics keep their timestamps. Embedded plain text lyrics are shown in scroll mode. diff --git a/ui/model/lyrics.go b/ui/model/lyrics.go index 103fcf246..20e45d06b 100644 --- a/ui/model/lyrics.go +++ b/ui/model/lyrics.go @@ -59,17 +59,21 @@ func (m *Model) retryLyrics() tea.Cmd { } // lyricsSyncable reports whether synced lyrics can track the current playback -// position. This is true for local files and Navidrome streams (which have -// accurate position tracking), but false for live radio (ICY — position is -// from stream start, not song start) and yt-dlp pipe streams (position is 0). +// position. This is true for local files, Navidrome streams (which have +// accurate position tracking), and yt-dlp tracks (whose ytdlPipeStreamer +// reports position from decoded PCM frames). It is false for live radio (ICY — +// position is from stream start, not song start) and for live streams with no +// finite duration, where the position doesn't map to song time. func (m *Model) lyricsSyncable() bool { track, idx := m.currentPlaybackTrack() if idx < 0 { return false } - // YouTube/yt-dlp pipe streams report position 0. - if playlist.IsYouTubeURL(track.Path) || playlist.IsYTDL(track.Path) { - return false + // yt-dlp pipe streams track position from decoded frames, so synced lyrics + // can follow them. Exclude streams without a known duration (e.g. YouTube + // Live), where the position is not relative to the song. + if playlist.IsYTDL(track.Path) { + return track.DurationSecs > 0 } // ICY radio streams: position counts from stream connect, not song start. // Provider streams with metadata (e.g. Navidrome) track position correctly. diff --git a/ui/model/lyrics_test.go b/ui/model/lyrics_test.go new file mode 100644 index 000000000..cb555a2e7 --- /dev/null +++ b/ui/model/lyrics_test.go @@ -0,0 +1,62 @@ +package model + +import ( + "testing" + + "github.com/bjarneo/cliamp/playlist" +) + +func TestLyricsSyncable(t *testing.T) { + tests := []struct { + name string + track playlist.Track + want bool + }{ + { + name: "local file", + track: playlist.Track{Title: "Local", Path: "/tmp/a.mp3", DurationSecs: 180}, + want: true, + }, + { + name: "youtube music finite track", + track: playlist.Track{Title: "Song", Path: "https://music.youtube.com/watch?v=abc", Stream: true, DurationSecs: 240}, + want: true, + }, + { + name: "youtube finite track", + track: playlist.Track{Title: "Song", Path: "https://www.youtube.com/watch?v=abc", Stream: true, DurationSecs: 240}, + want: true, + }, + { + name: "yt-dlp track (soundcloud) finite", + track: playlist.Track{Title: "SC", Path: "https://soundcloud.com/x/y", Stream: true, DurationSecs: 120}, + want: true, + }, + { + name: "youtube live (no duration)", + track: playlist.Track{Title: "Live", Path: "https://music.youtube.com/watch?v=live", Stream: true, DurationSecs: 0}, + want: false, + }, + { + name: "icy radio stream without provider metadata", + track: playlist.Track{Title: "Radio", Path: "https://radio.example/stream", Stream: true}, + want: false, + }, + { + name: "navidrome provider stream", + track: playlist.Track{Title: "Nav", Path: "https://nav.example/stream", Stream: true, DurationSecs: 200, ProviderMeta: map[string]string{"navidrome": "id"}}, + want: true, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + p := playlist.New() + p.Replace([]playlist.Track{tt.track}) + p.SetIndex(0) + m := Model{playlist: p} + if got := m.lyricsSyncable(); got != tt.want { + t.Fatalf("lyricsSyncable() = %v, want %v", got, tt.want) + } + }) + } +}