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))
|
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)
|
fmt.Fprintf(os.Stderr, "lastfm resolve error: %v\n", err)
|
||||||
os.Exit(1)
|
os.Exit(1)
|
||||||
}
|
}
|
||||||
if len(mainApp.Pending) == 0 {
|
if len(resolvedTracks) == 0 {
|
||||||
fmt.Println("no lastfm tracks resolved")
|
fmt.Println("no lastfm tracks resolved")
|
||||||
return
|
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 {
|
if err = mainApp.Resolve(ctx); err != nil {
|
||||||
fmt.Fprintf(os.Stderr, "resolve error: %v\n", err)
|
fmt.Fprintf(os.Stderr, "resolve error: %v\n", err)
|
||||||
os.Exit(1)
|
os.Exit(1)
|
||||||
@@ -552,7 +573,7 @@ func main() {
|
|||||||
fmt.Fprintf(os.Stderr, "rip error: %v\n", err)
|
fmt.Fprintf(os.Stderr, "rip error: %v\n", err)
|
||||||
os.Exit(1)
|
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":
|
case "soundcloud-smoke":
|
||||||
if len(os.Args) < 3 {
|
if len(os.Args) < 3 {
|
||||||
fmt.Println("usage: rip soundcloud-smoke <soundcloud_url>")
|
fmt.Println("usage: rip soundcloud-smoke <soundcloud_url>")
|
||||||
@@ -1355,10 +1376,18 @@ type lastFMTrack struct {
|
|||||||
Artist string
|
Artist string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type resolvedLastFMTrack struct {
|
||||||
|
Source string
|
||||||
|
ID string
|
||||||
|
Query string
|
||||||
|
}
|
||||||
|
|
||||||
var (
|
var (
|
||||||
lastFMTitleTagsRe = regexp.MustCompile(`<a\s+href="[^"]+"\s+title="([^"]+)"`)
|
lastFMTitleTagsRe = regexp.MustCompile(`<a\s+href="[^"]+"\s+title="([^"]+)"`)
|
||||||
lastFMTotalTracksRe = regexp.MustCompile(`data-playlisting-entry-count="(\d+)"`)
|
lastFMTotalTracksRe = regexp.MustCompile(`data-playlisting-entry-count="(\d+)"`)
|
||||||
lastFMPlaylistTitleRe = regexp.MustCompile(`<h1 class="playlisting-playlist-header-title">([^<]+)</h1>`)
|
lastFMPlaylistTitleRe = regexp.MustCompile(`<h1 class="playlisting-playlist-header-title">([^<]+)</h1>`)
|
||||||
|
lastFMMirrorTitleRe = regexp.MustCompile(`^Title:\s*(.+?)\s+\|`)
|
||||||
|
lastFMMirrorLinkTextRe = regexp.MustCompile(`\[([^\]]+)\]\(`)
|
||||||
errLastFMInvalidSource = "unsupported source"
|
errLastFMInvalidSource = "unsupported source"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -1590,11 +1619,11 @@ func fetchLastFMPlaylist(ctx context.Context, verifySSL bool, playlistURL string
|
|||||||
|
|
||||||
page1, err := fetchLastFMPlaylistPage(ctx, client, parsed, 1)
|
page1, err := fetchLastFMPlaylistPage(ctx, client, parsed, 1)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", nil, err
|
return fetchLastFMPlaylistViaMirror(ctx, verifySSL, playlistURL)
|
||||||
}
|
}
|
||||||
title, total, err := extractLastFMPlaylistInfo(page1)
|
title, total, err := extractLastFMPlaylistInfo(page1)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", nil, err
|
return fetchLastFMPlaylistViaMirror(ctx, verifySSL, playlistURL)
|
||||||
}
|
}
|
||||||
tracks := extractLastFMTitleArtistPairs(page1)
|
tracks := extractLastFMTitleArtistPairs(page1)
|
||||||
if total <= len(tracks) || total <= 50 {
|
if total <= len(tracks) || total <= 50 {
|
||||||
@@ -1622,6 +1651,86 @@ func fetchLastFMPlaylist(ctx context.Context, verifySSL bool, playlistURL string
|
|||||||
return title, tracks, nil
|
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) {
|
func fetchLastFMPlaylistPage(ctx context.Context, client *http.Client, parsed *url.URL, page int) (string, error) {
|
||||||
u := *parsed
|
u := *parsed
|
||||||
if page > 1 {
|
if page > 1 {
|
||||||
@@ -1680,21 +1789,70 @@ func extractLastFMTitleArtistPairs(page string) []lastFMTrack {
|
|||||||
return out
|
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)
|
primary, err := mainApp.GetLoggedInProvider(ctx, opts.Source)
|
||||||
if err != nil {
|
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
|
var fallback provider.Client
|
||||||
if opts.FallbackSource != "" && opts.FallbackSource != opts.Source {
|
if opts.FallbackSource != "" && opts.FallbackSource != opts.Source {
|
||||||
fallback, err = mainApp.GetLoggedInProvider(ctx, opts.FallbackSource)
|
fallback, err = mainApp.GetLoggedInProvider(ctx, opts.FallbackSource)
|
||||||
if err != nil {
|
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
|
found := 0
|
||||||
failed := 0
|
failed := 0
|
||||||
|
resolved := make([]resolvedLastFMTrack, 0, len(tracks))
|
||||||
for i, tr := range tracks {
|
for i, tr := range tracks {
|
||||||
query := strings.TrimSpace(tr.Title + " " + tr.Artist)
|
query := strings.TrimSpace(tr.Title + " " + tr.Artist)
|
||||||
id, source, searchErr := searchLastFMTrack(ctx, opts, primary, fallback, query)
|
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)
|
fmt.Printf("[%d/%d] no result: %s\n", i+1, len(tracks), query)
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
if err = mainApp.AddByID(ctx, source, "track", id); err != nil {
|
resolved = append(resolved, resolvedLastFMTrack{Source: source, ID: id, Query: query})
|
||||||
failed++
|
|
||||||
fmt.Printf("[%d/%d] add failed: %s (%v)\n", i+1, len(tracks), query, err)
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
found++
|
found++
|
||||||
fmt.Printf("[%d/%d] found: %s (%s)\n", i+1, len(tracks), query, source)
|
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)
|
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) {
|
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")
|
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
|
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 {
|
func (m *Main) ripCollection(ctx context.Context, p provider.Client, source, kind, id string, meta map[string]any) error {
|
||||||
name := titleFromMetadata(meta, id)
|
name := titleFromMetadata(meta, id)
|
||||||
if n := stringFromAny(meta["name"]); n != "" {
|
if n := stringFromAny(meta["name"]); n != "" {
|
||||||
|
|||||||
Reference in New Issue
Block a user