diff --git a/cmd/rip/main.go b/cmd/rip/main.go index f715bea..db6258d 100644 --- a/cmd/rip/main.go +++ b/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 ") @@ -1355,10 +1376,18 @@ type lastFMTrack struct { Artist string } +type resolvedLastFMTrack struct { + Source string + ID string + Query string +} + var ( lastFMTitleTagsRe = regexp.MustCompile(`([^<]+)`) + 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) { diff --git a/cmd/rip/main_test.go b/cmd/rip/main_test.go index bd4f395..cbaca24 100644 --- a/cmd/rip/main_test.go +++ b/cmd/rip/main_test.go @@ -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]) + } +} diff --git a/internal/app/app.go b/internal/app/app.go index 494533d..a19ecee 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -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 != "" {