diff --git a/cmd/rip/main.go b/cmd/rip/main.go index 8d1d251..d68a6d3 100644 --- a/cmd/rip/main.go +++ b/cmd/rip/main.go @@ -1640,6 +1640,9 @@ func parseLastFMArgs(args []string, defaultSource, defaultFallback string) (last if opts.PlaylistURL == "" { return lastFMOptions{}, fmt.Errorf("missing playlist url") } + if !isValidLastFMPlaylistURL(opts.PlaylistURL) { + return lastFMOptions{}, fmt.Errorf("playlist url must be a last.fm url") + } if !isAllowedSearchSource(opts.Source) { return lastFMOptions{}, fmt.Errorf("%s %q", errLastFMInvalidSource, opts.Source) } @@ -1649,11 +1652,27 @@ func parseLastFMArgs(args []string, defaultSource, defaultFallback string) (last return opts, nil } +func isValidLastFMPlaylistURL(raw string) bool { + u, err := url.Parse(strings.TrimSpace(raw)) + if err != nil || u == nil || u.Host == "" { + return false + } + s := strings.ToLower(strings.TrimSpace(u.Scheme)) + if s != "http" && s != "https" { + return false + } + h := strings.ToLower(strings.TrimPrefix(strings.TrimSpace(u.Host), "www.")) + return h == "last.fm" || strings.HasSuffix(h, ".last.fm") +} + func fetchLastFMPlaylist(ctx context.Context, verifySSL bool, playlistURL string) (string, []lastFMTrack, error) { parsed, err := url.Parse(playlistURL) if err != nil || parsed.Scheme == "" || parsed.Host == "" { return "", nil, fmt.Errorf("invalid playlist url") } + if !isValidLastFMPlaylistURL(playlistURL) { + return "", nil, fmt.Errorf("invalid playlist url") + } client := netutil.NewHTTPClient(30*time.Second, verifySSL) page1, err := fetchLastFMPlaylistPage(ctx, client, parsed, 1) @@ -1711,7 +1730,7 @@ func fetchLastFMPlaylistViaMirror(ctx context.Context, verifySSL bool, playlistU break } all = append(all, tracks...) - if !strings.Contains(body, "Show more") { + if !strings.Contains(strings.ToLower(body), "show more") { break } } @@ -1829,7 +1848,7 @@ func extractLastFMTracksFromMirrorMarkdown(md string) (string, []lastFMTrack) { title = strings.TrimSpace(html.UnescapeString(m[1])) } } - if !strings.HasPrefix(line, "|") || !strings.Contains(line, "Play track") { + if !strings.HasPrefix(line, "|") || !strings.Contains(strings.ToLower(line), "play track") { continue } cols := splitMarkdownTableRow(line) diff --git a/cmd/rip/main_test.go b/cmd/rip/main_test.go index eb020e3..d297600 100644 --- a/cmd/rip/main_test.go +++ b/cmd/rip/main_test.go @@ -94,6 +94,28 @@ func TestParseLastFMArgsOptions(t *testing.T) { } } +func TestParseLastFMArgsRejectsNonLastFMURL(t *testing.T) { + _, err := parseLastFMArgs([]string{"https://example.com/user/x/playlists/123"}, "qobuz", "") + if err == nil || !strings.Contains(strings.ToLower(err.Error()), "last.fm") { + t.Fatalf("expected last.fm url validation error, got %v", err) + } +} + +func TestIsValidLastFMPlaylistURL(t *testing.T) { + if !isValidLastFMPlaylistURL("https://www.last.fm/user/x/playlists/123") { + t.Fatalf("expected canonical last.fm playlist url to be valid") + } + if !isValidLastFMPlaylistURL("http://last.fm/user/x/playlists/123") { + t.Fatalf("expected http last.fm playlist url to be valid") + } + if isValidLastFMPlaylistURL("ftp://last.fm/user/x/playlists/123") { + t.Fatalf("expected non-http scheme to be invalid") + } + if isValidLastFMPlaylistURL("https://example.com/user/x/playlists/123") { + t.Fatalf("expected non-last.fm host to be invalid") + } +} + func TestExtractLastFMPlaylistInfoAndPairs(t *testing.T) { html := `

Road & Rain

@@ -191,6 +213,17 @@ func TestExtractLastFMTracksFromMirrorMarkdown(t *testing.T) { } } +func TestExtractLastFMTracksFromMirrorMarkdownLowercasePlayTrack(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 |` + _, tracks := extractLastFMTracksFromMirrorMarkdown(md) + if len(tracks) != 1 { + t.Fatalf("tracks len = %d, want 1", len(tracks)) + } +} + func TestParseSearchArgsAllowsFirstAndOutputFileButCallerCanReject(t *testing.T) { opts, err := parseSearchArgs([]string{"q", "--first", "--output-file", "/tmp/out.json"}, 20) if err != nil {