fix(ytmusic): preserve playlist batch boundaries
This commit is contained in:
Vendored
+5
-5
@@ -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
@@ -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
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user