package main import ( "context" "encoding/json" "fmt" "html" "io" "net/http" "net/url" "strconv" "strings" "time" "streamrip-go/internal/app" "streamrip-go/internal/netutil" "streamrip-go/internal/provider" ) func parseLastFMArgs(args []string, defaultSource, defaultFallback string) (lastFMOptions, error) { opts := lastFMOptions{Source: strings.ToLower(strings.TrimSpace(defaultSource)), FallbackSource: strings.ToLower(strings.TrimSpace(defaultFallback))} for i := 0; i < len(args); i++ { switch args[i] { case "-s", "--source": if i+1 >= len(args) { return lastFMOptions{}, fmt.Errorf("--source requires a value") } opts.Source = strings.ToLower(strings.TrimSpace(args[i+1])) i++ case "-fs", "--fallback-source": if i+1 >= len(args) { return lastFMOptions{}, fmt.Errorf("--fallback-source requires a value") } opts.FallbackSource = strings.ToLower(strings.TrimSpace(args[i+1])) i++ default: if strings.HasPrefix(args[i], "-") { return lastFMOptions{}, fmt.Errorf("unknown option %q", args[i]) } if opts.PlaylistURL != "" { return lastFMOptions{}, fmt.Errorf("unexpected extra argument %q", args[i]) } opts.PlaylistURL = strings.TrimSpace(args[i]) } } if opts.Source == "" { opts.Source = "qobuz" } 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) } if opts.FallbackSource != "" && !isAllowedSearchSource(opts.FallbackSource) { return lastFMOptions{}, fmt.Errorf("%s %q", errLastFMInvalidSource, opts.FallbackSource) } 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.")) if h != "last.fm" && !strings.HasSuffix(h, ".last.fm") { return false } p := strings.ToLower(strings.TrimSpace(u.Path)) return strings.Contains(p, "/playlists/") } 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, 0) page1, err := fetchLastFMPlaylistPage(ctx, client, parsed, 1) if err != nil { return fetchLastFMPlaylistViaMirror(ctx, verifySSL, playlistURL) } title, total, err := extractLastFMPlaylistInfo(page1) if err != nil { return fetchLastFMPlaylistViaMirror(ctx, verifySSL, playlistURL) } tracks := extractLastFMTitleArtistPairs(page1) if total <= len(tracks) || total <= 50 { if len(tracks) > total && total > 0 { tracks = tracks[:total] } return title, tracks, nil } remaining := total - 50 lastPage := 1 + remaining/50 if remaining%50 != 0 { lastPage++ } for page := 2; page <= lastPage; page++ { body, fetchErr := fetchLastFMPlaylistPage(ctx, client, parsed, page) if fetchErr != nil { return "", nil, fetchErr } tracks = append(tracks, extractLastFMTitleArtistPairs(body)...) } if len(tracks) > total { tracks = tracks[:total] } return title, tracks, nil } func fetchLastFMPlaylistViaMirror(ctx context.Context, verifySSL bool, playlistURL string) (string, []lastFMTrack, error) { client := netutil.NewHTTPClient(30*time.Second, verifySSL, 0) all := make([]lastFMTrack, 0, 200) 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 } all = append(all, tracks...) if !strings.Contains(strings.ToLower(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 { q := u.Query() q.Set("page", strconv.Itoa(page)) u.RawQuery = q.Encode() } req, err := http.NewRequestWithContext(ctx, http.MethodGet, u.String(), 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 request failed: status %d", resp.StatusCode) } b, err := io.ReadAll(resp.Body) if err != nil { return "", err } return string(b), nil } func extractLastFMPlaylistInfo(page string) (string, int, error) { titleMatch := lastFMPlaylistTitleRe.FindStringSubmatch(page) if len(titleMatch) < 2 { return "", 0, fmt.Errorf("could not parse playlist title") } totalMatch := lastFMTotalTracksRe.FindStringSubmatch(page) if len(totalMatch) < 2 { return "", 0, fmt.Errorf("could not parse total track count") } total, err := strconv.Atoi(totalMatch[1]) if err != nil { return "", 0, fmt.Errorf("invalid total track count") } return html.UnescapeString(strings.TrimSpace(titleMatch[1])), total, nil } func extractLastFMTitleArtistPairs(page string) []lastFMTrack { dataPairs := lastFMDataTrackArtistRe.FindAllStringSubmatch(page, -1) if len(dataPairs) > 0 { out := make([]lastFMTrack, 0, len(dataPairs)) for _, m := range dataPairs { title := html.UnescapeString(strings.TrimSpace(firstNonEmpty(m[1], m[2]))) artist := html.UnescapeString(strings.TrimSpace(firstNonEmpty(m[3], m[4]))) if title == "" || artist == "" { continue } out = append(out, lastFMTrack{Title: title, Artist: artist}) } if len(out) > 0 { return out } } titles := lastFMTitleTagsRe.FindAllStringSubmatch(page, -1) out := make([]lastFMTrack, 0, len(titles)/2) for i := 0; i+1 < len(titles); i += 2 { titleRaw := strings.TrimSpace(firstNonEmpty(titles[i][1], titles[i][2])) artistRaw := strings.TrimSpace(firstNonEmpty(titles[i+1][1], titles[i+1][2])) if strings.EqualFold(titleRaw, "Play on YouTube") || strings.EqualFold(artistRaw, "Play on YouTube") { continue } title := html.UnescapeString(titleRaw) artist := html.UnescapeString(artistRaw) if title == "" || artist == "" { continue } out = append(out, lastFMTrack{Title: title, Artist: artist}) } return out } func firstNonEmpty(items ...string) string { for _, item := range items { if strings.TrimSpace(item) != "" { return strings.TrimSpace(item) } } return "" } 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(strings.ToLower(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 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 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) if searchErr != nil { failed++ fmt.Printf("[%d/%d] search failed: %s (%v)\n", i+1, len(tracks), query, searchErr) continue } if id == "" { failed++ fmt.Printf("[%d/%d] no result: %s\n", i+1, len(tracks), query) 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 resolved, nil } func fetchSoundcloudOEmbed(ctx context.Context, verifySSL bool, trackURL string) (map[string]any, error) { parsed, err := url.Parse(trackURL) if err != nil || parsed.Scheme == "" || parsed.Host == "" { return nil, fmt.Errorf("invalid soundcloud url") } q := url.Values{} q.Set("format", "json") q.Set("url", trackURL) endpoint := "https://soundcloud.com/oembed?" + q.Encode() client := netutil.NewHTTPClient(20*time.Second, verifySSL, 0) req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil) if err != nil { return nil, err } req.Header.Set("User-Agent", "streamrip-go/0.1") resp, err := client.Do(req) if err != nil { return nil, err } defer func() { _ = resp.Body.Close() }() if resp.StatusCode < 200 || resp.StatusCode >= 300 { return nil, fmt.Errorf("soundcloud oembed failed: status %d", resp.StatusCode) } body, err := io.ReadAll(resp.Body) if err != nil { return nil, err } out := map[string]any{} if err = json.Unmarshal(body, &out); err != nil { return nil, err } return out, nil } func searchLastFMTrack(ctx context.Context, opts lastFMOptions, primary provider.Client, fallback provider.Client, query string) (string, string, error) { pages, err := primary.Search(ctx, "track", query, 1) if err == nil { results := normalizeSearchResults(opts.Source, "track", pages) if len(results) > 0 { return results[0].ID, opts.Source, nil } } if fallback != nil { pages, fbErr := fallback.Search(ctx, "track", query, 1) if fbErr != nil { if err != nil { return "", "", fmt.Errorf("primary=%v fallback=%v", err, fbErr) } return "", "", fbErr } results := normalizeSearchResults(opts.FallbackSource, "track", pages) if len(results) > 0 { return results[0].ID, opts.FallbackSource, nil } } if err != nil { return "", "", err } return "", "", nil }