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:
2026-04-20 02:01:16 +02:00
parent 47b754a216
commit 0748d5a325
3 changed files with 260 additions and 14 deletions

View File

@@ -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) {

View File

@@ -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])
}
}

View File

@@ -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 != "" {