fix(ytmusic): preserve playlist batch boundaries

This commit is contained in:
Bjarne Øverli
2026-08-20 19:04:39 +02:00
parent 724537d3b4
commit 0a1842097f
4 changed files with 62 additions and 15 deletions
+5 -5
View File
@@ -31,7 +31,7 @@ const (
type cookieBase struct {
browser string
fetchFn func(browser string) ([]playlist.PlaylistInfo, error)
resolveFn func(pageURL string, start, count int, browser ...string) ([]playlist.Track, error)
resolveFn func(pageURL string, start, count int, browser ...string) ([]playlist.Track, int, error)
mu sync.Mutex
playlists []playlist.PlaylistInfo
trackCache map[string][]playlist.Track
@@ -86,19 +86,19 @@ func (b *cookieBase) fetchTracks(target string) ([]playlist.Track, error) {
resolveBatch := b.resolveFn
if resolveBatch == nil {
resolveBatch = resolve.ResolveYTDLBatch
resolveBatch = resolve.ResolveYTDLBatchPage
}
var tracks []playlist.Track
for start := 0; ; {
batch, err := resolveBatch(target, start, cookiePlaylistBatchSize, b.browser)
batch, entries, err := resolveBatch(target, start, cookiePlaylistBatchSize, b.browser)
if err != nil {
return nil, fmt.Errorf("ytmusic: resolve playlist tracks: %w", err)
}
tracks = append(tracks, batch...)
if len(batch) < cookiePlaylistBatchSize {
if entries < cookiePlaylistBatchSize {
break
}
start += len(batch)
start += cookiePlaylistBatchSize
}
b.mu.Lock()
+10 -4
View File
@@ -255,7 +255,7 @@ func TestCookieProviderTracksLoadsInBatches(t *testing.T) {
t.Setenv("HOME", t.TempDir())
base := newCookieBase("firefox")
var starts []int
base.resolveFn = func(_ string, start, count int, browser ...string) ([]playlist.Track, error) {
base.resolveFn = func(_ string, start, count int, browser ...string) ([]playlist.Track, int, error) {
starts = append(starts, start)
if count != cookiePlaylistBatchSize {
t.Fatalf("count = %d, want %d", count, cookiePlaylistBatchSize)
@@ -266,20 +266,26 @@ func TestCookieProviderTracksLoadsInBatches(t *testing.T) {
n := cookiePlaylistBatchSize
if start > 0 {
n = 20
} else {
n-- // One malformed source entry was omitted from the parsed tracks.
}
tracks := make([]playlist.Track, n)
for i := range tracks {
tracks[i].Title = fmt.Sprintf("Track %d", start+i)
}
return tracks, nil
entries := n
if start == 0 {
entries = cookiePlaylistBatchSize
}
return tracks, entries, nil
}
tracks, err := (&CookieProvider{base: base, kind: KindMusic}).Tracks("PL123")
if err != nil {
t.Fatalf("Tracks() error: %v", err)
}
if len(tracks) != 120 {
t.Fatalf("tracks = %d, want 120", len(tracks))
if len(tracks) != 119 {
t.Fatalf("tracks = %d, want 119", len(tracks))
}
if !slices.Equal(starts, []int{0, cookiePlaylistBatchSize}) {
t.Fatalf("batch starts = %v, want [0 %d]", starts, cookiePlaylistBatchSize)
+28 -6
View File
@@ -616,6 +616,18 @@ func ResolveYTDLBatch(pageURL string, start, count int, browser ...string) ([]pl
return ResolveYTDLBatchContext(ctx, pageURL, start, count, browser...)
}
// ResolveYTDLBatchPage returns the valid tracks and number of source entries
// emitted by yt-dlp for a playlist range.
func ResolveYTDLBatchPage(pageURL string, start, count int, browser ...string) ([]playlist.Track, int, error) {
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
end := 0
if count > 0 {
end = start + count
}
return resolveYTDLRangePageContext(ctx, pageURL, start, end, browser...)
}
// ResolveYTDLBatchContext is ResolveYTDLBatch with caller-controlled
// cancellation and timeout.
func ResolveYTDLBatchContext(ctx context.Context, pageURL string, start, count int, browser ...string) ([]playlist.Track, error) {
@@ -644,8 +656,13 @@ func resolveYTDLRange(pageURL string, start, end int, browser ...string) ([]play
}
func resolveYTDLRangeContext(ctx context.Context, pageURL string, start, end int, browser ...string) ([]playlist.Track, error) {
tracks, _, err := resolveYTDLRangePageContext(ctx, pageURL, start, end, browser...)
return tracks, err
}
func resolveYTDLRangePageContext(ctx context.Context, pageURL string, start, end int, browser ...string) ([]playlist.Track, int, 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")
return nil, 0, fmt.Errorf("yt-dlp not found in PATH — see https://github.com/yt-dlp/yt-dlp#installation")
}
args := []string{"--flat-playlist", "-j", "--socket-timeout", "15"}
@@ -673,17 +690,21 @@ func resolveYTDLRangeContext(ctx context.Context, pageURL string, start, end int
stdout, err := cmd.Output()
if err != nil {
if ctxErr := ctx.Err(); ctxErr != nil {
return nil, fmt.Errorf("yt-dlp: resolve %s: %w", pageURL, ctxErr)
return nil, 0, fmt.Errorf("yt-dlp: resolve %s: %w", pageURL, ctxErr)
}
msg := strings.TrimSpace(stderr.String())
if msg != "" {
return nil, fmt.Errorf("yt-dlp: %s", msg)
return nil, 0, fmt.Errorf("yt-dlp: %s", msg)
}
return nil, fmt.Errorf("yt-dlp: %w", err)
return nil, 0, fmt.Errorf("yt-dlp: %w", err)
}
return parseYTDLTracks(bytes.NewReader(stdout))
}
func parseYTDLTracks(r io.Reader) ([]playlist.Track, int, error) {
var tracks []playlist.Track
scanner := bufio.NewScanner(bytes.NewReader(stdout))
entries := 0
scanner := bufio.NewScanner(r)
// yt-dlp JSON can exceed bufio.Scanner's default 64KB token limit
// (e.g. videos with very long descriptions).
scanner.Buffer(make([]byte, 0, scannerInitBufSize), scannerMaxLineSize)
@@ -692,6 +713,7 @@ func resolveYTDLRangeContext(ctx context.Context, pageURL string, start, end int
if line == "" {
continue
}
entries++
var e ytdlFlatEntry
if err := json.Unmarshal([]byte(line), &e); err != nil {
continue
@@ -722,7 +744,7 @@ func resolveYTDLRangeContext(ctx context.Context, pageURL string, start, end int
DurationSecs: int(e.Duration),
})
}
return tracks, scanner.Err()
return tracks, entries, scanner.Err()
}
// DownloadYTDL downloads a single track via yt-dlp to the given directory
+19
View File
@@ -311,3 +311,22 @@ func TestResolveYTDLBatchCookieSelection(t *testing.T) {
t.Errorf("did not expect fallback cookies 'firefox' in args, got: %s", string(logged))
}
}
func TestParseYTDLTracksCountsMalformedEntries(t *testing.T) {
input := strings.Join([]string{
`{"webpage_url":"https://example.com/one","title":"One"}`,
`{malformed}`,
`{"title":"Missing URL"}`,
}, "\n")
tracks, entries, err := parseYTDLTracks(strings.NewReader(input))
if err != nil {
t.Fatalf("parseYTDLTracks() error: %v", err)
}
if entries != 3 {
t.Fatalf("source entries = %d, want 3", entries)
}
if len(tracks) != 1 || tracks[0].Title != "One" {
t.Fatalf("tracks = %+v, want one valid track", tracks)
}
}