diff --git a/cmd/rip/main.go b/cmd/rip/main.go index b0a6108..461147a 100644 --- a/cmd/rip/main.go +++ b/cmd/rip/main.go @@ -552,6 +552,7 @@ func main() { os.Exit(1) } defer func() { _ = mainApp.Close() }() + mainApp.IgnoreDB = gopts.noDB title, tracks, err := fetchLastFMPlaylist(ctx, cfg.Session.Downloads.VerifySSL, opts.PlaylistURL) if err != nil { @@ -1422,12 +1423,13 @@ type resolvedLastFMTrack struct { } var ( - lastFMTitleTagsRe = regexp.MustCompile(`]*\btitle=(?:"([^"]+)"|'([^']+)')`) - lastFMTotalTracksRe = regexp.MustCompile(`data-playlisting-entry-count="(\d+)"`) - lastFMPlaylistTitleRe = regexp.MustCompile(`]*class="[^"]*playlisting-playlist-header-title[^"]*"[^>]*>([^<]+)`) - lastFMMirrorTitleRe = regexp.MustCompile(`^Title:\s*(.+?)\s+\|`) - lastFMMirrorLinkTextRe = regexp.MustCompile(`\[([^\]]+)\]\(`) - errLastFMInvalidSource = "unsupported source" + lastFMTitleTagsRe = regexp.MustCompile(`]*\btitle=(?:"([^"]+)"|'([^']+)')`) + lastFMDataTrackArtistRe = regexp.MustCompile(`data-track-name=(?:"([^"]+)"|'([^']+)')[^>]*data-artist-name=(?:"([^"]+)"|'([^']+)')`) + lastFMTotalTracksRe = regexp.MustCompile(`data-playlisting-entry-count="(\d+)"`) + lastFMPlaylistTitleRe = regexp.MustCompile(`]*class="[^"]*playlisting-playlist-header-title[^"]*"[^>]*>([^<]+)`) + lastFMMirrorTitleRe = regexp.MustCompile(`^Title:\s*(.+?)\s+\|`) + lastFMMirrorLinkTextRe = regexp.MustCompile(`\[([^\]]+)\]\(`) + errLastFMInvalidSource = "unsupported source" ) func addURLToQueue(ctx context.Context, mainApp *app.Main, raw string) bool { @@ -1828,11 +1830,30 @@ func extractLastFMPlaylistInfo(page string) (string, int, error) { } func extractLastFMTitleArtistPairs(page string) []lastFMTrack { + dataPairs := lastFMDataTrackArtistRe.FindAllStringSubmatch(page, -1) + if len(dataPairs) > 0 { + out := make([]lastFMTrack, 0, len(dataPairs)) + for _, m := range dataPairs { + title := html.UnescapeString(strings.TrimSpace(firstNonEmpty(m[1], m[2]))) + artist := html.UnescapeString(strings.TrimSpace(firstNonEmpty(m[3], m[4]))) + if title == "" || artist == "" { + continue + } + out = append(out, lastFMTrack{Title: title, Artist: artist}) + } + if len(out) > 0 { + return out + } + } + titles := lastFMTitleTagsRe.FindAllStringSubmatch(page, -1) out := make([]lastFMTrack, 0, len(titles)/2) for i := 0; i+1 < len(titles); i += 2 { titleRaw := strings.TrimSpace(firstNonEmpty(titles[i][1], titles[i][2])) artistRaw := strings.TrimSpace(firstNonEmpty(titles[i+1][1], titles[i+1][2])) + if strings.EqualFold(titleRaw, "Play on YouTube") || strings.EqualFold(artistRaw, "Play on YouTube") { + continue + } title := html.UnescapeString(titleRaw) artist := html.UnescapeString(artistRaw) if title == "" || artist == "" { diff --git a/cmd/rip/main_test.go b/cmd/rip/main_test.go index 2e15266..d19335d 100644 --- a/cmd/rip/main_test.go +++ b/cmd/rip/main_test.go @@ -171,6 +171,25 @@ func TestExtractLastFMTitleArtistPairsSingleQuotes(t *testing.T) { } } +func TestExtractLastFMTitleArtistPairsSkipsPlayOnYouTubeNoise(t *testing.T) { + html := `Play track + + +Play track + +` + pairs := extractLastFMTitleArtistPairs(html) + if len(pairs) != 2 { + t.Fatalf("pairs len = %d, want 2", len(pairs)) + } + if pairs[0].Title != "Won't Forget You" || pairs[0].Artist != "Shouse" { + t.Fatalf("unexpected first pair: %+v", pairs[0]) + } + if pairs[1].Title != "EYES" || pairs[1].Artist != "The Blaze" { + t.Fatalf("unexpected second pair: %+v", pairs[1]) + } +} + func TestParseGlobalArgsNoDBBeforeCommand(t *testing.T) { opts, err := parseGlobalArgs([]string{"-ndb", "url", "https://play.qobuz.com/album/0004228000522"}) if err != nil { diff --git a/internal/download/downloader.go b/internal/download/downloader.go index 06ed1ff..7a548c2 100644 --- a/internal/download/downloader.go +++ b/internal/download/downloader.go @@ -14,6 +14,7 @@ import ( "strconv" "strings" "sync/atomic" + "time" "github.com/vbauerster/mpb/v8" "github.com/vbauerster/mpb/v8/decor" @@ -90,24 +91,40 @@ func (d *Downloader) FileDeezerEncrypted(ctx context.Context, sourceURL, outputP }() var bar *mpb.Bar - if d.ProgressEnabled() && resp.ContentLength > 0 { + if d.ProgressEnabled() { d.barStarted.Store(1) desc := shortenName(filepath.Base(outputPath), 54) - bar = d.progress.AddBar( - resp.ContentLength, - mpb.PrependDecorators( - decor.Name(desc+" ", decor.WC{W: 56, C: decor.DSyncWidth | decor.DindentRight}), - decor.Percentage(decor.WCSyncWidthR), - ), - mpb.AppendDecorators( - decor.CountersKibiByte("% .1f / % .1f", decor.WCSyncWidthR), - decor.Name(" | ", decor.WCSyncWidth), - decor.AverageSpeed(decor.SizeB1024(0), "% .1f", decor.WCSyncWidthR), - decor.Name(" | ETA ", decor.WCSyncWidth), - decor.AverageETA(decor.ET_STYLE_GO, decor.WCSyncWidthR), - ), - mpb.BarRemoveOnComplete(), - ) + if resp.ContentLength > 0 { + bar = d.progress.AddBar( + resp.ContentLength, + mpb.PrependDecorators( + decor.Name(desc+" ", decor.WC{W: 56, C: decor.DSyncWidth | decor.DindentRight}), + decor.Percentage(decor.WCSyncWidthR), + ), + mpb.AppendDecorators( + decor.CountersKibiByte("% .1f / % .1f", decor.WCSyncWidthR), + decor.Name(" | ", decor.WCSyncWidth), + decor.AverageSpeed(decor.SizeB1024(0), "% .1f", decor.WCSyncWidthR), + decor.Name(" | ETA ", decor.WCSyncWidth), + decor.AverageETA(decor.ET_STYLE_GO, decor.WCSyncWidthR), + ), + mpb.BarRemoveOnComplete(), + ) + } else { + bar = d.progress.AddSpinner( + 0, + mpb.PrependDecorators( + decor.Name(desc+" ", decor.WC{W: 56, C: decor.DSyncWidth | decor.DindentRight}), + ), + mpb.AppendDecorators( + decor.CurrentKibiByte("% .1f", decor.WCSyncWidthR), + decor.Name(" | ", decor.WCSyncWidth), + decor.Elapsed(decor.ET_STYLE_GO, decor.WCSyncWidthR), + ), + mpb.BarRemoveOnComplete(), + ) + defer bar.SetTotal(-1, true) + } } block, err := blowfish.NewCipher(deriveDeezerBlowfishKey(trackID)) @@ -193,24 +210,41 @@ func (d *Downloader) file(ctx context.Context, sourceURL, outputPath string, all } }() - if d.ProgressEnabled() && allowProgress && resp.ContentLength > 0 { + if d.ProgressEnabled() && allowProgress { d.barStarted.Store(1) desc := shortenName(filepath.Base(outputPath), 54) - bar := d.progress.AddBar( - resp.ContentLength, - mpb.PrependDecorators( - decor.Name(desc+" ", decor.WC{W: 56, C: decor.DSyncWidth | decor.DindentRight}), - decor.Percentage(decor.WCSyncWidthR), - ), - mpb.AppendDecorators( - decor.CountersKibiByte("% .1f / % .1f", decor.WCSyncWidthR), - decor.Name(" | ", decor.WCSyncWidth), - decor.AverageSpeed(decor.SizeB1024(0), "% .1f", decor.WCSyncWidthR), - decor.Name(" | ETA ", decor.WCSyncWidth), - decor.AverageETA(decor.ET_STYLE_GO, decor.WCSyncWidthR), - ), - mpb.BarRemoveOnComplete(), - ) + var bar *mpb.Bar + if resp.ContentLength > 0 { + bar = d.progress.AddBar( + resp.ContentLength, + mpb.PrependDecorators( + decor.Name(desc+" ", decor.WC{W: 56, C: decor.DSyncWidth | decor.DindentRight}), + decor.Percentage(decor.WCSyncWidthR), + ), + mpb.AppendDecorators( + decor.CountersKibiByte("% .1f / % .1f", decor.WCSyncWidthR), + decor.Name(" | ", decor.WCSyncWidth), + decor.AverageSpeed(decor.SizeB1024(0), "% .1f", decor.WCSyncWidthR), + decor.Name(" | ETA ", decor.WCSyncWidth), + decor.AverageETA(decor.ET_STYLE_GO, decor.WCSyncWidthR), + ), + mpb.BarRemoveOnComplete(), + ) + } else { + bar = d.progress.AddSpinner( + 0, + mpb.PrependDecorators( + decor.Name(desc+" ", decor.WC{W: 56, C: decor.DSyncWidth | decor.DindentRight}), + ), + mpb.AppendDecorators( + decor.CurrentKibiByte("% .1f", decor.WCSyncWidthR), + decor.Name(" | ", decor.WCSyncWidth), + decor.Elapsed(decor.ET_STYLE_GO, decor.WCSyncWidthR), + ), + mpb.BarRemoveOnComplete(), + ) + defer bar.SetTotal(-1, true) + } buf := make([]byte, 256*1024) totalWritten := int64(0) for { @@ -286,6 +320,41 @@ func shortenName(name string, max int) string { } func (d *Downloader) streamManifestWithFFmpeg(ctx context.Context, sourceURL, outputPath string, includeVideo bool) error { + stopSpinner := func() {} + if d.ProgressEnabled() { + d.barStarted.Store(1) + desc := shortenName(filepath.Base(outputPath), 54) + spin := d.progress.AddSpinner( + 0, + mpb.PrependDecorators( + decor.Name(desc+" ", decor.WC{W: 56, C: decor.DSyncWidth | decor.DindentRight}), + ), + mpb.AppendDecorators( + decor.Elapsed(decor.ET_STYLE_GO, decor.WCSyncWidthR), + decor.Name(" | ffmpeg stream", decor.WCSyncWidth), + ), + mpb.BarRemoveOnComplete(), + ) + done := make(chan struct{}) + go func() { + ticker := time.NewTicker(120 * time.Millisecond) + defer ticker.Stop() + for { + select { + case <-ticker.C: + spin.IncrBy(1) + case <-done: + return + } + } + }() + stopSpinner = func() { + close(done) + spin.SetTotal(-1, true) + } + } + defer stopSpinner() + if _, err := exec.LookPath("ffmpeg"); err != nil { return fmt.Errorf("ffmpeg not found for manifest stream: %w", err) }