mirror of
https://git.sr.ht/~joren/streamrip-go
synced 2026-06-17 15:05:39 +02:00
- Extracted common JSON parsing helpers into internal/jsonutil - Removed duplicated helper functions from provider packages - Removed dead code in internal/app/app.go and downloader.go - Replaced deprecated strings.Title with jsonutil.TitleCase - Added graceful shutdown with signal handling in main.go - Split monolithic cmd/rip/main.go into args.go, helpers.go, lastfm.go, search.go
431 lines
13 KiB
Go
431 lines
13 KiB
Go
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)
|
|
|
|
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)
|
|
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)
|
|
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
|
|
}
|