mirror of
https://git.sr.ht/~joren/streamrip-go
synced 2026-06-17 15:05:39 +02:00
improve lastfm playlist parity and fallback resolution
Queue resolved last.fm tracks as playlist media to preserve playlist semantics, and add a robust mirror-based parser fallback for last.fm temporary unavailable responses while keeping track order and duplicates.
This commit is contained in:
195
cmd/rip/main.go
195
cmd/rip/main.go
@@ -536,14 +536,35 @@ func main() {
|
||||
}
|
||||
fmt.Printf("lastfm playlist: %s (%d tracks)\n", title, len(tracks))
|
||||
|
||||
if err = queueLastFMTracks(ctx, mainApp, opts, tracks); err != nil {
|
||||
resolvedTracks, err := resolveLastFMTracks(ctx, mainApp, opts, tracks)
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "lastfm resolve error: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
if len(mainApp.Pending) == 0 {
|
||||
if len(resolvedTracks) == 0 {
|
||||
fmt.Println("no lastfm tracks resolved")
|
||||
return
|
||||
}
|
||||
|
||||
playlistGroups := groupLastFMResolvedTracksBySource(resolvedTracks)
|
||||
addedPlaylists := 0
|
||||
for source, ids := range playlistGroups {
|
||||
playlistID := fmt.Sprintf("lastfm:%s:%s", source, strings.ToLower(strings.ReplaceAll(title, " ", "_")))
|
||||
playlistName := title
|
||||
if len(playlistGroups) > 1 {
|
||||
playlistName = fmt.Sprintf("%s (%s)", title, strings.Title(source))
|
||||
}
|
||||
if addErr := mainApp.AddPlaylistByTrackIDs(ctx, source, playlistID, playlistName, ids); addErr != nil {
|
||||
fmt.Printf("playlist queue failed: source=%s err=%v\n", source, addErr)
|
||||
continue
|
||||
}
|
||||
addedPlaylists++
|
||||
fmt.Printf("queued lastfm playlist: %s (%d tracks, %s)\n", playlistName, len(ids), source)
|
||||
}
|
||||
if addedPlaylists == 0 {
|
||||
fmt.Println("no lastfm playlists queued")
|
||||
return
|
||||
}
|
||||
if err = mainApp.Resolve(ctx); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "resolve error: %v\n", err)
|
||||
os.Exit(1)
|
||||
@@ -552,7 +573,7 @@ func main() {
|
||||
fmt.Fprintf(os.Stderr, "rip error: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
fmt.Printf("lastfm rip complete (%d track(s))\n", len(mainApp.Pending))
|
||||
fmt.Printf("lastfm rip complete (%d track(s) across %d playlist(s))\n", len(resolvedTracks), addedPlaylists)
|
||||
case "soundcloud-smoke":
|
||||
if len(os.Args) < 3 {
|
||||
fmt.Println("usage: rip soundcloud-smoke <soundcloud_url>")
|
||||
@@ -1355,10 +1376,18 @@ type lastFMTrack struct {
|
||||
Artist string
|
||||
}
|
||||
|
||||
type resolvedLastFMTrack struct {
|
||||
Source string
|
||||
ID string
|
||||
Query string
|
||||
}
|
||||
|
||||
var (
|
||||
lastFMTitleTagsRe = regexp.MustCompile(`<a\s+href="[^"]+"\s+title="([^"]+)"`)
|
||||
lastFMTotalTracksRe = regexp.MustCompile(`data-playlisting-entry-count="(\d+)"`)
|
||||
lastFMPlaylistTitleRe = regexp.MustCompile(`<h1 class="playlisting-playlist-header-title">([^<]+)</h1>`)
|
||||
lastFMMirrorTitleRe = regexp.MustCompile(`^Title:\s*(.+?)\s+\|`)
|
||||
lastFMMirrorLinkTextRe = regexp.MustCompile(`\[([^\]]+)\]\(`)
|
||||
errLastFMInvalidSource = "unsupported source"
|
||||
)
|
||||
|
||||
@@ -1590,11 +1619,11 @@ func fetchLastFMPlaylist(ctx context.Context, verifySSL bool, playlistURL string
|
||||
|
||||
page1, err := fetchLastFMPlaylistPage(ctx, client, parsed, 1)
|
||||
if err != nil {
|
||||
return "", nil, err
|
||||
return fetchLastFMPlaylistViaMirror(ctx, verifySSL, playlistURL)
|
||||
}
|
||||
title, total, err := extractLastFMPlaylistInfo(page1)
|
||||
if err != nil {
|
||||
return "", nil, err
|
||||
return fetchLastFMPlaylistViaMirror(ctx, verifySSL, playlistURL)
|
||||
}
|
||||
tracks := extractLastFMTitleArtistPairs(page1)
|
||||
if total <= len(tracks) || total <= 50 {
|
||||
@@ -1622,6 +1651,86 @@ func fetchLastFMPlaylist(ctx context.Context, verifySSL bool, playlistURL string
|
||||
return title, tracks, nil
|
||||
}
|
||||
|
||||
func fetchLastFMPlaylistViaMirror(ctx context.Context, verifySSL bool, playlistURL string) (string, []lastFMTrack, error) {
|
||||
client := netutil.NewHTTPClient(30*time.Second, verifySSL)
|
||||
all := make([]lastFMTrack, 0, 200)
|
||||
seen := map[string]struct{}{}
|
||||
title := ""
|
||||
|
||||
for page := 1; page <= 50; page++ {
|
||||
body, err := fetchLastFMPlaylistMirrorPage(ctx, client, playlistURL, page)
|
||||
if err != nil {
|
||||
if page == 1 {
|
||||
return "", nil, err
|
||||
}
|
||||
break
|
||||
}
|
||||
pageTitle, tracks := extractLastFMTracksFromMirrorMarkdown(body)
|
||||
if title == "" && strings.TrimSpace(pageTitle) != "" {
|
||||
title = pageTitle
|
||||
}
|
||||
if len(tracks) == 0 {
|
||||
break
|
||||
}
|
||||
newOnPage := 0
|
||||
for _, tr := range tracks {
|
||||
key := strings.ToLower(strings.TrimSpace(tr.Title + "\x00" + tr.Artist))
|
||||
if _, dup := seen[key]; dup {
|
||||
continue
|
||||
}
|
||||
seen[key] = struct{}{}
|
||||
all = append(all, tr)
|
||||
newOnPage++
|
||||
}
|
||||
if newOnPage == 0 || !strings.Contains(body, "Show more") {
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if len(all) == 0 {
|
||||
return "", nil, fmt.Errorf("could not parse playlist tracks from last.fm")
|
||||
}
|
||||
if strings.TrimSpace(title) == "" {
|
||||
title = "Last.fm Playlist"
|
||||
}
|
||||
return title, all, nil
|
||||
}
|
||||
|
||||
func fetchLastFMPlaylistMirrorPage(ctx context.Context, client *http.Client, playlistURL string, page int) (string, error) {
|
||||
u, err := url.Parse(playlistURL)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if page > 1 {
|
||||
q := u.Query()
|
||||
q.Set("page", strconv.Itoa(page))
|
||||
u.RawQuery = q.Encode()
|
||||
}
|
||||
raw := u.String()
|
||||
raw = strings.TrimPrefix(raw, "https://")
|
||||
raw = strings.TrimPrefix(raw, "http://")
|
||||
mirrorURL := "https://r.jina.ai/http://" + raw
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, mirrorURL, nil)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
req.Header.Set("User-Agent", "streamrip-go/0")
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||
return "", fmt.Errorf("lastfm mirror request failed: status %d", resp.StatusCode)
|
||||
}
|
||||
b, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return string(b), nil
|
||||
}
|
||||
|
||||
func fetchLastFMPlaylistPage(ctx context.Context, client *http.Client, parsed *url.URL, page int) (string, error) {
|
||||
u := *parsed
|
||||
if page > 1 {
|
||||
@@ -1680,21 +1789,70 @@ func extractLastFMTitleArtistPairs(page string) []lastFMTrack {
|
||||
return out
|
||||
}
|
||||
|
||||
func queueLastFMTracks(ctx context.Context, mainApp *app.Main, opts lastFMOptions, tracks []lastFMTrack) error {
|
||||
func extractLastFMTracksFromMirrorMarkdown(md string) (string, []lastFMTrack) {
|
||||
lines := strings.Split(strings.ReplaceAll(md, "\r\n", "\n"), "\n")
|
||||
title := ""
|
||||
tracks := make([]lastFMTrack, 0, 100)
|
||||
for _, line := range lines {
|
||||
line = strings.TrimSpace(line)
|
||||
if title == "" {
|
||||
if m := lastFMMirrorTitleRe.FindStringSubmatch(line); len(m) >= 2 {
|
||||
title = strings.TrimSpace(html.UnescapeString(m[1]))
|
||||
}
|
||||
}
|
||||
if !strings.HasPrefix(line, "|") || !strings.Contains(line, "Play track") {
|
||||
continue
|
||||
}
|
||||
cols := splitMarkdownTableRow(line)
|
||||
if len(cols) < 6 {
|
||||
continue
|
||||
}
|
||||
trackName := markdownLinkText(cols[3])
|
||||
artist := markdownLinkText(cols[4])
|
||||
if strings.TrimSpace(trackName) == "" || strings.TrimSpace(artist) == "" {
|
||||
continue
|
||||
}
|
||||
tracks = append(tracks, lastFMTrack{Title: html.UnescapeString(strings.TrimSpace(trackName)), Artist: html.UnescapeString(strings.TrimSpace(artist))})
|
||||
}
|
||||
return title, tracks
|
||||
}
|
||||
|
||||
func splitMarkdownTableRow(line string) []string {
|
||||
trimmed := strings.TrimSpace(line)
|
||||
trimmed = strings.TrimPrefix(trimmed, "|")
|
||||
trimmed = strings.TrimSuffix(trimmed, "|")
|
||||
parts := strings.Split(trimmed, "|")
|
||||
out := make([]string, 0, len(parts))
|
||||
for _, p := range parts {
|
||||
out = append(out, strings.TrimSpace(p))
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func markdownLinkText(cell string) string {
|
||||
m := lastFMMirrorLinkTextRe.FindStringSubmatch(cell)
|
||||
if len(m) >= 2 {
|
||||
return m[1]
|
||||
}
|
||||
return strings.TrimSpace(cell)
|
||||
}
|
||||
|
||||
func resolveLastFMTracks(ctx context.Context, mainApp *app.Main, opts lastFMOptions, tracks []lastFMTrack) ([]resolvedLastFMTrack, error) {
|
||||
primary, err := mainApp.GetLoggedInProvider(ctx, opts.Source)
|
||||
if err != nil {
|
||||
return fmt.Errorf("%s login error: %w", opts.Source, err)
|
||||
return nil, fmt.Errorf("%s login error: %w", opts.Source, err)
|
||||
}
|
||||
var fallback provider.Client
|
||||
if opts.FallbackSource != "" && opts.FallbackSource != opts.Source {
|
||||
fallback, err = mainApp.GetLoggedInProvider(ctx, opts.FallbackSource)
|
||||
if err != nil {
|
||||
return fmt.Errorf("%s login error: %w", opts.FallbackSource, err)
|
||||
return nil, fmt.Errorf("%s login error: %w", opts.FallbackSource, err)
|
||||
}
|
||||
}
|
||||
|
||||
found := 0
|
||||
failed := 0
|
||||
resolved := make([]resolvedLastFMTrack, 0, len(tracks))
|
||||
for i, tr := range tracks {
|
||||
query := strings.TrimSpace(tr.Title + " " + tr.Artist)
|
||||
id, source, searchErr := searchLastFMTrack(ctx, opts, primary, fallback, query)
|
||||
@@ -1708,16 +1866,25 @@ func queueLastFMTracks(ctx context.Context, mainApp *app.Main, opts lastFMOption
|
||||
fmt.Printf("[%d/%d] no result: %s\n", i+1, len(tracks), query)
|
||||
continue
|
||||
}
|
||||
if err = mainApp.AddByID(ctx, source, "track", id); err != nil {
|
||||
failed++
|
||||
fmt.Printf("[%d/%d] add failed: %s (%v)\n", i+1, len(tracks), query, err)
|
||||
continue
|
||||
}
|
||||
resolved = append(resolved, resolvedLastFMTrack{Source: source, ID: id, Query: query})
|
||||
found++
|
||||
fmt.Printf("[%d/%d] found: %s (%s)\n", i+1, len(tracks), query, source)
|
||||
}
|
||||
fmt.Printf("lastfm resolve complete: %d found, %d failed\n", found, failed)
|
||||
return nil
|
||||
return resolved, nil
|
||||
}
|
||||
|
||||
func groupLastFMResolvedTracksBySource(resolved []resolvedLastFMTrack) map[string][]string {
|
||||
out := map[string][]string{}
|
||||
for _, item := range resolved {
|
||||
source := strings.TrimSpace(item.Source)
|
||||
id := strings.TrimSpace(item.ID)
|
||||
if source == "" || id == "" {
|
||||
continue
|
||||
}
|
||||
out[source] = append(out[source], id)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func fetchSoundcloudOEmbed(ctx context.Context, verifySSL bool, trackURL string) (map[string]any, error) {
|
||||
|
||||
@@ -166,3 +166,41 @@ func TestNormalizeCodecRejectsUnknown(t *testing.T) {
|
||||
t.Fatalf("expected error for unsupported codec")
|
||||
}
|
||||
}
|
||||
|
||||
func TestGroupLastFMResolvedTracksBySourcePreservesOrderAndDuplicates(t *testing.T) {
|
||||
resolved := []resolvedLastFMTrack{
|
||||
{Source: "tidal", ID: "1"},
|
||||
{Source: "tidal", ID: "1"},
|
||||
{Source: "qobuz", ID: "2"},
|
||||
{Source: "tidal", ID: "3"},
|
||||
{Source: "", ID: "4"},
|
||||
}
|
||||
groups := groupLastFMResolvedTracksBySource(resolved)
|
||||
if len(groups["tidal"]) != 3 {
|
||||
t.Fatalf("tidal ids len = %d, want 3", len(groups["tidal"]))
|
||||
}
|
||||
if len(groups["qobuz"]) != 1 {
|
||||
t.Fatalf("qobuz ids len = %d, want 1", len(groups["qobuz"]))
|
||||
}
|
||||
if groups["tidal"][0] != "1" || groups["tidal"][1] != "1" || groups["tidal"][2] != "3" {
|
||||
t.Fatalf("unexpected tidal ordering: %+v", groups["tidal"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtractLastFMTracksFromMirrorMarkdown(t *testing.T) {
|
||||
md := `Title: My Playlist | user playlists | Last.fm
|
||||
| Play | Image | Loved | Name | Artist name | Buy | Options | Duration |
|
||||
| --- | --- | --- | --- | --- | --- | --- | --- |
|
||||
| [Play track](https://x) | [img](https://i) | x | [Song A](https://a) | [Artist A](https://aa) | | | 3:00 |
|
||||
| [Play track](https://x) | [img](https://i) | x | [Song B](https://b) | [Artist B](https://bb) | | | 4:00 |`
|
||||
title, tracks := extractLastFMTracksFromMirrorMarkdown(md)
|
||||
if title != "My Playlist" {
|
||||
t.Fatalf("title = %q, want %q", title, "My Playlist")
|
||||
}
|
||||
if len(tracks) != 2 {
|
||||
t.Fatalf("tracks len = %d, want 2", len(tracks))
|
||||
}
|
||||
if tracks[0].Title != "Song A" || tracks[0].Artist != "Artist A" {
|
||||
t.Fatalf("unexpected first track: %+v", tracks[0])
|
||||
}
|
||||
}
|
||||
|
||||
@@ -178,6 +178,47 @@ func (m *Main) AddByID(ctx context.Context, source, mediaType, id string) error
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *Main) AddPlaylistByTrackIDs(ctx context.Context, source, playlistID, playlistName string, trackIDs []string) error {
|
||||
p, err := m.GetLoggedInProvider(ctx, source)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if strings.TrimSpace(playlistName) == "" {
|
||||
playlistName = playlistID
|
||||
}
|
||||
ids := make([]string, 0, len(trackIDs))
|
||||
for _, id := range trackIDs {
|
||||
id = strings.TrimSpace(id)
|
||||
if id != "" {
|
||||
ids = append(ids, id)
|
||||
}
|
||||
}
|
||||
if len(ids) == 0 {
|
||||
return fmt.Errorf("playlist %q has no track ids", playlistName)
|
||||
}
|
||||
|
||||
pending := media.PendingFunc{
|
||||
ResolveFn: func(context.Context) (media.Media, error) {
|
||||
metaItems := make([]any, 0, len(ids))
|
||||
for _, id := range ids {
|
||||
metaItems = append(metaItems, map[string]any{"id": id})
|
||||
}
|
||||
playlistMeta := map[string]any{
|
||||
"name": playlistName,
|
||||
"tracks": map[string]any{"items": metaItems},
|
||||
}
|
||||
return media.MediaFunc{RipFn: func(ctx context.Context) error {
|
||||
if m.Config.Session.CLI.TextOutput {
|
||||
m.logf("Downloading playlist: %s\n", playlistName)
|
||||
}
|
||||
return m.ripPlaylist(ctx, p, source, playlistID, playlistMeta)
|
||||
}}, nil
|
||||
},
|
||||
}
|
||||
m.Pending = append(m.Pending, pending)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *Main) ripCollection(ctx context.Context, p provider.Client, source, kind, id string, meta map[string]any) error {
|
||||
name := titleFromMetadata(meta, id)
|
||||
if n := stringFromAny(meta["name"]); n != "" {
|
||||
|
||||
Reference in New Issue
Block a user