diff --git a/cmd/rip/args.go b/cmd/rip/args.go
new file mode 100644
index 0000000..8fbf60d
--- /dev/null
+++ b/cmd/rip/args.go
@@ -0,0 +1,211 @@
+package main
+
+import (
+ "fmt"
+ "strconv"
+ "strings"
+
+ "streamrip-go/internal/config"
+)
+
+type smokeOptions struct {
+ qualitySet bool
+ quality int
+ ignoreDB bool
+}
+
+type globalOptions struct {
+ configPath string
+ folder string
+ noDB bool
+ qualitySet bool
+ quality int
+ codecSet bool
+ codec string
+ noProgress bool
+ noSSLVerify bool
+ verbose bool
+ command string
+ commandArgs []string
+}
+
+func parseGlobalArgs(args []string) (globalOptions, error) {
+ opts := globalOptions{}
+ for i := 0; i < len(args); i++ {
+ arg := args[i]
+ if arg == "" {
+ continue
+ }
+ if !strings.HasPrefix(arg, "-") {
+ opts.command = arg
+ if i+1 < len(args) {
+ opts.commandArgs = append([]string(nil), args[i+1:]...)
+ }
+ return opts, nil
+ }
+
+ switch {
+ case arg == "-ndb" || arg == "--no-db":
+ opts.noDB = true
+ case arg == "--no-progress":
+ opts.noProgress = true
+ case arg == "--no-ssl-verify":
+ opts.noSSLVerify = true
+ case arg == "-v" || arg == "--verbose":
+ opts.verbose = true
+ case arg == "-f" || arg == "--folder":
+ if i+1 >= len(args) {
+ return globalOptions{}, fmt.Errorf("%s requires a value", arg)
+ }
+ opts.folder = strings.TrimSpace(args[i+1])
+ i++
+ case strings.HasPrefix(arg, "--folder="):
+ opts.folder = strings.TrimSpace(strings.TrimPrefix(arg, "--folder="))
+ case arg == "--config-path":
+ if i+1 >= len(args) {
+ return globalOptions{}, fmt.Errorf("--config-path requires a value")
+ }
+ opts.configPath = strings.TrimSpace(args[i+1])
+ i++
+ case strings.HasPrefix(arg, "--config-path="):
+ opts.configPath = strings.TrimSpace(strings.TrimPrefix(arg, "--config-path="))
+ case arg == "-q" || arg == "--quality":
+ if i+1 >= len(args) {
+ return globalOptions{}, fmt.Errorf("%s requires a value", arg)
+ }
+ q, err := strconv.Atoi(args[i+1])
+ if err != nil || q < 0 || q > 4 {
+ return globalOptions{}, fmt.Errorf("invalid quality %q (expected 0-4)", args[i+1])
+ }
+ opts.qualitySet = true
+ opts.quality = q
+ i++
+ case strings.HasPrefix(arg, "--quality="):
+ qRaw := strings.TrimSpace(strings.TrimPrefix(arg, "--quality="))
+ q, err := strconv.Atoi(qRaw)
+ if err != nil || q < 0 || q > 4 {
+ return globalOptions{}, fmt.Errorf("invalid quality %q (expected 0-4)", qRaw)
+ }
+ opts.qualitySet = true
+ opts.quality = q
+ case arg == "-c" || arg == "--codec":
+ if i+1 >= len(args) {
+ return globalOptions{}, fmt.Errorf("%s requires a value", arg)
+ }
+ codec, err := normalizeCodec(args[i+1])
+ if err != nil {
+ return globalOptions{}, err
+ }
+ opts.codecSet = true
+ opts.codec = codec
+ i++
+ case strings.HasPrefix(arg, "--codec="):
+ codecRaw := strings.TrimSpace(strings.TrimPrefix(arg, "--codec="))
+ codec, err := normalizeCodec(codecRaw)
+ if err != nil {
+ return globalOptions{}, err
+ }
+ opts.codecSet = true
+ opts.codec = codec
+ default:
+ return globalOptions{}, fmt.Errorf("unknown global option %q", arg)
+ }
+ }
+ return opts, nil
+}
+
+func normalizeCodec(raw string) (string, error) {
+ codec := strings.ToUpper(strings.TrimSpace(raw))
+ switch codec {
+ case "ALAC", "FLAC", "MP3", "AAC", "VORBIS":
+ return codec, nil
+ case "OGG":
+ return "VORBIS", nil
+ default:
+ return "", fmt.Errorf("unsupported codec %q (expected ALAC, FLAC, OGG, MP3, AAC)", raw)
+ }
+}
+
+func applyGlobalConfigOverrides(cfg *config.Config, opts globalOptions) {
+ if opts.folder != "" {
+ cfg.Session.Downloads.Folder = opts.folder
+ }
+ if opts.noDB {
+ cfg.Session.Database.DownloadsEnabled = false
+ }
+ if opts.qualitySet {
+ cfg.Session.Qobuz.Quality = opts.quality
+ cfg.Session.Tidal.Quality = opts.quality
+ cfg.Session.Deezer.Quality = opts.quality
+ cfg.Session.Soundcloud.Quality = opts.quality
+ }
+ if opts.codecSet {
+ cfg.Session.Conversion.Enabled = true
+ cfg.Session.Conversion.Codec = opts.codec
+ }
+ if opts.noProgress {
+ cfg.Session.CLI.ProgressBars = false
+ }
+ if opts.noSSLVerify {
+ cfg.Session.Downloads.VerifySSL = false
+ }
+}
+
+func errorWithActionableHint(err error, opts globalOptions) string {
+ if err == nil {
+ return ""
+ }
+ msg := err.Error()
+ if opts.noSSLVerify {
+ return msg
+ }
+ lower := strings.ToLower(msg)
+ if strings.Contains(lower, "x509") || strings.Contains(lower, "certificate") || strings.Contains(lower, "tls") || strings.Contains(lower, "ssl") {
+ return msg + " (hint: try again with --no-ssl-verify)"
+ }
+ return msg
+}
+
+func parseSmokeOptions(args []string, minQuality int, maxQuality int) (smokeOptions, error) {
+ opts := smokeOptions{}
+ for _, arg := range args {
+ switch arg {
+ case "--force", "--ignore-db":
+ opts.ignoreDB = true
+ default:
+ q, err := parseQuality(arg, minQuality, maxQuality)
+ if err != nil {
+ return smokeOptions{}, fmt.Errorf("unknown option %q", arg)
+ }
+ opts.quality = q
+ opts.qualitySet = true
+ }
+ }
+ return opts, nil
+}
+
+func parseQuality(raw string, min int, max int) (int, error) {
+ q, err := strconv.Atoi(raw)
+ if err != nil {
+ return 0, err
+ }
+ if q < min || q > max {
+ return 0, fmt.Errorf("quality must be %d-%d, got %d", min, max, q)
+ }
+ return q, nil
+}
+
+func asString(v any) string {
+ switch t := v.(type) {
+ case string:
+ return t
+ case int:
+ return strconv.Itoa(t)
+ case int64:
+ return strconv.FormatInt(t, 10)
+ case float64:
+ return strconv.FormatFloat(t, 'f', -1, 64)
+ default:
+ return ""
+ }
+}
diff --git a/cmd/rip/helpers.go b/cmd/rip/helpers.go
new file mode 100644
index 0000000..d3a91d7
--- /dev/null
+++ b/cmd/rip/helpers.go
@@ -0,0 +1,234 @@
+package main
+
+import (
+ "bufio"
+ "context"
+ "database/sql"
+ "encoding/json"
+ "fmt"
+ "os"
+ "os/exec"
+ "regexp"
+ "runtime"
+ "strings"
+
+ "streamrip-go/internal/app"
+ "streamrip-go/internal/urlparse"
+)
+
+type fileIDItem struct {
+ Source string
+ MediaType string
+ ID string
+}
+
+type failedRow struct {
+ Source string
+ MediaType string
+ ID string
+}
+
+type lastFMOptions struct {
+ Source string
+ FallbackSource string
+ PlaylistURL string
+}
+
+type lastFMTrack struct {
+ Title string
+ Artist string
+}
+
+type resolvedLastFMTrack struct {
+ Source string
+ ID string
+ Query string
+}
+
+var (
+ lastFMTitleTagsRe = regexp.MustCompile(`]*\btitle=(?:"([^"]+)"|'([^']+)')`)
+ lastFMDataTrackArtistRe = regexp.MustCompile(`data-track-name=(?:"([^"]+)"|'([^']+)')[^>]*data-artist-name=(?:"([^"]+)"|'([^']+)')`)
+ lastFMTotalTracksRe = regexp.MustCompile(`data-playlisting-entry-count="(\d+)"`)
+ lastFMPlaylistTitleRe = regexp.MustCompile(`]*class="[^"]*playlisting-playlist-header-title[^"]*"[^>]*>([^<]+)
`)
+ lastFMMirrorTitleRe = regexp.MustCompile(`^Title:\s*(.+?)\s+\|`)
+ lastFMMirrorLinkTextRe = regexp.MustCompile(`\[([^\]]+)\]\(`)
+ errLastFMInvalidSource = "unsupported source"
+)
+
+func addURLToQueue(ctx context.Context, mainApp *app.Main, raw string) bool {
+ parsed := urlparse.Parse(raw)
+ if parsed == nil {
+ fmt.Printf("invalid: %s\n", raw)
+ return false
+ }
+ if parsed.Kind != urlparse.KindGeneric && parsed.Kind != urlparse.KindSoundcloud {
+ fmt.Printf("not yet supported: %s (kind=%s)\n", raw, parsed.Kind)
+ return false
+ }
+ if parsed.Source != "qobuz" && parsed.Source != "tidal" && parsed.Source != "deezer" && parsed.Source != "soundcloud" {
+ fmt.Printf("provider not yet implemented: source=%s url=%s\n", parsed.Source, raw)
+ return false
+ }
+ if err := mainApp.AddByID(ctx, parsed.Source, parsed.MediaType, parsed.ID); err != nil {
+ fmt.Printf("add failed: source=%s type=%s id=%s err=%v\n", parsed.Source, parsed.MediaType, parsed.ID, err)
+ return false
+ }
+ return true
+}
+
+func parseFileInput(content []byte) ([]fileIDItem, []string, int, bool, error) {
+ trimmed := strings.TrimSpace(string(content))
+ if trimmed == "" {
+ return nil, nil, 0, false, nil
+ }
+
+ var parsed any
+ if err := json.Unmarshal([]byte(trimmed), &parsed); err == nil {
+ arr, ok := parsed.([]any)
+ if !ok {
+ return nil, nil, 0, true, fmt.Errorf("json input must be an array of objects")
+ }
+ items := make([]fileIDItem, 0, len(arr))
+ for i, raw := range arr {
+ entry, ok := raw.(map[string]any)
+ if !ok {
+ return nil, nil, 0, true, fmt.Errorf("json item %d must be an object", i+1)
+ }
+ source := strings.ToLower(strings.TrimSpace(asString(entry["source"])))
+ mediaType := strings.ToLower(strings.TrimSpace(asString(entry["media_type"])))
+ if mediaType == "" {
+ mediaType = strings.ToLower(strings.TrimSpace(asString(entry["mediaType"])))
+ }
+ id := strings.TrimSpace(asString(entry["id"]))
+ if source == "" || mediaType == "" || id == "" {
+ return nil, nil, 0, true, fmt.Errorf("json item %d missing source/media_type/id", i+1)
+ }
+ items = append(items, fileIDItem{Source: source, MediaType: mediaType, ID: id})
+ }
+ return items, nil, 0, true, nil
+ }
+
+ parts := strings.Fields(trimmed)
+ if len(parts) == 0 {
+ return nil, nil, 0, false, nil
+ }
+ seen := make(map[string]struct{}, len(parts))
+ urls := make([]string, 0, len(parts))
+ repeated := 0
+ for _, raw := range parts {
+ if _, ok := seen[raw]; ok {
+ repeated++
+ continue
+ }
+ seen[raw] = struct{}{}
+ urls = append(urls, raw)
+ }
+ return nil, urls, repeated, false, nil
+}
+
+func promptYesNo(prompt string) (bool, error) {
+ reader := bufio.NewReader(os.Stdin)
+ fmt.Print(prompt)
+ line, err := reader.ReadString('\n')
+ if err != nil {
+ return false, err
+ }
+ line = strings.ToLower(strings.TrimSpace(line))
+ return line == "y" || line == "yes", nil
+}
+
+func openConfigInEditor(path string, vim bool) error {
+ launch := func(name string, args ...string) error {
+ cmd := exec.Command(name, args...)
+ cmd.Stdin = os.Stdin
+ cmd.Stdout = os.Stdout
+ cmd.Stderr = os.Stderr
+ return cmd.Run()
+ }
+
+ if vim {
+ if p, err := exec.LookPath("nvim"); err == nil {
+ return launch(p, path)
+ }
+ if p, err := exec.LookPath("vim"); err == nil {
+ return launch(p, path)
+ }
+ }
+
+ if editor := strings.TrimSpace(os.Getenv("EDITOR")); editor != "" {
+ parts := strings.Fields(editor)
+ if len(parts) > 0 {
+ return launch(parts[0], append(parts[1:], path)...)
+ }
+ }
+
+ switch runtime.GOOS {
+ case "darwin":
+ return launch("open", path)
+ case "windows":
+ return launch("cmd", "/c", "start", "", path)
+ default:
+ if p, err := exec.LookPath("xdg-open"); err == nil {
+ return launch(p, path)
+ }
+ return fmt.Errorf("could not find an editor (set $EDITOR or install xdg-open)")
+ }
+}
+
+func listDownloadsRows(path string) ([]string, error) {
+ db, err := sql.Open("sqlite", path)
+ if err != nil {
+ return nil, err
+ }
+ defer func() { _ = db.Close() }()
+
+ rows, err := db.Query(`SELECT id FROM downloads ORDER BY rowid`)
+ if err != nil {
+ if isNoSuchTableErr(err) {
+ return []string{}, nil
+ }
+ return nil, err
+ }
+ defer func() { _ = rows.Close() }()
+
+ out := []string{}
+ for rows.Next() {
+ var id string
+ if err = rows.Scan(&id); err != nil {
+ return nil, err
+ }
+ out = append(out, id)
+ }
+ return out, rows.Err()
+}
+
+func listFailedRows(path string) ([]failedRow, error) {
+ db, err := sql.Open("sqlite", path)
+ if err != nil {
+ return nil, err
+ }
+ defer func() { _ = db.Close() }()
+
+ rows, err := db.Query(`SELECT source, media_type, id FROM failed_downloads ORDER BY rowid`)
+ if err != nil {
+ return nil, err
+ }
+ defer func() { _ = rows.Close() }()
+
+ out := []failedRow{}
+ for rows.Next() {
+ var r failedRow
+ if err = rows.Scan(&r.Source, &r.MediaType, &r.ID); err != nil {
+ return nil, err
+ }
+ out = append(out, r)
+ }
+ return out, rows.Err()
+}
+
+func isNoSuchTableErr(err error) bool {
+ if err == nil {
+ return false
+ }
+ return strings.Contains(strings.ToLower(err.Error()), "no such table")
+}
diff --git a/cmd/rip/lastfm.go b/cmd/rip/lastfm.go
new file mode 100644
index 0000000..38eb74c
--- /dev/null
+++ b/cmd/rip/lastfm.go
@@ -0,0 +1,430 @@
+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
+}
diff --git a/cmd/rip/main.go b/cmd/rip/main.go
index 461147a..9d64184 100644
--- a/cmd/rip/main.go
+++ b/cmd/rip/main.go
@@ -1,33 +1,19 @@
package main
import (
- "bufio"
"context"
- "database/sql"
- "encoding/json"
"errors"
"fmt"
- "html"
- "io"
- "net/http"
- "net/url"
"os"
- "os/exec"
- "path/filepath"
- "regexp"
- "runtime"
- "strconv"
+ "os/signal"
"strings"
- "time"
+ "syscall"
- "github.com/AlecAivazis/survey/v2"
"golang.org/x/term"
"streamrip-go/internal/app"
"streamrip-go/internal/config"
- "streamrip-go/internal/netutil"
"streamrip-go/internal/provider"
- "streamrip-go/internal/urlparse"
_ "modernc.org/sqlite"
)
@@ -69,7 +55,8 @@ func main() {
os.Args = append([]string{os.Args[0], gopts.command}, gopts.commandArgs...)
- ctx := context.Background()
+ ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
+ defer stop()
switch os.Args[1] {
case "dev-help":
@@ -1190,1346 +1177,3 @@ func main() {
os.Exit(2)
}
}
-
-type smokeOptions struct {
- qualitySet bool
- quality int
- ignoreDB bool
-}
-
-type globalOptions struct {
- configPath string
- folder string
- noDB bool
- qualitySet bool
- quality int
- codecSet bool
- codec string
- noProgress bool
- noSSLVerify bool
- verbose bool
- command string
- commandArgs []string
-}
-
-func parseGlobalArgs(args []string) (globalOptions, error) {
- opts := globalOptions{}
- for i := 0; i < len(args); i++ {
- arg := args[i]
- if arg == "" {
- continue
- }
- if !strings.HasPrefix(arg, "-") {
- opts.command = arg
- if i+1 < len(args) {
- opts.commandArgs = append([]string(nil), args[i+1:]...)
- }
- return opts, nil
- }
-
- switch {
- case arg == "-ndb" || arg == "--no-db":
- opts.noDB = true
- case arg == "--no-progress":
- opts.noProgress = true
- case arg == "--no-ssl-verify":
- opts.noSSLVerify = true
- case arg == "-v" || arg == "--verbose":
- opts.verbose = true
- case arg == "-f" || arg == "--folder":
- if i+1 >= len(args) {
- return globalOptions{}, fmt.Errorf("%s requires a value", arg)
- }
- opts.folder = strings.TrimSpace(args[i+1])
- i++
- case strings.HasPrefix(arg, "--folder="):
- opts.folder = strings.TrimSpace(strings.TrimPrefix(arg, "--folder="))
- case arg == "--config-path":
- if i+1 >= len(args) {
- return globalOptions{}, fmt.Errorf("--config-path requires a value")
- }
- opts.configPath = strings.TrimSpace(args[i+1])
- i++
- case strings.HasPrefix(arg, "--config-path="):
- opts.configPath = strings.TrimSpace(strings.TrimPrefix(arg, "--config-path="))
- case arg == "-q" || arg == "--quality":
- if i+1 >= len(args) {
- return globalOptions{}, fmt.Errorf("%s requires a value", arg)
- }
- q, err := strconv.Atoi(args[i+1])
- if err != nil || q < 0 || q > 4 {
- return globalOptions{}, fmt.Errorf("invalid quality %q (expected 0-4)", args[i+1])
- }
- opts.qualitySet = true
- opts.quality = q
- i++
- case strings.HasPrefix(arg, "--quality="):
- qRaw := strings.TrimSpace(strings.TrimPrefix(arg, "--quality="))
- q, err := strconv.Atoi(qRaw)
- if err != nil || q < 0 || q > 4 {
- return globalOptions{}, fmt.Errorf("invalid quality %q (expected 0-4)", qRaw)
- }
- opts.qualitySet = true
- opts.quality = q
- case arg == "-c" || arg == "--codec":
- if i+1 >= len(args) {
- return globalOptions{}, fmt.Errorf("%s requires a value", arg)
- }
- codec, err := normalizeCodec(args[i+1])
- if err != nil {
- return globalOptions{}, err
- }
- opts.codecSet = true
- opts.codec = codec
- i++
- case strings.HasPrefix(arg, "--codec="):
- codecRaw := strings.TrimSpace(strings.TrimPrefix(arg, "--codec="))
- codec, err := normalizeCodec(codecRaw)
- if err != nil {
- return globalOptions{}, err
- }
- opts.codecSet = true
- opts.codec = codec
- default:
- return globalOptions{}, fmt.Errorf("unknown global option %q", arg)
- }
- }
- return opts, nil
-}
-
-func normalizeCodec(raw string) (string, error) {
- codec := strings.ToUpper(strings.TrimSpace(raw))
- switch codec {
- case "ALAC", "FLAC", "MP3", "AAC", "VORBIS":
- return codec, nil
- case "OGG":
- return "VORBIS", nil
- default:
- return "", fmt.Errorf("unsupported codec %q (expected ALAC, FLAC, OGG, MP3, AAC)", raw)
- }
-}
-
-func applyGlobalConfigOverrides(cfg *config.Config, opts globalOptions) {
- if opts.folder != "" {
- cfg.Session.Downloads.Folder = opts.folder
- }
- if opts.noDB {
- cfg.Session.Database.DownloadsEnabled = false
- }
- if opts.qualitySet {
- cfg.Session.Qobuz.Quality = opts.quality
- cfg.Session.Tidal.Quality = opts.quality
- cfg.Session.Deezer.Quality = opts.quality
- cfg.Session.Soundcloud.Quality = opts.quality
- }
- if opts.codecSet {
- cfg.Session.Conversion.Enabled = true
- cfg.Session.Conversion.Codec = opts.codec
- }
- if opts.noProgress {
- cfg.Session.CLI.ProgressBars = false
- }
- if opts.noSSLVerify {
- cfg.Session.Downloads.VerifySSL = false
- }
-}
-
-func errorWithActionableHint(err error, opts globalOptions) string {
- if err == nil {
- return ""
- }
- msg := err.Error()
- if opts.noSSLVerify {
- return msg
- }
- lower := strings.ToLower(msg)
- if strings.Contains(lower, "x509") || strings.Contains(lower, "certificate") || strings.Contains(lower, "tls") || strings.Contains(lower, "ssl") {
- return msg + " (hint: try again with --no-ssl-verify)"
- }
- return msg
-}
-
-func parseSmokeOptions(args []string, minQuality int, maxQuality int) (smokeOptions, error) {
- opts := smokeOptions{}
- for _, arg := range args {
- switch arg {
- case "--force", "--ignore-db":
- opts.ignoreDB = true
- default:
- q, err := parseQuality(arg, minQuality, maxQuality)
- if err != nil {
- return smokeOptions{}, fmt.Errorf("unknown option %q", arg)
- }
- opts.quality = q
- opts.qualitySet = true
- }
- }
- return opts, nil
-}
-
-func parseQuality(raw string, min int, max int) (int, error) {
- q, err := strconv.Atoi(raw)
- if err != nil {
- return 0, err
- }
- if q < min || q > max {
- return 0, fmt.Errorf("quality must be %d-%d, got %d", min, max, q)
- }
- return q, nil
-}
-
-func asString(v any) string {
- switch t := v.(type) {
- case string:
- return t
- case int:
- return strconv.Itoa(t)
- case int64:
- return strconv.FormatInt(t, 10)
- case float64:
- return strconv.FormatFloat(t, 'f', -1, 64)
- default:
- return ""
- }
-}
-
-type fileIDItem struct {
- Source string
- MediaType string
- ID string
-}
-
-type failedRow struct {
- Source string
- MediaType string
- ID string
-}
-
-type lastFMOptions struct {
- Source string
- FallbackSource string
- PlaylistURL string
-}
-
-type lastFMTrack struct {
- Title string
- Artist string
-}
-
-type resolvedLastFMTrack struct {
- Source string
- ID string
- Query string
-}
-
-var (
- lastFMTitleTagsRe = regexp.MustCompile(`]*\btitle=(?:"([^"]+)"|'([^']+)')`)
- lastFMDataTrackArtistRe = regexp.MustCompile(`data-track-name=(?:"([^"]+)"|'([^']+)')[^>]*data-artist-name=(?:"([^"]+)"|'([^']+)')`)
- lastFMTotalTracksRe = regexp.MustCompile(`data-playlisting-entry-count="(\d+)"`)
- lastFMPlaylistTitleRe = regexp.MustCompile(`]*class="[^"]*playlisting-playlist-header-title[^"]*"[^>]*>([^<]+)
`)
- lastFMMirrorTitleRe = regexp.MustCompile(`^Title:\s*(.+?)\s+\|`)
- lastFMMirrorLinkTextRe = regexp.MustCompile(`\[([^\]]+)\]\(`)
- errLastFMInvalidSource = "unsupported source"
-)
-
-func addURLToQueue(ctx context.Context, mainApp *app.Main, raw string) bool {
- parsed := urlparse.Parse(raw)
- if parsed == nil {
- fmt.Printf("invalid: %s\n", raw)
- return false
- }
- if parsed.Kind != urlparse.KindGeneric && parsed.Kind != urlparse.KindSoundcloud {
- fmt.Printf("not yet supported: %s (kind=%s)\n", raw, parsed.Kind)
- return false
- }
- if parsed.Source != "qobuz" && parsed.Source != "tidal" && parsed.Source != "deezer" && parsed.Source != "soundcloud" {
- fmt.Printf("provider not yet implemented: source=%s url=%s\n", parsed.Source, raw)
- return false
- }
- if err := mainApp.AddByID(ctx, parsed.Source, parsed.MediaType, parsed.ID); err != nil {
- fmt.Printf("add failed: source=%s type=%s id=%s err=%v\n", parsed.Source, parsed.MediaType, parsed.ID, err)
- return false
- }
- return true
-}
-
-func parseFileInput(content []byte) ([]fileIDItem, []string, int, bool, error) {
- trimmed := strings.TrimSpace(string(content))
- if trimmed == "" {
- return nil, nil, 0, false, nil
- }
-
- var parsed any
- if err := json.Unmarshal([]byte(trimmed), &parsed); err == nil {
- arr, ok := parsed.([]any)
- if !ok {
- return nil, nil, 0, true, fmt.Errorf("json input must be an array of objects")
- }
- items := make([]fileIDItem, 0, len(arr))
- for i, raw := range arr {
- entry, ok := raw.(map[string]any)
- if !ok {
- return nil, nil, 0, true, fmt.Errorf("json item %d must be an object", i+1)
- }
- source := strings.ToLower(strings.TrimSpace(asString(entry["source"])))
- mediaType := strings.ToLower(strings.TrimSpace(asString(entry["media_type"])))
- if mediaType == "" {
- mediaType = strings.ToLower(strings.TrimSpace(asString(entry["mediaType"])))
- }
- id := strings.TrimSpace(asString(entry["id"]))
- if source == "" || mediaType == "" || id == "" {
- return nil, nil, 0, true, fmt.Errorf("json item %d missing source/media_type/id", i+1)
- }
- items = append(items, fileIDItem{Source: source, MediaType: mediaType, ID: id})
- }
- return items, nil, 0, true, nil
- }
-
- parts := strings.Fields(trimmed)
- if len(parts) == 0 {
- return nil, nil, 0, false, nil
- }
- seen := make(map[string]struct{}, len(parts))
- urls := make([]string, 0, len(parts))
- repeated := 0
- for _, raw := range parts {
- if _, ok := seen[raw]; ok {
- repeated++
- continue
- }
- seen[raw] = struct{}{}
- urls = append(urls, raw)
- }
- return nil, urls, repeated, false, nil
-}
-
-func promptYesNo(prompt string) (bool, error) {
- reader := bufio.NewReader(os.Stdin)
- fmt.Print(prompt)
- line, err := reader.ReadString('\n')
- if err != nil {
- return false, err
- }
- line = strings.ToLower(strings.TrimSpace(line))
- return line == "y" || line == "yes", nil
-}
-
-func openConfigInEditor(path string, vim bool) error {
- launch := func(name string, args ...string) error {
- cmd := exec.Command(name, args...)
- cmd.Stdin = os.Stdin
- cmd.Stdout = os.Stdout
- cmd.Stderr = os.Stderr
- return cmd.Run()
- }
-
- if vim {
- if p, err := exec.LookPath("nvim"); err == nil {
- return launch(p, path)
- }
- if p, err := exec.LookPath("vim"); err == nil {
- return launch(p, path)
- }
- }
-
- if editor := strings.TrimSpace(os.Getenv("EDITOR")); editor != "" {
- parts := strings.Fields(editor)
- if len(parts) > 0 {
- return launch(parts[0], append(parts[1:], path)...)
- }
- }
-
- switch runtime.GOOS {
- case "darwin":
- return launch("open", path)
- case "windows":
- return launch("cmd", "/c", "start", "", path)
- default:
- if p, err := exec.LookPath("xdg-open"); err == nil {
- return launch(p, path)
- }
- return fmt.Errorf("could not find an editor (set $EDITOR or install xdg-open)")
- }
-}
-
-func listDownloadsRows(path string) ([]string, error) {
- db, err := sql.Open("sqlite", path)
- if err != nil {
- return nil, err
- }
- defer func() { _ = db.Close() }()
-
- rows, err := db.Query(`SELECT id FROM downloads ORDER BY rowid`)
- if err != nil {
- if isNoSuchTableErr(err) {
- return []string{}, nil
- }
- return nil, err
- }
- defer func() { _ = rows.Close() }()
-
- out := []string{}
- for rows.Next() {
- var id string
- if err = rows.Scan(&id); err != nil {
- return nil, err
- }
- out = append(out, id)
- }
- return out, rows.Err()
-}
-
-func listFailedRows(path string) ([]failedRow, error) {
- db, err := sql.Open("sqlite", path)
- if err != nil {
- return nil, err
- }
- defer func() { _ = db.Close() }()
-
- rows, err := db.Query(`SELECT source, media_type, id FROM failed_downloads ORDER BY rowid`)
- if err != nil {
- return nil, err
- }
- defer func() { _ = rows.Close() }()
-
- out := []failedRow{}
- for rows.Next() {
- var r failedRow
- if err = rows.Scan(&r.Source, &r.MediaType, &r.ID); err != nil {
- return nil, err
- }
- out = append(out, r)
- }
- return out, rows.Err()
-}
-
-func isNoSuchTableErr(err error) bool {
- if err == nil {
- return false
- }
- return strings.Contains(strings.ToLower(err.Error()), "no such table")
-}
-
-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
-}
-
-type searchResult struct {
- ID string
- Title string
- Artist string
- Album string
- TrackCount int
- Explicit bool
-}
-
-type searchOptions struct {
- query string
- limit int
- ignoreDB bool
- noDownload bool
- first bool
- outputFile string
-}
-
-func parseSearchArgs(args []string, defaultLimit int) (searchOptions, error) {
- if defaultLimit <= 0 {
- defaultLimit = 20
- }
- limit := defaultLimit
- parts := make([]string, 0, len(args))
- ignoreDB := false
- noDownload := false
- first := false
- outputFile := ""
- for i := 0; i < len(args); i++ {
- if args[i] == "--" {
- if i+1 < len(args) {
- parts = append(parts, args[i+1:]...)
- }
- break
- }
- switch args[i] {
- case "--force", "--ignore-db":
- ignoreDB = true
- continue
- case "--no-download":
- noDownload = true
- continue
- case "--first":
- first = true
- continue
- case "--output-file":
- if i+1 >= len(args) {
- return searchOptions{}, fmt.Errorf("--output-file requires a path")
- }
- outputFile = strings.TrimSpace(args[i+1])
- if outputFile == "" {
- return searchOptions{}, fmt.Errorf("--output-file requires a non-empty path")
- }
- i++
- continue
- case "--num-results":
- if i+1 >= len(args) {
- return searchOptions{}, fmt.Errorf("--num-results requires a value")
- }
- v, err := strconv.Atoi(args[i+1])
- if err != nil || v <= 0 {
- return searchOptions{}, fmt.Errorf("invalid --num-results value %q", args[i+1])
- }
- limit = v
- i++
- continue
- }
- if args[i] == "--limit" {
- if i+1 >= len(args) {
- return searchOptions{}, fmt.Errorf("--limit requires a value")
- }
- v, err := strconv.Atoi(args[i+1])
- if err != nil || v <= 0 {
- return searchOptions{}, fmt.Errorf("invalid --limit value %q", args[i+1])
- }
- limit = v
- i++
- continue
- }
- if strings.HasPrefix(args[i], "-") {
- return searchOptions{}, fmt.Errorf("unknown option %q", args[i])
- }
- parts = append(parts, args[i])
- }
- return searchOptions{
- query: strings.TrimSpace(strings.Join(parts, " ")),
- limit: limit,
- ignoreDB: ignoreDB,
- noDownload: noDownload,
- first: first,
- outputFile: outputFile,
- }, nil
-}
-
-func promptSearchSelection(results []searchResult) ([]int, error) {
- reader := bufio.NewReader(os.Stdin)
- for {
- fmt.Print("Select results to download (e.g. 1,3-5; a=all; q=cancel): ")
- line, err := reader.ReadString('\n')
- if err != nil {
- return nil, err
- }
- line = strings.TrimSpace(line)
- if line == "" || strings.EqualFold(line, "q") || strings.EqualFold(line, "quit") {
- return nil, nil
- }
- if strings.EqualFold(line, "a") || strings.EqualFold(line, "all") {
- out := make([]int, 0, len(results))
- for i := range results {
- out = append(out, i)
- }
- return out, nil
- }
-
- selected := map[int]struct{}{}
- chunks := strings.Split(line, ",")
- ok := true
- for _, raw := range chunks {
- part := strings.TrimSpace(raw)
- if part == "" {
- continue
- }
- if strings.Contains(part, "-") {
- bounds := strings.SplitN(part, "-", 2)
- if len(bounds) != 2 {
- ok = false
- break
- }
- start, err1 := strconv.Atoi(strings.TrimSpace(bounds[0]))
- end, err2 := strconv.Atoi(strings.TrimSpace(bounds[1]))
- if err1 != nil || err2 != nil || start <= 0 || end <= 0 || start > end {
- ok = false
- break
- }
- for i := start; i <= end; i++ {
- if i > len(results) {
- ok = false
- break
- }
- selected[i-1] = struct{}{}
- }
- if !ok {
- break
- }
- continue
- }
- idx, err := strconv.Atoi(part)
- if err != nil || idx <= 0 || idx > len(results) {
- ok = false
- break
- }
- selected[idx-1] = struct{}{}
- }
-
- if !ok || len(selected) == 0 {
- fmt.Println("Invalid selection, try again.")
- continue
- }
-
- out := make([]int, 0, len(selected))
- for idx := range selected {
- out = append(out, idx)
- }
- for i := 1; i < len(out); i++ {
- for j := i; j > 0 && out[j] < out[j-1]; j-- {
- out[j], out[j-1] = out[j-1], out[j]
- }
- }
- return out, nil
- }
-}
-
-func promptSearchSelectionMenu(source, mediaType, query string, results []searchResult) ([]int, error) {
- if len(results) == 0 {
- return nil, nil
- }
-
- labels := make([]string, 0, len(results))
- labelToIndex := map[string]int{}
- for i, r := range results {
- artist := strings.TrimSpace(r.Artist)
- if artist == "" {
- artist = "Unknown Artist"
- }
- label := fmt.Sprintf("%2d. %s - %s", i+1, artist, r.Title)
- labels = append(labels, label)
- labelToIndex[label] = i
- }
-
- selected := []string{}
- prompt := &survey.MultiSelect{
- Message: fmt.Sprintf("Results for %s '%s' from %s", mediaType, query, strings.Title(source)),
- Help: "SPACE: select ENTER: download /: filter ESC: cancel",
- Options: labels,
- Description: func(value string, index int) string {
- resultIndex, ok := labelToIndex[value]
- if !ok || resultIndex < 0 || resultIndex >= len(results) {
- return ""
- }
- return formatSearchDetails(results[resultIndex])
- },
- PageSize: 15,
- }
- if err := survey.AskOne(prompt, &selected); err != nil {
- if strings.Contains(strings.ToLower(err.Error()), "interrupt") {
- return nil, nil
- }
- return nil, err
- }
-
- if len(selected) == 0 {
- return nil, nil
- }
- out := make([]int, 0, len(selected))
- for _, label := range selected {
- if idx, ok := labelToIndex[label]; ok {
- out = append(out, idx)
- }
- }
- for i := 1; i < len(out); i++ {
- for j := i; j > 0 && out[j] < out[j-1]; j-- {
- out[j], out[j-1] = out[j-1], out[j]
- }
- }
- return out, nil
-}
-
-func writeSearchResultsToFile(source, mediaType string, results []searchResult, path string) error {
- type outItem struct {
- Source string `json:"source"`
- MediaType string `json:"media_type"`
- ID string `json:"id"`
- Title string `json:"title"`
- }
- out := make([]outItem, 0, len(results))
- for _, r := range results {
- out = append(out, outItem{Source: source, MediaType: mediaType, ID: r.ID, Title: r.Title})
- }
- b, err := json.MarshalIndent(out, "", " ")
- if err != nil {
- return err
- }
- dir := filepath.Dir(path)
- if dir != "" && dir != "." {
- if err = os.MkdirAll(dir, 0o755); err != nil {
- return err
- }
- }
- return os.WriteFile(path, b, 0o644)
-}
-
-func isAllowedSearchSource(source string) bool {
- return source == "qobuz" || source == "tidal" || source == "deezer" || source == "soundcloud"
-}
-
-func isAllowedMediaType(mediaType string) bool {
- switch mediaType {
- case "track", "album", "playlist", "artist", "label", "video":
- return true
- default:
- return false
- }
-}
-
-func promptSearchInteractive(defaultLimit int) (string, string, searchOptions, error) {
- reader := bufio.NewReader(os.Stdin)
-
- read := func(prompt string) (string, error) {
- fmt.Print(prompt)
- line, err := reader.ReadString('\n')
- if err != nil {
- return "", err
- }
- return strings.TrimSpace(line), nil
- }
-
- for {
- source, err := read("Source [qobuz/tidal/deezer/soundcloud]: ")
- if err != nil {
- return "", "", searchOptions{}, err
- }
- source = strings.ToLower(source)
- if !isAllowedSearchSource(source) {
- fmt.Println("Invalid source.")
- continue
- }
-
- mediaType, err := read("Type [track/album/playlist/artist/label/video]: ")
- if err != nil {
- return "", "", searchOptions{}, err
- }
- mediaType = strings.ToLower(mediaType)
- if !isAllowedMediaType(mediaType) {
- fmt.Println("Invalid media type.")
- continue
- }
- if source == "soundcloud" && mediaType != "track" && mediaType != "playlist" {
- fmt.Println("SoundCloud search supports track and playlist only.")
- continue
- }
-
- query, err := read("Query: ")
- if err != nil {
- return "", "", searchOptions{}, err
- }
- if strings.TrimSpace(query) == "" {
- fmt.Println("Query cannot be empty.")
- continue
- }
-
- limitRaw, err := read(fmt.Sprintf("Limit [%d]: ", defaultLimit))
- if err != nil {
- return "", "", searchOptions{}, err
- }
- limit := defaultLimit
- if strings.TrimSpace(limitRaw) != "" {
- v, convErr := strconv.Atoi(limitRaw)
- if convErr != nil || v <= 0 {
- fmt.Println("Invalid limit.")
- continue
- }
- limit = v
- }
-
- return source, mediaType, searchOptions{query: query, limit: limit}, nil
- }
-}
-
-func normalizeSearchResults(source, mediaType string, pages []map[string]any) []searchResult {
- results := make([]searchResult, 0)
- seen := map[string]struct{}{}
- appendUnique := func(r searchResult) {
- if strings.TrimSpace(r.ID) == "" || strings.TrimSpace(r.Title) == "" {
- return
- }
- key := r.ID
- if _, ok := seen[key]; ok {
- return
- }
- seen[key] = struct{}{}
- results = append(results, r)
- }
- for _, page := range pages {
- switch source {
- case "qobuz":
- key := mediaType + "s"
- bucket, ok := page[key].(map[string]any)
- if !ok {
- continue
- }
- items, ok := bucket["items"].([]any)
- if !ok {
- continue
- }
- for _, raw := range items {
- itm, ok := raw.(map[string]any)
- if !ok {
- continue
- }
- id := asString(itm["id"])
- title := asString(itm["title"])
- if title == "" {
- title = asString(itm["name"])
- }
- if version := asString(itm["version"]); version != "" {
- title += " (" + version + ")"
- }
- artist := nestedSearchString(itm, "artist", "name")
- if artist == "" {
- artist = nestedSearchString(itm, "performer", "name")
- }
- album := nestedSearchString(itm, "album", "title")
- trackCount := searchInt(itm["tracks_count"])
- if trackCount == 0 {
- trackCount = searchInt(itm["track_count"])
- }
- explicit := searchBool(itm["parental_warning"])
- appendUnique(searchResult{ID: id, Title: title, Artist: artist, Album: album, TrackCount: trackCount, Explicit: explicit})
- }
- case "tidal":
- items, ok := page["items"].([]any)
- if !ok {
- continue
- }
- for _, raw := range items {
- itm, ok := raw.(map[string]any)
- if !ok {
- continue
- }
- if wrapped, ok := itm["item"].(map[string]any); ok {
- itm = wrapped
- }
- id := asString(itm["id"])
- title := asString(itm["title"])
- if title == "" {
- title = asString(itm["name"])
- }
- artist := nestedSearchString(itm, "artist", "name")
- if artist == "" {
- if artists, ok := itm["artists"].([]any); ok && len(artists) > 0 {
- if a0, ok := artists[0].(map[string]any); ok {
- artist = asString(a0["name"])
- }
- }
- }
- album := nestedSearchString(itm, "album", "title")
- trackCount := searchInt(itm["numberOfTracks"])
- if trackCount == 0 {
- trackCount = searchInt(itm["tracks_count"])
- }
- explicit := searchBool(itm["explicit"])
- appendUnique(searchResult{ID: id, Title: title, Artist: artist, Album: album, TrackCount: trackCount, Explicit: explicit})
- }
- case "deezer":
- key := mediaType + "s"
- bucket, ok := page[key].(map[string]any)
- if !ok {
- continue
- }
- items, ok := bucket["items"].([]any)
- if !ok {
- continue
- }
- for _, raw := range items {
- itm, ok := raw.(map[string]any)
- if !ok {
- continue
- }
- id := asString(itm["id"])
- title := asString(itm["title"])
- if title == "" {
- title = asString(itm["name"])
- }
- artist := nestedSearchString(itm, "artist", "name")
- album := nestedSearchString(itm, "album", "title")
- trackCount := searchInt(itm["nb_tracks"])
- explicit := searchBool(itm["explicit_lyrics"])
- appendUnique(searchResult{ID: id, Title: title, Artist: artist, Album: album, TrackCount: trackCount, Explicit: explicit})
- }
- case "soundcloud":
- items, ok := page["items"].([]any)
- if !ok {
- continue
- }
- for _, raw := range items {
- itm, ok := raw.(map[string]any)
- if !ok {
- continue
- }
- id := asString(itm["id"])
- title := asString(itm["title"])
- artist := nestedSearchString(itm, "artist", "name")
- trackCount := searchInt(itm["tracks_count"])
- appendUnique(searchResult{ID: id, Title: title, Artist: artist, TrackCount: trackCount})
- }
- }
- }
- return results
-}
-
-func formatSearchDetails(r searchResult) string {
- lines := []string{"Selected item", ""}
- lines = append(lines, fmt.Sprintf("Title : %s", r.Title))
- if strings.TrimSpace(r.Artist) != "" {
- lines = append(lines, fmt.Sprintf("Artist : %s", r.Artist))
- }
- if strings.TrimSpace(r.Album) != "" {
- lines = append(lines, fmt.Sprintf("Album : %s", r.Album))
- }
- if r.TrackCount > 0 {
- lines = append(lines, fmt.Sprintf("Tracks : %d", r.TrackCount))
- }
- if r.Explicit {
- lines = append(lines, "Explicit: yes")
- }
- lines = append(lines, fmt.Sprintf("ID : %s", r.ID))
- return strings.Join(lines, "\n")
-}
-
-func nestedSearchString(v map[string]any, keys ...string) string {
- cur := any(v)
- for _, key := range keys {
- m, ok := cur.(map[string]any)
- if !ok {
- return ""
- }
- cur = m[key]
- }
- return asString(cur)
-}
-
-func searchInt(v any) int {
- switch t := v.(type) {
- case int:
- return t
- case int64:
- return int(t)
- case float64:
- return int(t)
- case string:
- i, _ := strconv.Atoi(t)
- return i
- default:
- return 0
- }
-}
-
-func searchBool(v any) bool {
- b, ok := v.(bool)
- return ok && b
-}
diff --git a/cmd/rip/search.go b/cmd/rip/search.go
new file mode 100644
index 0000000..c27ebf8
--- /dev/null
+++ b/cmd/rip/search.go
@@ -0,0 +1,527 @@
+package main
+
+import (
+ "bufio"
+ "encoding/json"
+ "fmt"
+ "os"
+ "path/filepath"
+ "strconv"
+ "strings"
+
+ "github.com/AlecAivazis/survey/v2"
+
+ "streamrip-go/internal/jsonutil"
+)
+
+type searchResult struct {
+ ID string
+ Title string
+ Artist string
+ Album string
+ TrackCount int
+ Explicit bool
+}
+
+type searchOptions struct {
+ query string
+ limit int
+ ignoreDB bool
+ noDownload bool
+ first bool
+ outputFile string
+}
+
+func parseSearchArgs(args []string, defaultLimit int) (searchOptions, error) {
+ if defaultLimit <= 0 {
+ defaultLimit = 20
+ }
+ limit := defaultLimit
+ parts := make([]string, 0, len(args))
+ ignoreDB := false
+ noDownload := false
+ first := false
+ outputFile := ""
+ for i := 0; i < len(args); i++ {
+ if args[i] == "--" {
+ if i+1 < len(args) {
+ parts = append(parts, args[i+1:]...)
+ }
+ break
+ }
+ switch args[i] {
+ case "--force", "--ignore-db":
+ ignoreDB = true
+ continue
+ case "--no-download":
+ noDownload = true
+ continue
+ case "--first":
+ first = true
+ continue
+ case "--output-file":
+ if i+1 >= len(args) {
+ return searchOptions{}, fmt.Errorf("--output-file requires a path")
+ }
+ outputFile = strings.TrimSpace(args[i+1])
+ if outputFile == "" {
+ return searchOptions{}, fmt.Errorf("--output-file requires a non-empty path")
+ }
+ i++
+ continue
+ case "--num-results":
+ if i+1 >= len(args) {
+ return searchOptions{}, fmt.Errorf("--num-results requires a value")
+ }
+ v, err := strconv.Atoi(args[i+1])
+ if err != nil || v <= 0 {
+ return searchOptions{}, fmt.Errorf("invalid --num-results value %q", args[i+1])
+ }
+ limit = v
+ i++
+ continue
+ }
+ if args[i] == "--limit" {
+ if i+1 >= len(args) {
+ return searchOptions{}, fmt.Errorf("--limit requires a value")
+ }
+ v, err := strconv.Atoi(args[i+1])
+ if err != nil || v <= 0 {
+ return searchOptions{}, fmt.Errorf("invalid --limit value %q", args[i+1])
+ }
+ limit = v
+ i++
+ continue
+ }
+ if strings.HasPrefix(args[i], "-") {
+ return searchOptions{}, fmt.Errorf("unknown option %q", args[i])
+ }
+ parts = append(parts, args[i])
+ }
+ return searchOptions{
+ query: strings.TrimSpace(strings.Join(parts, " ")),
+ limit: limit,
+ ignoreDB: ignoreDB,
+ noDownload: noDownload,
+ first: first,
+ outputFile: outputFile,
+ }, nil
+}
+
+func promptSearchSelection(results []searchResult) ([]int, error) {
+ reader := bufio.NewReader(os.Stdin)
+ for {
+ fmt.Print("Select results to download (e.g. 1,3-5; a=all; q=cancel): ")
+ line, err := reader.ReadString('\n')
+ if err != nil {
+ return nil, err
+ }
+ line = strings.TrimSpace(line)
+ if line == "" || strings.EqualFold(line, "q") || strings.EqualFold(line, "quit") {
+ return nil, nil
+ }
+ if strings.EqualFold(line, "a") || strings.EqualFold(line, "all") {
+ out := make([]int, 0, len(results))
+ for i := range results {
+ out = append(out, i)
+ }
+ return out, nil
+ }
+
+ selected := map[int]struct{}{}
+ chunks := strings.Split(line, ",")
+ ok := true
+ for _, raw := range chunks {
+ part := strings.TrimSpace(raw)
+ if part == "" {
+ continue
+ }
+ if strings.Contains(part, "-") {
+ bounds := strings.SplitN(part, "-", 2)
+ if len(bounds) != 2 {
+ ok = false
+ break
+ }
+ start, err1 := strconv.Atoi(strings.TrimSpace(bounds[0]))
+ end, err2 := strconv.Atoi(strings.TrimSpace(bounds[1]))
+ if err1 != nil || err2 != nil || start <= 0 || end <= 0 || start > end {
+ ok = false
+ break
+ }
+ for i := start; i <= end; i++ {
+ if i > len(results) {
+ ok = false
+ break
+ }
+ selected[i-1] = struct{}{}
+ }
+ if !ok {
+ break
+ }
+ continue
+ }
+ idx, err := strconv.Atoi(part)
+ if err != nil || idx <= 0 || idx > len(results) {
+ ok = false
+ break
+ }
+ selected[idx-1] = struct{}{}
+ }
+
+ if !ok || len(selected) == 0 {
+ fmt.Println("Invalid selection, try again.")
+ continue
+ }
+
+ out := make([]int, 0, len(selected))
+ for idx := range selected {
+ out = append(out, idx)
+ }
+ for i := 1; i < len(out); i++ {
+ for j := i; j > 0 && out[j] < out[j-1]; j-- {
+ out[j], out[j-1] = out[j-1], out[j]
+ }
+ }
+ return out, nil
+ }
+}
+
+func promptSearchSelectionMenu(source, mediaType, query string, results []searchResult) ([]int, error) {
+ if len(results) == 0 {
+ return nil, nil
+ }
+
+ labels := make([]string, 0, len(results))
+ labelToIndex := map[string]int{}
+ for i, r := range results {
+ artist := strings.TrimSpace(r.Artist)
+ if artist == "" {
+ artist = "Unknown Artist"
+ }
+ label := fmt.Sprintf("%2d. %s - %s", i+1, artist, r.Title)
+ labels = append(labels, label)
+ labelToIndex[label] = i
+ }
+
+ selected := []string{}
+ prompt := &survey.MultiSelect{
+ Message: fmt.Sprintf("Results for %s '%s' from %s", mediaType, query, jsonutil.TitleCase(source)),
+ Help: "SPACE: select ENTER: download /: filter ESC: cancel",
+ Options: labels,
+ Description: func(value string, index int) string {
+ resultIndex, ok := labelToIndex[value]
+ if !ok || resultIndex < 0 || resultIndex >= len(results) {
+ return ""
+ }
+ return formatSearchDetails(results[resultIndex])
+ },
+ PageSize: 15,
+ }
+ if err := survey.AskOne(prompt, &selected); err != nil {
+ if strings.Contains(strings.ToLower(err.Error()), "interrupt") {
+ return nil, nil
+ }
+ return nil, err
+ }
+
+ if len(selected) == 0 {
+ return nil, nil
+ }
+ out := make([]int, 0, len(selected))
+ for _, label := range selected {
+ if idx, ok := labelToIndex[label]; ok {
+ out = append(out, idx)
+ }
+ }
+ for i := 1; i < len(out); i++ {
+ for j := i; j > 0 && out[j] < out[j-1]; j-- {
+ out[j], out[j-1] = out[j-1], out[j]
+ }
+ }
+ return out, nil
+}
+
+func writeSearchResultsToFile(source, mediaType string, results []searchResult, path string) error {
+ type outItem struct {
+ Source string `json:"source"`
+ MediaType string `json:"media_type"`
+ ID string `json:"id"`
+ Title string `json:"title"`
+ }
+ out := make([]outItem, 0, len(results))
+ for _, r := range results {
+ out = append(out, outItem{Source: source, MediaType: mediaType, ID: r.ID, Title: r.Title})
+ }
+ b, err := json.MarshalIndent(out, "", " ")
+ if err != nil {
+ return err
+ }
+ dir := filepath.Dir(path)
+ if dir != "" && dir != "." {
+ if err = os.MkdirAll(dir, 0o755); err != nil {
+ return err
+ }
+ }
+ return os.WriteFile(path, b, 0o644)
+}
+
+func isAllowedSearchSource(source string) bool {
+ return source == "qobuz" || source == "tidal" || source == "deezer" || source == "soundcloud"
+}
+
+func isAllowedMediaType(mediaType string) bool {
+ switch mediaType {
+ case "track", "album", "playlist", "artist", "label", "video":
+ return true
+ default:
+ return false
+ }
+}
+
+func promptSearchInteractive(defaultLimit int) (string, string, searchOptions, error) {
+ reader := bufio.NewReader(os.Stdin)
+
+ read := func(prompt string) (string, error) {
+ fmt.Print(prompt)
+ line, err := reader.ReadString('\n')
+ if err != nil {
+ return "", err
+ }
+ return strings.TrimSpace(line), nil
+ }
+
+ for {
+ source, err := read("Source [qobuz/tidal/deezer/soundcloud]: ")
+ if err != nil {
+ return "", "", searchOptions{}, err
+ }
+ source = strings.ToLower(source)
+ if !isAllowedSearchSource(source) {
+ fmt.Println("Invalid source.")
+ continue
+ }
+
+ mediaType, err := read("Type [track/album/playlist/artist/label/video]: ")
+ if err != nil {
+ return "", "", searchOptions{}, err
+ }
+ mediaType = strings.ToLower(mediaType)
+ if !isAllowedMediaType(mediaType) {
+ fmt.Println("Invalid media type.")
+ continue
+ }
+ if source == "soundcloud" && mediaType != "track" && mediaType != "playlist" {
+ fmt.Println("SoundCloud search supports track and playlist only.")
+ continue
+ }
+
+ query, err := read("Query: ")
+ if err != nil {
+ return "", "", searchOptions{}, err
+ }
+ if strings.TrimSpace(query) == "" {
+ fmt.Println("Query cannot be empty.")
+ continue
+ }
+
+ limitRaw, err := read(fmt.Sprintf("Limit [%d]: ", defaultLimit))
+ if err != nil {
+ return "", "", searchOptions{}, err
+ }
+ limit := defaultLimit
+ if strings.TrimSpace(limitRaw) != "" {
+ v, convErr := strconv.Atoi(limitRaw)
+ if convErr != nil || v <= 0 {
+ fmt.Println("Invalid limit.")
+ continue
+ }
+ limit = v
+ }
+
+ return source, mediaType, searchOptions{query: query, limit: limit}, nil
+ }
+}
+
+func normalizeSearchResults(source, mediaType string, pages []map[string]any) []searchResult {
+ results := make([]searchResult, 0)
+ seen := map[string]struct{}{}
+ appendUnique := func(r searchResult) {
+ if strings.TrimSpace(r.ID) == "" || strings.TrimSpace(r.Title) == "" {
+ return
+ }
+ key := r.ID
+ if _, ok := seen[key]; ok {
+ return
+ }
+ seen[key] = struct{}{}
+ results = append(results, r)
+ }
+ for _, page := range pages {
+ switch source {
+ case "qobuz":
+ key := mediaType + "s"
+ bucket, ok := page[key].(map[string]any)
+ if !ok {
+ continue
+ }
+ items, ok := bucket["items"].([]any)
+ if !ok {
+ continue
+ }
+ for _, raw := range items {
+ itm, ok := raw.(map[string]any)
+ if !ok {
+ continue
+ }
+ id := asString(itm["id"])
+ title := asString(itm["title"])
+ if title == "" {
+ title = asString(itm["name"])
+ }
+ if version := asString(itm["version"]); version != "" {
+ title += " (" + version + ")"
+ }
+ artist := nestedSearchString(itm, "artist", "name")
+ if artist == "" {
+ artist = nestedSearchString(itm, "performer", "name")
+ }
+ album := nestedSearchString(itm, "album", "title")
+ trackCount := searchInt(itm["tracks_count"])
+ if trackCount == 0 {
+ trackCount = searchInt(itm["track_count"])
+ }
+ explicit := searchBool(itm["parental_warning"])
+ appendUnique(searchResult{ID: id, Title: title, Artist: artist, Album: album, TrackCount: trackCount, Explicit: explicit})
+ }
+ case "tidal":
+ items, ok := page["items"].([]any)
+ if !ok {
+ continue
+ }
+ for _, raw := range items {
+ itm, ok := raw.(map[string]any)
+ if !ok {
+ continue
+ }
+ if wrapped, ok := itm["item"].(map[string]any); ok {
+ itm = wrapped
+ }
+ id := asString(itm["id"])
+ title := asString(itm["title"])
+ if title == "" {
+ title = asString(itm["name"])
+ }
+ artist := nestedSearchString(itm, "artist", "name")
+ if artist == "" {
+ if artists, ok := itm["artists"].([]any); ok && len(artists) > 0 {
+ if a0, ok := artists[0].(map[string]any); ok {
+ artist = asString(a0["name"])
+ }
+ }
+ }
+ album := nestedSearchString(itm, "album", "title")
+ trackCount := searchInt(itm["numberOfTracks"])
+ if trackCount == 0 {
+ trackCount = searchInt(itm["tracks_count"])
+ }
+ explicit := searchBool(itm["explicit"])
+ appendUnique(searchResult{ID: id, Title: title, Artist: artist, Album: album, TrackCount: trackCount, Explicit: explicit})
+ }
+ case "deezer":
+ key := mediaType + "s"
+ bucket, ok := page[key].(map[string]any)
+ if !ok {
+ continue
+ }
+ items, ok := bucket["items"].([]any)
+ if !ok {
+ continue
+ }
+ for _, raw := range items {
+ itm, ok := raw.(map[string]any)
+ if !ok {
+ continue
+ }
+ id := asString(itm["id"])
+ title := asString(itm["title"])
+ if title == "" {
+ title = asString(itm["name"])
+ }
+ artist := nestedSearchString(itm, "artist", "name")
+ album := nestedSearchString(itm, "album", "title")
+ trackCount := searchInt(itm["nb_tracks"])
+ explicit := searchBool(itm["explicit_lyrics"])
+ appendUnique(searchResult{ID: id, Title: title, Artist: artist, Album: album, TrackCount: trackCount, Explicit: explicit})
+ }
+ case "soundcloud":
+ items, ok := page["items"].([]any)
+ if !ok {
+ continue
+ }
+ for _, raw := range items {
+ itm, ok := raw.(map[string]any)
+ if !ok {
+ continue
+ }
+ id := asString(itm["id"])
+ title := asString(itm["title"])
+ artist := nestedSearchString(itm, "artist", "name")
+ trackCount := searchInt(itm["tracks_count"])
+ appendUnique(searchResult{ID: id, Title: title, Artist: artist, TrackCount: trackCount})
+ }
+ }
+ }
+ return results
+}
+
+func formatSearchDetails(r searchResult) string {
+ lines := []string{"Selected item", ""}
+ lines = append(lines, fmt.Sprintf("Title : %s", r.Title))
+ if strings.TrimSpace(r.Artist) != "" {
+ lines = append(lines, fmt.Sprintf("Artist : %s", r.Artist))
+ }
+ if strings.TrimSpace(r.Album) != "" {
+ lines = append(lines, fmt.Sprintf("Album : %s", r.Album))
+ }
+ if r.TrackCount > 0 {
+ lines = append(lines, fmt.Sprintf("Tracks : %d", r.TrackCount))
+ }
+ if r.Explicit {
+ lines = append(lines, "Explicit: yes")
+ }
+ lines = append(lines, fmt.Sprintf("ID : %s", r.ID))
+ return strings.Join(lines, "\n")
+}
+
+func nestedSearchString(v map[string]any, keys ...string) string {
+ cur := any(v)
+ for _, key := range keys {
+ m, ok := cur.(map[string]any)
+ if !ok {
+ return ""
+ }
+ cur = m[key]
+ }
+ return asString(cur)
+}
+
+func searchInt(v any) int {
+ switch t := v.(type) {
+ case int:
+ return t
+ case int64:
+ return int(t)
+ case float64:
+ return int(t)
+ case string:
+ i, _ := strconv.Atoi(t)
+ return i
+ default:
+ return 0
+ }
+}
+
+func searchBool(v any) bool {
+ b, ok := v.(bool)
+ return ok && b
+}
diff --git a/internal/app/app.go b/internal/app/app.go
index 1d9e882..b0d847c 100644
--- a/internal/app/app.go
+++ b/internal/app/app.go
@@ -18,6 +18,7 @@ import (
"streamrip-go/internal/config"
"streamrip-go/internal/domain/media"
"streamrip-go/internal/download"
+ "streamrip-go/internal/jsonutil"
"streamrip-go/internal/naming"
"streamrip-go/internal/provider"
deezerprovider "streamrip-go/internal/provider/deezer"
@@ -259,7 +260,7 @@ func (m *Main) AddMixedPlaylistByTrackRefs(ctx context.Context, playlistID, play
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 != "" {
+ if n := jsonutil.StringFromAny(meta["name"]); n != "" {
name = n
}
@@ -327,18 +328,18 @@ func (m *Main) ripVideo(ctx context.Context, p provider.Client, source, videoID
}
func buildCollectionAlbum(id string, meta map[string]any) collectionAlbum {
- trackCount := intFromAny(meta["tracks_count"])
+ trackCount := jsonutil.IntFromAny(meta["tracks_count"])
if trackCount == 0 {
- trackCount = intFromAny(meta["numberOfTracks"])
+ trackCount = jsonutil.IntFromAny(meta["numberOfTracks"])
}
return collectionAlbum{
ID: id,
Meta: meta,
Title: titleFromMetadata(meta, id),
- AlbumArtist: nestedString(meta, "artist", "name"),
- BitDepth: intFromAny(meta["maximum_bit_depth"]),
- Sampling: floatFromAny(meta["maximum_sampling_rate"]),
- Explicit: boolFromAny(meta["parental_warning"]),
+ AlbumArtist: jsonutil.NestedString(meta, "artist", "name"),
+ BitDepth: jsonutil.IntFromAny(meta["maximum_bit_depth"]),
+ Sampling: jsonutil.FloatFromAny(meta["maximum_sampling_rate"]),
+ Explicit: jsonutil.BoolFromAny(meta["parental_warning"]),
TrackCount: trackCount,
}
}
@@ -459,10 +460,10 @@ func extractAlbumIDs(meta map[string]any) []string {
if !ok {
continue
}
- id := stringFromAny(itm["id"])
+ id := jsonutil.StringFromAny(itm["id"])
if id == "" {
if nested, ok := itm["album"].(map[string]any); ok {
- id = stringFromAny(nested["id"])
+ id = jsonutil.StringFromAny(nested["id"])
}
}
if id == "" {
@@ -517,23 +518,23 @@ func (m *Main) ripAlbum(ctx context.Context, p provider.Client, source, albumID
}
albumTitle := titleFromMetadata(albumMeta, albumID)
- albumArtist := nestedString(albumMeta, "artist", "name")
+ albumArtist := jsonutil.NestedString(albumMeta, "artist", "name")
if albumArtist == "" {
albumArtist = "Unknown"
}
- releaseDate := stringFromAny(albumMeta["release_date_original"])
+ releaseDate := jsonutil.StringFromAny(albumMeta["release_date_original"])
if releaseDate == "" {
- releaseDate = stringFromAny(albumMeta["release_date"])
+ releaseDate = jsonutil.StringFromAny(albumMeta["release_date"])
}
if releaseDate == "" {
- releaseDate = stringFromAny(albumMeta["releaseDate"])
+ releaseDate = jsonutil.StringFromAny(albumMeta["releaseDate"])
}
if releaseDate == "" {
- releaseDate = stringFromAny(albumMeta["streamStartDate"])
+ releaseDate = jsonutil.StringFromAny(albumMeta["streamStartDate"])
}
year := naming.YearFromDate(releaseDate)
- bitDepth := intFromAny(albumMeta["maximum_bit_depth"])
- sampling := stringFromAny(albumMeta["maximum_sampling_rate"])
+ bitDepth := jsonutil.IntFromAny(albumMeta["maximum_bit_depth"])
+ sampling := jsonutil.StringFromAny(albumMeta["maximum_sampling_rate"])
if bitDepth == 0 || sampling == "" {
fallbackBitDepth, fallbackSampling := m.qualityProfileForSource(source)
if bitDepth == 0 {
@@ -564,7 +565,7 @@ func (m *Main) ripAlbum(ctx context.Context, p provider.Client, source, albumID
if !ok {
continue
}
- id := stringFromAny(itm["id"])
+ id := jsonutil.StringFromAny(itm["id"])
if id != "" {
trackIDs = append(trackIDs, id)
}
@@ -573,9 +574,9 @@ func (m *Main) ripAlbum(ctx context.Context, p provider.Client, source, albumID
folder := m.albumFolderPath(source, albumID, albumTitle, albumArtist, year, bitDepth, sampling)
artRes, _ := artwork.Prepare(ctx, m.DL, folder, albumMeta, m.Config.Session.Artwork, false)
total := len(trackIDs)
- discTotal := intFromAny(albumMeta["media_count"])
+ discTotal := jsonutil.IntFromAny(albumMeta["media_count"])
if discTotal == 0 {
- discTotal = intFromAny(albumMeta["numberOfVolumes"])
+ discTotal = jsonutil.IntFromAny(albumMeta["numberOfVolumes"])
}
m.logf("Album: %s (%d tracks)\n", albumTitle, total)
failures := 0
@@ -631,12 +632,12 @@ func (m *Main) ripPlaylist(ctx context.Context, p provider.Client, source, playl
}
name := titleFromMetadata(playlistMeta, playlistID)
- if n := stringFromAny(playlistMeta["name"]); n != "" {
+ if n := jsonutil.StringFromAny(playlistMeta["name"]); n != "" {
name = n
}
base := m.Config.Session.Downloads.Folder
if m.Config.Session.Downloads.SourceSubdirectories {
- base = filepath.Join(base, strings.Title(source))
+ base = filepath.Join(base, jsonutil.TitleCase(source))
}
folder := filepath.Join(base, naming.CleanName(name, naming.Config{
RestrictCharacters: m.Config.Session.Filepaths.RestrictCharacters,
@@ -665,9 +666,9 @@ func (m *Main) ripPlaylist(ctx context.Context, p provider.Client, source, playl
if !ok {
continue
}
- id := stringFromAny(itm["id"])
+ id := jsonutil.StringFromAny(itm["id"])
if id == "" {
- id = stringFromAny(itm["track_id"])
+ id = jsonutil.StringFromAny(itm["track_id"])
}
if id != "" {
ids = append(ids, id)
@@ -806,11 +807,9 @@ func (m *Main) requireSourceDownloadAuth(source string) error {
}
func (m *Main) ripTrack(ctx context.Context, p provider.Client, source, id, fallbackTitle string, opts ripTrackOptions) error {
- alreadyDownloaded, err := m.Store.IsDownloaded(ctx, source, id)
- if err == nil && alreadyDownloaded {
- if m.IgnoreDB {
- alreadyDownloaded = false
- } else {
+ if !m.IgnoreDB {
+ alreadyDownloaded, err := m.Store.IsDownloaded(ctx, source, id)
+ if err == nil && alreadyDownloaded {
if opts.total > 0 {
m.logf("[%d/%d] skip (already downloaded) id=%s\n", opts.index, opts.total, id)
} else {
@@ -820,19 +819,6 @@ func (m *Main) ripTrack(ctx context.Context, p provider.Client, source, id, fall
}
}
- if m.IgnoreDB {
- alreadyDownloaded = false
- }
-
- if alreadyDownloaded {
- if opts.total > 0 {
- m.logf("[%d/%d] skip (already downloaded) id=%s\n", opts.index, opts.total, id)
- } else {
- m.logf("skip (already downloaded) id=%s\n", id)
- }
- return nil
- }
-
meta, err := p.GetMetadata(ctx, id, "track")
if err != nil {
_ = m.Store.MarkFailed(ctx, source, "track", id)
@@ -970,7 +956,7 @@ func (m *Main) qualityProfileForSource(source string) (int, string) {
func (m *Main) albumFolderPath(source, albumID, albumTitle, albumArtist, year string, bitDepth int, samplingRate string) string {
base := m.Config.Session.Downloads.Folder
if m.Config.Session.Downloads.SourceSubdirectories {
- base = filepath.Join(base, strings.Title(source))
+ base = filepath.Join(base, jsonutil.TitleCase(source))
}
vals := map[string]string{
@@ -995,34 +981,34 @@ func (m *Main) albumFolderPath(source, albumID, albumTitle, albumArtist, year st
func (m *Main) trackOutputPath(source, id, title, ext string, trackMeta map[string]any, albumFolder string, albumDiscTotal int) string {
base := m.Config.Session.Downloads.Folder
if m.Config.Session.Downloads.SourceSubdirectories {
- base = filepath.Join(base, strings.Title(source))
+ base = filepath.Join(base, jsonutil.TitleCase(source))
}
if albumFolder == "" && m.Config.Session.Filepaths.AddSinglesToFolder {
- albumTitle := nestedString(trackMeta, "album", "title")
- albumID := nestedString(trackMeta, "album", "id")
+ albumTitle := jsonutil.NestedString(trackMeta, "album", "title")
+ albumID := jsonutil.NestedString(trackMeta, "album", "id")
if albumID == "" {
albumID = id
}
- albumArtist := nestedString(trackMeta, "album", "artist", "name")
+ albumArtist := jsonutil.NestedString(trackMeta, "album", "artist", "name")
if albumArtist == "" {
- albumArtist = nestedString(trackMeta, "performer", "name")
+ albumArtist = jsonutil.NestedString(trackMeta, "performer", "name")
}
- albumYear := naming.YearFromDate(stringFromAny(trackMeta["release_date_original"]))
+ albumYear := naming.YearFromDate(jsonutil.StringFromAny(trackMeta["release_date_original"]))
if albumYear == "Unknown" {
- albumYear = naming.YearFromDate(stringFromAny(trackMeta["release_date"]))
+ albumYear = naming.YearFromDate(jsonutil.StringFromAny(trackMeta["release_date"]))
}
- albumFolder = m.albumFolderPath(source, albumID, albumTitle, albumArtist, albumYear, intFromAny(trackMeta["maximum_bit_depth"]), stringFromAny(trackMeta["maximum_sampling_rate"]))
+ albumFolder = m.albumFolderPath(source, albumID, albumTitle, albumArtist, albumYear, jsonutil.IntFromAny(trackMeta["maximum_bit_depth"]), jsonutil.StringFromAny(trackMeta["maximum_sampling_rate"]))
}
if albumFolder != "" {
base = albumFolder
if m.Config.Session.Downloads.DiscSubdirectories && albumDiscTotal > 1 {
- discNumber := intFromAny(trackMeta["media_number"])
+ discNumber := jsonutil.IntFromAny(trackMeta["media_number"])
if discNumber == 0 {
- discNumber = intFromAny(trackMeta["volumeNumber"])
+ discNumber = jsonutil.IntFromAny(trackMeta["volumeNumber"])
}
if discNumber == 0 {
- discNumber = intFromAny(trackMeta["disk_number"])
+ discNumber = jsonutil.IntFromAny(trackMeta["disk_number"])
}
if discNumber == 0 {
discNumber = 1
@@ -1033,19 +1019,19 @@ func (m *Main) trackOutputPath(source, id, title, ext string, trackMeta map[stri
}
}
- trackNumber := intFromAny(trackMeta["track_number"])
+ trackNumber := jsonutil.IntFromAny(trackMeta["track_number"])
if trackNumber == 0 {
- trackNumber = intFromAny(trackMeta["trackNumber"])
+ trackNumber = jsonutil.IntFromAny(trackMeta["trackNumber"])
}
explicit := ""
- if boolFromAny(trackMeta["parental_warning"]) || boolFromAny(trackMeta["explicit"]) {
+ if jsonutil.BoolFromAny(trackMeta["parental_warning"]) || jsonutil.BoolFromAny(trackMeta["explicit"]) {
explicit = " (Explicit)"
}
- artist := nestedString(trackMeta, "performer", "name")
+ artist := jsonutil.NestedString(trackMeta, "performer", "name")
if artist == "" {
- artist = nestedString(trackMeta, "artist", "name")
+ artist = jsonutil.NestedString(trackMeta, "artist", "name")
}
- albumArtist := nestedString(trackMeta, "album", "artist", "name")
+ albumArtist := jsonutil.NestedString(trackMeta, "album", "artist", "name")
if albumArtist == "" {
albumArtist = artist
}
@@ -1073,7 +1059,7 @@ func (m *Main) videoOutputPath(source, id, title, ext string) string {
}
base := m.Config.Session.Downloads.Folder
if m.Config.Session.Downloads.SourceSubdirectories {
- base = filepath.Join(base, strings.Title(source))
+ base = filepath.Join(base, jsonutil.TitleCase(source))
}
fileName := naming.CleanName(title, naming.Config{
RestrictCharacters: m.Config.Session.Filepaths.RestrictCharacters,
@@ -1088,7 +1074,7 @@ func (m *Main) videoOutputPath(source, id, title, ext string) string {
func titleFromMetadata(meta map[string]any, fallback string) string {
if title, ok := meta["title"].(string); ok {
title = strings.TrimSpace(title)
- version := strings.TrimSpace(stringFromAny(meta["version"]))
+ version := strings.TrimSpace(jsonutil.StringFromAny(meta["version"]))
if version != "" {
return title + " (" + version + ")"
}
@@ -1099,70 +1085,8 @@ func titleFromMetadata(meta map[string]any, fallback string) string {
return fallback
}
-func nestedString(v map[string]any, keys ...string) string {
- return stringFromAny(nestedAny(v, keys...))
-}
-
-func nestedAny(v map[string]any, keys ...string) any {
- cur := any(v)
- for _, key := range keys {
- m, ok := cur.(map[string]any)
- if !ok {
- return nil
- }
- cur = m[key]
- }
- return cur
-}
-
-func stringFromAny(v any) string {
- switch t := v.(type) {
- case string:
- return t
- case float64:
- return strconv.FormatFloat(t, 'f', -1, 64)
- case int64:
- return strconv.FormatInt(t, 10)
- case int:
- return strconv.Itoa(t)
- default:
- return ""
- }
-}
-
-func intFromAny(v any) int {
- switch t := v.(type) {
- case int:
- return t
- case int64:
- return int(t)
- case float64:
- return int(t)
- default:
- return 0
- }
-}
-
-func floatFromAny(v any) float64 {
- switch t := v.(type) {
- case float64:
- return t
- case int:
- return float64(t)
- case int64:
- return float64(t)
- default:
- return 0
- }
-}
-
-func boolFromAny(v any) bool {
- b, _ := v.(bool)
- return b
-}
-
func replaygainGainFromAny(v any) string {
- s := strings.TrimSpace(stringFromAny(v))
+ s := strings.TrimSpace(jsonutil.StringFromAny(v))
if s == "" {
return ""
}
@@ -1183,7 +1107,7 @@ func replaygainGainFromAny(v any) string {
}
func replaygainPeakFromAny(v any) string {
- return strings.TrimSpace(stringFromAny(v))
+ return strings.TrimSpace(jsonutil.StringFromAny(v))
}
func trackMetaAlbum(trackMeta map[string]any) map[string]any {
@@ -1195,53 +1119,53 @@ func trackMetaAlbum(trackMeta map[string]any) map[string]any {
}
func buildTagMetadata(trackMeta map[string]any, title, source, trackID string, opts ripTrackOptions) tag.Metadata {
- artist := nestedString(trackMeta, "performer", "name")
+ artist := jsonutil.NestedString(trackMeta, "performer", "name")
if artist == "" {
- artist = nestedString(trackMeta, "artist", "name")
+ artist = jsonutil.NestedString(trackMeta, "artist", "name")
}
- albumArtist := nestedString(trackMeta, "album", "artist", "name")
+ albumArtist := jsonutil.NestedString(trackMeta, "album", "artist", "name")
if albumArtist == "" {
albumArtist = artist
}
if strings.TrimSpace(opts.albumArtist) != "" {
albumArtist = strings.TrimSpace(opts.albumArtist)
}
- trackNumber := intFromAny(trackMeta["track_number"])
+ trackNumber := jsonutil.IntFromAny(trackMeta["track_number"])
if trackNumber == 0 {
- trackNumber = intFromAny(trackMeta["trackNumber"])
+ trackNumber = jsonutil.IntFromAny(trackMeta["trackNumber"])
}
- discNumber := intFromAny(trackMeta["media_number"])
+ discNumber := jsonutil.IntFromAny(trackMeta["media_number"])
if discNumber == 0 {
- discNumber = intFromAny(trackMeta["volumeNumber"])
+ discNumber = jsonutil.IntFromAny(trackMeta["volumeNumber"])
}
if discNumber == 0 {
- discNumber = intFromAny(trackMeta["disk_number"])
+ discNumber = jsonutil.IntFromAny(trackMeta["disk_number"])
}
- date := stringFromAny(trackMeta["release_date_original"])
+ date := jsonutil.StringFromAny(trackMeta["release_date_original"])
if date == "" {
- date = stringFromAny(trackMeta["release_date"])
+ date = jsonutil.StringFromAny(trackMeta["release_date"])
}
if date == "" {
- date = stringFromAny(trackMeta["streamStartDate"])
+ date = jsonutil.StringFromAny(trackMeta["streamStartDate"])
}
- album := nestedString(trackMeta, "album", "title")
+ album := jsonutil.NestedString(trackMeta, "album", "title")
if album == "" {
- album = stringFromAny(trackMeta["title"])
+ album = jsonutil.StringFromAny(trackMeta["title"])
}
- trackTotal := intFromAny(trackMeta["tracks_count"])
+ trackTotal := jsonutil.IntFromAny(trackMeta["tracks_count"])
if trackTotal == 0 {
- trackTotal = intFromAny(trackMeta["numberOfTracks"])
+ trackTotal = jsonutil.IntFromAny(trackMeta["numberOfTracks"])
}
if trackTotal == 0 {
- trackTotal = intFromAny(trackMeta["track_total"])
+ trackTotal = jsonutil.IntFromAny(trackMeta["track_total"])
}
if opts.forPlaylist && opts.total > 0 {
trackTotal = opts.total
}
- discTotal := intFromAny(trackMeta["media_count"])
+ discTotal := jsonutil.IntFromAny(trackMeta["media_count"])
if discTotal == 0 {
- discTotal = intFromAny(trackMeta["numberOfVolumes"])
+ discTotal = jsonutil.IntFromAny(trackMeta["numberOfVolumes"])
}
if discTotal == 0 && opts.albumDiscTotal > 0 {
discTotal = opts.albumDiscTotal
@@ -1253,15 +1177,15 @@ func buildTagMetadata(trackMeta map[string]any, title, source, trackID string, o
discNumber = 1
}
- genre := nestedString(trackMeta, "genre", "name")
+ genre := jsonutil.NestedString(trackMeta, "genre", "name")
if genre == "" {
- genre = stringFromAny(trackMeta["genre"])
+ genre = jsonutil.StringFromAny(trackMeta["genre"])
}
- comment := stringFromAny(trackMeta["comment"])
- description := stringFromAny(trackMeta["description"])
- lyrics := stringFromAny(trackMeta["lyrics"])
- if lrc := stringFromAny(trackMeta["lyrics_synced"]); lrc != "" {
+ comment := jsonutil.StringFromAny(trackMeta["comment"])
+ description := jsonutil.StringFromAny(trackMeta["description"])
+ lyrics := jsonutil.StringFromAny(trackMeta["lyrics"])
+ if lrc := jsonutil.StringFromAny(trackMeta["lyrics_synced"]); lrc != "" {
lyrics = lrc
}
trackGain := replaygainGainFromAny(trackMeta["replaygain_track_gain"])
@@ -1273,7 +1197,7 @@ func buildTagMetadata(trackMeta map[string]any, title, source, trackID string, o
}
albumGain := replaygainGainFromAny(trackMeta["replaygain_album_gain"])
if albumGain == "" {
- albumGain = replaygainGainFromAny(nestedAny(trackMeta, "album", "replaygain_album_gain"))
+ albumGain = replaygainGainFromAny(jsonutil.NestedAny(trackMeta, "album", "replaygain_album_gain"))
}
trackPeak := replaygainPeakFromAny(trackMeta["replaygain_track_peak"])
if trackPeak == "" {
@@ -1281,22 +1205,22 @@ func buildTagMetadata(trackMeta map[string]any, title, source, trackID string, o
}
albumPeak := replaygainPeakFromAny(trackMeta["replaygain_album_peak"])
if albumPeak == "" {
- albumPeak = replaygainPeakFromAny(nestedAny(trackMeta, "album", "replaygain_album_peak"))
+ albumPeak = replaygainPeakFromAny(jsonutil.NestedAny(trackMeta, "album", "replaygain_album_peak"))
}
- sourceAlbumID := nestedString(trackMeta, "album", "id")
+ sourceAlbumID := jsonutil.NestedString(trackMeta, "album", "id")
if sourceAlbumID == "" {
- sourceAlbumID = stringFromAny(trackMeta["source_album_id"])
+ sourceAlbumID = jsonutil.StringFromAny(trackMeta["source_album_id"])
}
- sourceArtistID := nestedString(trackMeta, "artist", "id")
+ sourceArtistID := jsonutil.NestedString(trackMeta, "artist", "id")
if sourceArtistID == "" {
- sourceArtistID = nestedString(trackMeta, "performer", "id")
+ sourceArtistID = jsonutil.NestedString(trackMeta, "performer", "id")
}
if sourceArtistID == "" {
- sourceArtistID = stringFromAny(trackMeta["source_artist_id"])
+ sourceArtistID = jsonutil.StringFromAny(trackMeta["source_artist_id"])
}
sourceTrackID := trackID
- if v := stringFromAny(trackMeta["source_track_id"]); v != "" {
+ if v := jsonutil.StringFromAny(trackMeta["source_track_id"]); v != "" {
sourceTrackID = v
}
@@ -1314,8 +1238,8 @@ func buildTagMetadata(trackMeta map[string]any, title, source, trackID string, o
Comment: comment,
Description: description,
Lyrics: lyrics,
- Copyright: stringFromAny(trackMeta["copyright"]),
- ISRC: stringFromAny(trackMeta["isrc"]),
+ Copyright: jsonutil.StringFromAny(trackMeta["copyright"]),
+ ISRC: jsonutil.StringFromAny(trackMeta["isrc"]),
ReplaygainTrackGain: trackGain,
ReplaygainAlbumGain: albumGain,
ReplaygainTrackPeak: trackPeak,
diff --git a/internal/download/downloader.go b/internal/download/downloader.go
index 5cdaaf8..ef4c0a1 100644
--- a/internal/download/downloader.go
+++ b/internal/download/downloader.go
@@ -553,31 +553,6 @@ const deezerBFChunkSize = 2048
var deezerBFIV = []byte{0, 1, 2, 3, 4, 5, 6, 7}
-func decryptDeezerBFCBCStripe(in []byte, trackID string) ([]byte, error) {
- block, err := blowfish.NewCipher(deriveDeezerBlowfishKey(trackID))
- if err != nil {
- return nil, err
- }
- out := make([]byte, len(in))
- for i := 0; i*deezerBFChunkSize < len(in); i++ {
- start := i * deezerBFChunkSize
- end := start + deezerBFChunkSize
- if end > len(in) {
- end = len(in)
- }
- chunk := in[start:end]
- if i%3 == 0 && len(chunk) == deezerBFChunkSize {
- dec := make([]byte, len(chunk))
- mode := cipher.NewCBCDecrypter(block, deezerBFIV)
- mode.CryptBlocks(dec, chunk)
- copy(out[start:end], dec)
- } else {
- copy(out[start:end], chunk)
- }
- }
- return out, nil
-}
-
func deriveDeezerBlowfishKey(trackID string) []byte {
sum := md5.Sum([]byte(trackID))
md5Hex := fmt.Sprintf("%x", sum)
@@ -588,20 +563,3 @@ func deriveDeezerBlowfishKey(trackID string) []byte {
}
return key
}
-
-func normalizeDeezerTrackID(raw string) string {
- trimmed := strings.TrimSpace(raw)
- if trimmed == "" {
- return ""
- }
- if _, err := strconv.Atoi(trimmed); err == nil {
- return trimmed
- }
- parts := strings.Split(strings.Trim(trimmed, "/"), "/")
- for i := len(parts) - 1; i >= 0; i-- {
- if _, err := strconv.Atoi(parts[i]); err == nil {
- return parts[i]
- }
- }
- return trimmed
-}
diff --git a/internal/download/downloader_test.go b/internal/download/downloader_test.go
index ace490b..3f1407f 100644
--- a/internal/download/downloader_test.go
+++ b/internal/download/downloader_test.go
@@ -66,36 +66,15 @@ func TestManifestDetection(t *testing.T) {
}
}
-func TestNormalizeDeezerTrackID(t *testing.T) {
- if got := normalizeDeezerTrackID("https://www.deezer.com/track/3135556"); got != "3135556" {
- t.Fatalf("normalize track id = %q, want 3135556", got)
- }
-}
-
-func TestDecryptDeezerBFCBCStripe(t *testing.T) {
+func TestDeezerBlowfishKeyDerivation(t *testing.T) {
trackID := "3135556"
- plain := make([]byte, deezerBFChunkSize*2)
- for i := range plain {
- plain[i] = byte(i % 251)
- }
- enc := make([]byte, len(plain))
- copy(enc, plain)
- block, err := blowfish.NewCipher(deriveDeezerBlowfishKey(trackID))
- if err != nil {
- t.Fatalf("cipher error: %v", err)
- }
- cbc := cipher.NewCBCEncrypter(block, deezerBFIV)
- cbc.CryptBlocks(enc[:deezerBFChunkSize], enc[:deezerBFChunkSize])
-
- dec, err := decryptDeezerBFCBCStripe(enc, trackID)
- if err != nil {
- t.Fatalf("decrypt error: %v", err)
- }
- if len(dec) != len(plain) || string(dec) != string(plain) {
- t.Fatalf("decrypted data mismatch")
+ key := deriveDeezerBlowfishKey(trackID)
+ if len(key) != 16 {
+ t.Fatalf("blowfish key len = %d, want 16", len(key))
}
}
+
func TestFileDeezerEncrypted(t *testing.T) {
trackID := "3135556"
plain := make([]byte, deezerBFChunkSize+777)
diff --git a/internal/jsonutil/jsonutil.go b/internal/jsonutil/jsonutil.go
new file mode 100644
index 0000000..d0219ca
--- /dev/null
+++ b/internal/jsonutil/jsonutil.go
@@ -0,0 +1,131 @@
+// Package jsonutil provides shared helpers for working with untyped JSON
+// values (map[string]any) that come from API responses across all providers.
+package jsonutil
+
+import (
+ "strconv"
+ "strings"
+)
+
+// StringFromAny converts a dynamic JSON value to a string.
+// Numeric types are formatted without trailing zeroes.
+func StringFromAny(v any) string {
+ switch t := v.(type) {
+ case string:
+ return t
+ case int:
+ return strconv.Itoa(t)
+ case int64:
+ return strconv.FormatInt(t, 10)
+ case float64:
+ return strconv.FormatFloat(t, 'f', -1, 64)
+ default:
+ return ""
+ }
+}
+
+// IntFromAny converts a dynamic JSON value to an int.
+// Handles int, int64, float64, and string types.
+func IntFromAny(v any) int {
+ switch t := v.(type) {
+ case int:
+ return t
+ case int32:
+ return int(t)
+ case int64:
+ return int(t)
+ case float64:
+ return int(t)
+ case string:
+ i, _ := strconv.Atoi(strings.TrimSpace(t))
+ return i
+ default:
+ return 0
+ }
+}
+
+// FloatFromAny converts a dynamic JSON value to a float64.
+func FloatFromAny(v any) float64 {
+ switch t := v.(type) {
+ case float64:
+ return t
+ case int:
+ return float64(t)
+ case int64:
+ return float64(t)
+ default:
+ return 0
+ }
+}
+
+// BoolFromAny converts a dynamic JSON value to a bool.
+// Supports bool, string ("true"/"1"/"yes"), and numeric types.
+func BoolFromAny(v any) bool {
+ switch t := v.(type) {
+ case bool:
+ return t
+ case string:
+ l := strings.ToLower(strings.TrimSpace(t))
+ return l == "1" || l == "true" || l == "yes"
+ case int:
+ return t != 0
+ case int64:
+ return t != 0
+ case float64:
+ return t != 0
+ default:
+ return false
+ }
+}
+
+// FirstNonEmpty returns the first string in items that is non-empty after trimming.
+func FirstNonEmpty(items ...string) string {
+ for _, item := range items {
+ if strings.TrimSpace(item) != "" {
+ return strings.TrimSpace(item)
+ }
+ }
+ return ""
+}
+
+// NestedMap returns the value at m[key] as a map[string]any.
+// Returns an empty map if the key is missing or the value is not a map.
+func NestedMap(m map[string]any, key string) map[string]any {
+ v, ok := m[key].(map[string]any)
+ if !ok {
+ return map[string]any{}
+ }
+ return v
+}
+
+// NestedAny traverses a chain of map keys and returns the final value.
+func NestedAny(v map[string]any, keys ...string) any {
+ cur := any(v)
+ for _, key := range keys {
+ m, ok := cur.(map[string]any)
+ if !ok {
+ return nil
+ }
+ cur = m[key]
+ }
+ return cur
+}
+
+// NestedString traverses a chain of map keys and returns the final value as a string.
+func NestedString(v map[string]any, keys ...string) string {
+ return StringFromAny(NestedAny(v, keys...))
+}
+
+// TitleCase capitalises the first rune of s. This is a simple ASCII replacement
+// for the deprecated strings.Title function, suitable for source names like
+// "qobuz" → "Qobuz".
+func TitleCase(s string) string {
+ if s == "" {
+ return s
+ }
+ r := []rune(s)
+ if r[0] >= 'a' && r[0] <= 'z' {
+ r[0] -= 'a' - 'A'
+ }
+ return string(r)
+}
diff --git a/internal/provider/deezer/client.go b/internal/provider/deezer/client.go
index a4c0aa3..eb2192b 100644
--- a/internal/provider/deezer/client.go
+++ b/internal/provider/deezer/client.go
@@ -17,6 +17,7 @@ import (
"time"
"streamrip-go/internal/config"
+ "streamrip-go/internal/jsonutil"
"streamrip-go/internal/netutil"
"streamrip-go/internal/provider"
"streamrip-go/internal/ratelimit"
@@ -152,7 +153,7 @@ func (c *Client) GetMetadata(ctx context.Context, item, mediaType string) (map[s
return nil, err
}
enrichTrack(resp)
- if lyr, lyrErr := c.fetchLyricsFromPipe(ctx, strings.TrimSpace(stringFromAny(resp["id"]))); lyrErr == nil {
+ if lyr, lyrErr := c.fetchLyricsFromPipe(ctx, strings.TrimSpace(jsonutil.StringFromAny(resp["id"]))); lyrErr == nil {
if strings.TrimSpace(lyr.Text) != "" {
resp["lyrics"] = lyr.Text
}
@@ -205,7 +206,7 @@ func (c *Client) GetMetadata(ctx context.Context, item, mediaType string) (map[s
case "artist":
name := strings.TrimSpace(item)
if artistMeta, artistErr := c.apiGet(ctx, "/artist/"+item, nil); artistErr == nil {
- if n := strings.TrimSpace(stringFromAny(artistMeta["name"])); n != "" {
+ if n := strings.TrimSpace(jsonutil.StringFromAny(artistMeta["name"])); n != "" {
name = n
}
}
@@ -246,7 +247,7 @@ func (c *Client) getArtistAlbums(ctx context.Context, artistID string) (map[stri
data, _ := resp["data"].([]any)
all = append(all, data...)
if total < 0 {
- total = intFromAny(resp["total"])
+ total = jsonutil.IntFromAny(resp["total"])
}
if len(data) < pageSize {
break
@@ -288,7 +289,7 @@ func (c *Client) GetDownloadable(ctx context.Context, item string, _ int) (*prov
if err != nil {
return nil, err
}
- trackToken := strings.TrimSpace(stringFromAny(meta["track_token"]))
+ trackToken := strings.TrimSpace(jsonutil.StringFromAny(meta["track_token"]))
if trackToken == "" {
trackToken, err = c.getTrackToken(ctx, item)
if err != nil {
@@ -303,7 +304,7 @@ func (c *Client) GetDownloadable(ctx context.Context, item string, _ int) (*prov
if ext == "" {
ext = "mp3"
}
- trackID := strings.TrimSpace(stringFromAny(meta["id"]))
+ trackID := strings.TrimSpace(jsonutil.StringFromAny(meta["id"]))
if trackID == "" {
trackID = strings.TrimSpace(item)
}
@@ -344,9 +345,9 @@ func (c *Client) apiGet(ctx context.Context, path string, params url.Values) (ma
return nil, fmt.Errorf("deezer api failed: status=%d body=%s", resp.StatusCode, string(body))
}
if errObj, ok := out["error"].(map[string]any); ok {
- msg := strings.TrimSpace(stringFromAny(errObj["message"]))
+ msg := strings.TrimSpace(jsonutil.StringFromAny(errObj["message"]))
if msg == "" {
- msg = strings.TrimSpace(stringFromAny(errObj["type"]))
+ msg = strings.TrimSpace(jsonutil.StringFromAny(errObj["type"]))
}
if msg == "" {
msg = "unknown deezer error"
@@ -394,17 +395,17 @@ func (c *Client) refreshSessionFromARL(ctx context.Context) error {
return err
}
if errObj, ok := out["error"].(map[string]any); ok && len(errObj) > 0 {
- return fmt.Errorf("deezer getUserData error: %s", stringFromAny(errObj["message"]))
+ return fmt.Errorf("deezer getUserData error: %s", jsonutil.StringFromAny(errObj["message"]))
}
results, _ := out["results"].(map[string]any)
if len(results) == 0 {
return errors.New("deezer getUserData returned empty results")
}
- c.sid = firstNonEmpty(c.sid, sidFromCookies(c.http, webGWLight))
+ c.sid = jsonutil.FirstNonEmpty(c.sid, sidFromCookies(c.http, webGWLight))
c.license = findStringByKey(results, "license_token")
c.userID = findStringByKey(results, "USER_ID")
- c.jwt = firstNonEmpty(c.jwt, findStringByKey(results, "JWT"))
- c.refresh = firstNonEmpty(c.refresh, findStringByKey(results, "refresh_token"))
+ c.jwt = jsonutil.FirstNonEmpty(c.jwt, findStringByKey(results, "JWT"))
+ c.refresh = jsonutil.FirstNonEmpty(c.refresh, findStringByKey(results, "refresh_token"))
if c.sid == "" {
if sid, sidErr := c.bootstrapSID(ctx); sidErr == nil {
c.sid = sid
@@ -460,7 +461,7 @@ func (c *Client) loginWithCredentials(ctx context.Context, email, password strin
if err != nil {
return err
}
- c.sid = firstNonEmpty(c.sid, sid)
+ c.sid = jsonutil.FirstNonEmpty(c.sid, sid)
encryptedPassword, err := encryptPassword(mobileToken, password)
if err != nil {
@@ -515,22 +516,22 @@ func (c *Client) loginWithCredentials(ctx context.Context, email, password strin
return err
}
if errObj, ok := out["error"].(map[string]any); ok && len(errObj) > 0 {
- msg := firstNonEmpty(stringFromAny(errObj["message"]), stringFromAny(errObj["type"]))
+ msg := jsonutil.FirstNonEmpty(jsonutil.StringFromAny(errObj["message"]), jsonutil.StringFromAny(errObj["type"]))
if msg == "" {
msg = "unknown mobile_userAuth error"
}
return errors.New(msg)
}
- results := nestedMap(out, "results")
+ results := jsonutil.NestedMap(out, "results")
if len(results) == 0 {
return errors.New("mobile_userAuth returned empty results")
}
- c.arl = firstNonEmpty(c.arl, findStringByKey(results, "ARL"))
- c.jwt = firstNonEmpty(c.jwt, findStringByKey(results, "JWT"))
- c.refresh = firstNonEmpty(c.refresh, findStringByKey(results, "refresh_token"))
- c.license = firstNonEmpty(c.license, findStringByKey(results, "license_token"))
- c.userID = firstNonEmpty(c.userID, findStringByKey(results, "USER_ID"))
+ c.arl = jsonutil.FirstNonEmpty(c.arl, findStringByKey(results, "ARL"))
+ c.jwt = jsonutil.FirstNonEmpty(c.jwt, findStringByKey(results, "JWT"))
+ c.refresh = jsonutil.FirstNonEmpty(c.refresh, findStringByKey(results, "refresh_token"))
+ c.license = jsonutil.FirstNonEmpty(c.license, findStringByKey(results, "license_token"))
+ c.userID = jsonutil.FirstNonEmpty(c.userID, findStringByKey(results, "USER_ID"))
if c.arl == "" {
return errors.New("mobile_userAuth missing arl")
@@ -558,7 +559,7 @@ func (c *Client) getTrackToken(ctx context.Context, trackID string) (string, err
if err != nil {
return "", err
}
- token := strings.TrimSpace(stringFromAny(resp["track_token"]))
+ token := strings.TrimSpace(jsonutil.StringFromAny(resp["track_token"]))
if token == "" {
return "", errors.New("deezer track metadata missing track_token")
}
@@ -613,8 +614,8 @@ func (c *Client) fetchLyricsFromPipe(ctx context.Context, trackID string) (*lyri
msg := ""
typ := ""
if em, ok := errs[0].(map[string]any); ok {
- msg = strings.TrimSpace(stringFromAny(em["message"]))
- typ = strings.TrimSpace(stringFromAny(em["type"]))
+ msg = strings.TrimSpace(jsonutil.StringFromAny(em["message"]))
+ typ = strings.TrimSpace(jsonutil.StringFromAny(em["type"]))
}
if strings.EqualFold(typ, "JwtTokenExpiredError") || strings.Contains(strings.ToLower(msg), "not valid anymore") || strings.Contains(strings.ToLower(msg), "jwt") && strings.Contains(strings.ToLower(msg), "expired") {
return nil, errDeezerJWTExpired
@@ -624,8 +625,8 @@ func (c *Client) fetchLyricsFromPipe(ctx context.Context, trackID string) (*lyri
}
return nil, errors.New(msg)
}
- lyrics := nestedMap(nestedMap(nestedMap(out, "data"), "track"), "lyrics")
- text := strings.TrimSpace(stringFromAny(lyrics["text"]))
+ lyrics := jsonutil.NestedMap(jsonutil.NestedMap(jsonutil.NestedMap(out, "data"), "track"), "lyrics")
+ text := strings.TrimSpace(jsonutil.StringFromAny(lyrics["text"]))
synced := buildSyncedLRC(lyrics["synchronizedLines"])
if text != "" || synced != "" {
return &lyricsResult{Text: text, SyncedLRC: synced}, nil
@@ -637,9 +638,9 @@ func (c *Client) fetchLyricsFromPipe(ctx context.Context, trackID string) (*lyri
if !ok {
continue
}
- line := strings.TrimSpace(stringFromAny(m["line"]))
+ line := strings.TrimSpace(jsonutil.StringFromAny(m["line"]))
if line == "" {
- line = strings.TrimSpace(stringFromAny(m["lineTranslated"]))
+ line = strings.TrimSpace(jsonutil.StringFromAny(m["lineTranslated"]))
}
if line != "" {
parts = append(parts, line)
@@ -682,14 +683,14 @@ func buildSyncedLRC(v any) string {
if !ok {
continue
}
- line := strings.TrimSpace(stringFromAny(m["line"]))
+ line := strings.TrimSpace(jsonutil.StringFromAny(m["line"]))
if line == "" {
- line = strings.TrimSpace(stringFromAny(m["lineTranslated"]))
+ line = strings.TrimSpace(jsonutil.StringFromAny(m["lineTranslated"]))
}
if line == "" {
continue
}
- ms := intFromAny(m["milliseconds"])
+ ms := jsonutil.IntFromAny(m["milliseconds"])
out = append(out, fmt.Sprintf("[%02d:%05.2f]%s", ms/60000, float64(ms%60000)/1000.0, line))
}
return strings.Join(out, "\n")
@@ -741,13 +742,13 @@ func (c *Client) mobileAuth(ctx context.Context) (string, error) {
return "", err
}
if errObj, ok := out["error"].(map[string]any); ok && len(errObj) > 0 {
- msg := firstNonEmpty(stringFromAny(errObj["message"]), stringFromAny(errObj["type"]))
+ msg := jsonutil.FirstNonEmpty(jsonutil.StringFromAny(errObj["message"]), jsonutil.StringFromAny(errObj["type"]))
if msg == "" {
msg = "mobile_auth returned an error"
}
return "", errors.New(msg)
}
- token := findStringByKey(nestedMap(out, "results"), "TOKEN")
+ token := findStringByKey(jsonutil.NestedMap(out, "results"), "TOKEN")
if token == "" {
return "", errors.New("mobile_auth returned empty token")
}
@@ -788,13 +789,13 @@ func (c *Client) apiCheckToken(ctx context.Context, authToken string) (string, e
return "", err
}
if errObj, ok := out["error"].(map[string]any); ok && len(errObj) > 0 {
- msg := firstNonEmpty(stringFromAny(errObj["message"]), stringFromAny(errObj["type"]))
+ msg := jsonutil.FirstNonEmpty(jsonutil.StringFromAny(errObj["message"]), jsonutil.StringFromAny(errObj["type"]))
if msg == "" {
msg = "api_checkToken returned an error"
}
return "", errors.New(msg)
}
- sid := strings.TrimSpace(stringFromAny(out["results"]))
+ sid := strings.TrimSpace(jsonutil.StringFromAny(out["results"]))
if sid == "" {
return "", errors.New("api_checkToken returned empty sid")
}
@@ -852,13 +853,13 @@ func (c *Client) mobileUserAutolog(ctx context.Context) error {
if errObj, ok := out["error"].(map[string]any); ok && len(errObj) > 0 {
continue
}
- results := nestedMap(out, "results")
+ results := jsonutil.NestedMap(out, "results")
if len(results) == 0 {
continue
}
- c.jwt = firstNonEmpty(c.jwt, findStringByKey(results, "JWT"))
- c.refresh = firstNonEmpty(c.refresh, findStringByKey(results, "refresh_token"))
- c.license = firstNonEmpty(c.license, findStringByKey(results, "license_token"))
+ c.jwt = jsonutil.FirstNonEmpty(c.jwt, findStringByKey(results, "JWT"))
+ c.refresh = jsonutil.FirstNonEmpty(c.refresh, findStringByKey(results, "refresh_token"))
+ c.license = jsonutil.FirstNonEmpty(c.license, findStringByKey(results, "license_token"))
if c.jwt != "" || c.license != "" {
return nil
}
@@ -895,16 +896,16 @@ func (c *Client) refreshJWT(ctx context.Context) error {
return errors.New("invalid jwt refresh response")
}
if errObj, ok := out["error"].(map[string]any); ok && len(errObj) > 0 {
- msg := firstNonEmpty(stringFromAny(errObj["message"]), stringFromAny(errObj["type"]))
+ msg := jsonutil.FirstNonEmpty(jsonutil.StringFromAny(errObj["message"]), jsonutil.StringFromAny(errObj["type"]))
if msg == "" {
msg = "jwt refresh returned an error"
}
return errors.New(msg)
}
- if jwt := strings.TrimSpace(stringFromAny(out["jwt"])); jwt != "" {
+ if jwt := strings.TrimSpace(jsonutil.StringFromAny(out["jwt"])); jwt != "" {
c.jwt = jwt
}
- if rt := strings.TrimSpace(stringFromAny(out["refresh_token"])); rt != "" {
+ if rt := strings.TrimSpace(jsonutil.StringFromAny(out["refresh_token"])); rt != "" {
c.refresh = rt
}
if c.jwt == "" {
@@ -951,7 +952,7 @@ func (c *Client) refreshLicenseFromPipe(ctx context.Context) error {
if errs, ok := out["errors"].([]any); ok && len(errs) > 0 {
msg := ""
if em, ok := errs[0].(map[string]any); ok {
- msg = strings.TrimSpace(stringFromAny(em["message"]))
+ msg = strings.TrimSpace(jsonutil.StringFromAny(em["message"]))
}
if msg == "" {
msg = "pipe response returned graphql error"
@@ -1228,7 +1229,7 @@ func findStringByKey(v any, wantedKey string) string {
case map[string]any:
for k, value := range x {
if strings.ToLower(k) == w {
- if s := stringFromAny(value); strings.TrimSpace(s) != "" {
+ if s := jsonutil.StringFromAny(value); strings.TrimSpace(s) != "" {
return s
}
}
@@ -1246,17 +1247,9 @@ func findStringByKey(v any, wantedKey string) string {
return ""
}
-func nestedMap(m map[string]any, key string) map[string]any {
- v, ok := m[key].(map[string]any)
- if !ok {
- return map[string]any{}
- }
- return v
-}
-
func enrichTrack(track map[string]any) {
if artist, ok := track["artist"].(map[string]any); ok {
- track["performer"] = map[string]any{"name": stringFromAny(artist["name"]), "id": stringFromAny(artist["id"])}
+ track["performer"] = map[string]any{"name": jsonutil.StringFromAny(artist["name"]), "id": jsonutil.StringFromAny(artist["id"])}
}
if album, ok := track["album"].(map[string]any); ok {
enrichAlbumImage(album)
@@ -1271,7 +1264,7 @@ func enrichTrack(track map[string]any) {
track["media_number"] = d
}
}
- if boolFromAny(track["explicit_lyrics"]) {
+ if jsonutil.BoolFromAny(track["explicit_lyrics"]) {
track["explicit"] = true
}
}
@@ -1280,11 +1273,11 @@ func enrichAlbumImage(meta map[string]any) {
if _, ok := meta["image"].(map[string]any); ok {
return
}
- cover := firstNonEmpty(
- stringFromAny(meta["cover_xl"]),
- stringFromAny(meta["cover_big"]),
- stringFromAny(meta["cover_medium"]),
- stringFromAny(meta["cover_small"]),
+ cover := jsonutil.FirstNonEmpty(
+ jsonutil.StringFromAny(meta["cover_xl"]),
+ jsonutil.StringFromAny(meta["cover_big"]),
+ jsonutil.StringFromAny(meta["cover_medium"]),
+ jsonutil.StringFromAny(meta["cover_small"]),
)
if cover == "" {
return
@@ -1296,48 +1289,3 @@ func enrichAlbumImage(meta map[string]any) {
"original": cover,
}
}
-
-func stringFromAny(v any) string {
- switch t := v.(type) {
- case string:
- return t
- case int:
- return strconv.Itoa(t)
- case int64:
- return strconv.FormatInt(t, 10)
- case float64:
- return strconv.FormatFloat(t, 'f', -1, 64)
- default:
- return ""
- }
-}
-
-func firstNonEmpty(items ...string) string {
- for _, item := range items {
- if strings.TrimSpace(item) != "" {
- return strings.TrimSpace(item)
- }
- }
- return ""
-}
-
-func intFromAny(v any) int {
- switch t := v.(type) {
- case int:
- return t
- case int64:
- return int(t)
- case float64:
- return int(t)
- case string:
- i, _ := strconv.Atoi(strings.TrimSpace(t))
- return i
- default:
- return 0
- }
-}
-
-func boolFromAny(v any) bool {
- b, ok := v.(bool)
- return ok && b
-}
diff --git a/internal/provider/deezer/client_test.go b/internal/provider/deezer/client_test.go
index 067fd6f..b8911ae 100644
--- a/internal/provider/deezer/client_test.go
+++ b/internal/provider/deezer/client_test.go
@@ -9,6 +9,8 @@ import (
"strings"
"testing"
+ "streamrip-go/internal/jsonutil"
+
"streamrip-go/internal/config"
)
@@ -89,7 +91,7 @@ func TestGetMetadataArtistPaginatesAlbums(t *testing.T) {
if len(items) != 101 {
t.Fatalf("albums len = %d, want 101", len(items))
}
- if got := strings.TrimSpace(stringFromAny(meta["name"])); got != "Lost Frequencies" {
+ if got := strings.TrimSpace(jsonutil.StringFromAny(meta["name"])); got != "Lost Frequencies" {
t.Fatalf("artist name = %q, want Lost Frequencies", got)
}
if callCount != 2 {
@@ -220,11 +222,11 @@ func TestGetMetadataAddsLyricsFromPipe(t *testing.T) {
if err != nil {
t.Fatalf("GetMetadata() error = %v", err)
}
- if !strings.Contains(stringFromAny(meta["lyrics"]), "Go shawty") {
- t.Fatalf("expected lyrics text, got %q", stringFromAny(meta["lyrics"]))
+ if !strings.Contains(jsonutil.StringFromAny(meta["lyrics"]), "Go shawty") {
+ t.Fatalf("expected lyrics text, got %q", jsonutil.StringFromAny(meta["lyrics"]))
}
- if !strings.Contains(stringFromAny(meta["lyrics_synced"]), "[00:00.00]Go, go, go") {
- t.Fatalf("expected synced lyrics, got %q", stringFromAny(meta["lyrics_synced"]))
+ if !strings.Contains(jsonutil.StringFromAny(meta["lyrics_synced"]), "[00:00.00]Go, go, go") {
+ t.Fatalf("expected synced lyrics, got %q", jsonutil.StringFromAny(meta["lyrics_synced"]))
}
}
@@ -243,7 +245,7 @@ func TestLoginWithCredentials(t *testing.T) {
case "mobile_userAuth":
var payload map[string]any
_ = json.NewDecoder(r.Body).Decode(&payload)
- if strings.TrimSpace(stringFromAny(payload["mail"])) == "" || strings.TrimSpace(stringFromAny(payload["password"])) == "" {
+ if strings.TrimSpace(jsonutil.StringFromAny(payload["mail"])) == "" || strings.TrimSpace(jsonutil.StringFromAny(payload["password"])) == "" {
w.WriteHeader(http.StatusBadRequest)
_ = json.NewEncoder(w).Encode(map[string]any{"error": map[string]any{"message": "missing creds"}})
return
diff --git a/internal/provider/qobuz/client.go b/internal/provider/qobuz/client.go
index d305d32..c0ca300 100644
--- a/internal/provider/qobuz/client.go
+++ b/internal/provider/qobuz/client.go
@@ -18,6 +18,7 @@ import (
"time"
"streamrip-go/internal/config"
+ "streamrip-go/internal/jsonutil"
"streamrip-go/internal/netutil"
"streamrip-go/internal/provider"
"streamrip-go/internal/ratelimit"
@@ -694,7 +695,7 @@ func (c *Client) fetchAppIDAndSecrets(ctx context.Context) (string, []string, er
tzNames := make([]string, 0, len(ordered))
for _, o := range ordered {
- tzNames = append(tzNames, strings.Title(o.timezone))
+ tzNames = append(tzNames, jsonutil.TitleCase(o.timezone))
}
infoRe := regexp.MustCompile(fmt.Sprintf(infoExtrasTemplate, strings.Join(tzNames, "|")))
idxInfo := infoRe.SubexpIndex("info")
diff --git a/internal/provider/soundcloud/client.go b/internal/provider/soundcloud/client.go
index 862177a..ab7d335 100644
--- a/internal/provider/soundcloud/client.go
+++ b/internal/provider/soundcloud/client.go
@@ -10,12 +10,12 @@ import (
"net/url"
"os/exec"
"regexp"
- "strconv"
"strings"
"sync"
"time"
"streamrip-go/internal/config"
+ "streamrip-go/internal/jsonutil"
"streamrip-go/internal/provider"
)
@@ -102,14 +102,14 @@ func (c *Client) searchTracks(ctx context.Context, query string, limit int) ([]m
if id == "" {
continue
}
- artist := strings.TrimSpace(stringFromAny(m["uploader"]))
+ artist := strings.TrimSpace(jsonutil.StringFromAny(m["uploader"]))
if artist == "" {
- artist = strings.TrimSpace(stringFromAny(m["channel"]))
+ artist = strings.TrimSpace(jsonutil.StringFromAny(m["channel"]))
}
- artistID := strings.TrimSpace(firstNonEmpty(stringFromAny(m["uploader_id"]), stringFromAny(m["channel_id"])))
+ artistID := strings.TrimSpace(jsonutil.FirstNonEmpty(jsonutil.StringFromAny(m["uploader_id"]), jsonutil.StringFromAny(m["channel_id"])))
item := map[string]any{
"id": id,
- "title": stringFromAny(m["title"]),
+ "title": jsonutil.StringFromAny(m["title"]),
"artist": map[string]any{
"name": artist,
},
@@ -117,7 +117,7 @@ func (c *Client) searchTracks(ctx context.Context, query string, limit int) ([]m
if artistID != "" {
item["artist"] = map[string]any{"name": artist, "id": artistID}
}
- if trackID := strings.TrimSpace(stringFromAny(m["id"])); trackID != "" {
+ if trackID := strings.TrimSpace(jsonutil.StringFromAny(m["id"])); trackID != "" {
item["source_track_id"] = trackID
}
items = append(items, item)
@@ -163,17 +163,17 @@ func (c *Client) searchPlaylists(ctx context.Context, query string, limit int) (
if infoErr != nil {
continue
}
- title := strings.TrimSpace(stringFromAny(info["title"]))
+ title := strings.TrimSpace(jsonutil.StringFromAny(info["title"]))
if title == "" {
title = strings.Trim(strings.ReplaceAll(path, "/", " "), " ")
}
- artist := strings.TrimSpace(firstNonEmpty(stringFromAny(info["uploader"]), stringFromAny(info["channel"])))
- artistID := strings.TrimSpace(firstNonEmpty(stringFromAny(info["uploader_id"]), stringFromAny(info["channel_id"])))
+ artist := strings.TrimSpace(jsonutil.FirstNonEmpty(jsonutil.StringFromAny(info["uploader"]), jsonutil.StringFromAny(info["channel"])))
+ artistID := strings.TrimSpace(jsonutil.FirstNonEmpty(jsonutil.StringFromAny(info["uploader_id"]), jsonutil.StringFromAny(info["channel_id"])))
trackCount := 0
if entries := asAnySlice(info["entries"]); len(entries) > 0 {
trackCount = len(entries)
}
- canonical := firstNonEmpty(canonicalSoundcloudURL(info), playlistURL)
+ canonical := jsonutil.FirstNonEmpty(canonicalSoundcloudURL(info), playlistURL)
item := map[string]any{
"id": canonical,
"title": title,
@@ -183,10 +183,10 @@ func (c *Client) searchPlaylists(ctx context.Context, query string, limit int) (
if artistID != "" {
item["artist"] = map[string]any{"name": artist, "id": artistID}
}
- if pid := strings.TrimSpace(stringFromAny(info["id"])); pid != "" {
+ if pid := strings.TrimSpace(jsonutil.StringFromAny(info["id"])); pid != "" {
item["source_playlist_id"] = pid
}
- if thumb := strings.TrimSpace(stringFromAny(info["thumbnail"])); thumb != "" {
+ if thumb := strings.TrimSpace(jsonutil.StringFromAny(info["thumbnail"])); thumb != "" {
item["image"] = soundcloudImageMap(thumb)
}
items = append(items, item)
@@ -228,15 +228,15 @@ func (c *Client) GetMetadata(ctx context.Context, item, mediaType string) (map[s
continue
}
track := map[string]any{"id": id}
- if trackID := strings.TrimSpace(stringFromAny(entry["id"])); trackID != "" {
+ if trackID := strings.TrimSpace(jsonutil.StringFromAny(entry["id"])); trackID != "" {
track["source_track_id"] = trackID
}
- if title := strings.TrimSpace(stringFromAny(entry["title"])); title != "" {
+ if title := strings.TrimSpace(jsonutil.StringFromAny(entry["title"])); title != "" {
track["title"] = title
}
- if artist := strings.TrimSpace(firstNonEmpty(stringFromAny(entry["uploader"]), stringFromAny(entry["channel"]))); artist != "" {
+ if artist := strings.TrimSpace(jsonutil.FirstNonEmpty(jsonutil.StringFromAny(entry["uploader"]), jsonutil.StringFromAny(entry["channel"]))); artist != "" {
artistMap := map[string]any{"name": artist}
- if artistID := strings.TrimSpace(firstNonEmpty(stringFromAny(entry["uploader_id"]), stringFromAny(entry["channel_id"]))); artistID != "" {
+ if artistID := strings.TrimSpace(jsonutil.FirstNonEmpty(jsonutil.StringFromAny(entry["uploader_id"]), jsonutil.StringFromAny(entry["channel_id"]))); artistID != "" {
artistMap["id"] = artistID
}
track["artist"] = artistMap
@@ -244,23 +244,23 @@ func (c *Client) GetMetadata(ctx context.Context, item, mediaType string) (map[s
track["track_number"] = i + 1
tracks = append(tracks, track)
}
- name := strings.TrimSpace(stringFromAny(root["title"]))
+ name := strings.TrimSpace(jsonutil.StringFromAny(root["title"]))
if name == "" {
name = "SoundCloud Playlist"
}
meta := map[string]any{
- "id": firstNonEmpty(canonicalSoundcloudURL(root), item),
+ "id": jsonutil.FirstNonEmpty(canonicalSoundcloudURL(root), item),
"name": name,
- "description": strings.TrimSpace(stringFromAny(root["description"])),
+ "description": strings.TrimSpace(jsonutil.StringFromAny(root["description"])),
"tracks": map[string]any{"items": tracks},
}
- if pid := strings.TrimSpace(stringFromAny(root["id"])); pid != "" {
+ if pid := strings.TrimSpace(jsonutil.StringFromAny(root["id"])); pid != "" {
meta["source_playlist_id"] = pid
}
- if artist := strings.TrimSpace(firstNonEmpty(stringFromAny(root["uploader"]), stringFromAny(root["channel"]))); artist != "" {
+ if artist := strings.TrimSpace(jsonutil.FirstNonEmpty(jsonutil.StringFromAny(root["uploader"]), jsonutil.StringFromAny(root["channel"]))); artist != "" {
meta["artist"] = map[string]any{"name": artist}
}
- if thumb := strings.TrimSpace(stringFromAny(root["thumbnail"])); thumb != "" {
+ if thumb := strings.TrimSpace(jsonutil.StringFromAny(root["thumbnail"])); thumb != "" {
meta["image"] = soundcloudImageMap(thumb)
}
if entries := asAnySlice(root["entries"]); len(entries) > 0 {
@@ -280,11 +280,11 @@ func (c *Client) GetDownloadable(ctx context.Context, item string, _ int) (*prov
if err != nil {
return nil, err
}
- streamURL := strings.TrimSpace(stringFromAny(info["url"]))
+ streamURL := strings.TrimSpace(jsonutil.StringFromAny(info["url"]))
if streamURL == "" {
return nil, errors.New("yt-dlp output missing url (track may be unavailable or region-restricted)")
}
- ext := strings.TrimSpace(stringFromAny(info["ext"]))
+ ext := strings.TrimSpace(jsonutil.StringFromAny(info["ext"]))
if ext == "" {
ext = "m4a"
}
@@ -337,36 +337,36 @@ func (c *Client) playlistInfo(ctx context.Context, item string) (map[string]any,
}
func trackMetadataFromInfo(id string, info map[string]any) map[string]any {
- canonicalID := firstNonEmpty(canonicalSoundcloudURL(info), id)
- publisher := nestedMap(info, "publisher_metadata")
- title := strings.TrimSpace(stringFromAny(info["title"]))
+ canonicalID := jsonutil.FirstNonEmpty(canonicalSoundcloudURL(info), id)
+ publisher := jsonutil.NestedMap(info, "publisher_metadata")
+ title := strings.TrimSpace(jsonutil.StringFromAny(info["title"]))
if title == "" {
title = canonicalID
}
- albumTitle := strings.TrimSpace(stringFromAny(publisher["album_title"]))
+ albumTitle := strings.TrimSpace(jsonutil.StringFromAny(publisher["album_title"]))
if albumTitle == "" {
- albumTitle = strings.TrimSpace(stringFromAny(info["album"]))
+ albumTitle = strings.TrimSpace(jsonutil.StringFromAny(info["album"]))
}
if albumTitle == "" {
albumTitle = title
}
- artistName := strings.TrimSpace(stringFromAny(info["artist"]))
+ artistName := strings.TrimSpace(jsonutil.StringFromAny(info["artist"]))
if artistName == "" {
- artistName = strings.TrimSpace(stringFromAny(publisher["artist"]))
+ artistName = strings.TrimSpace(jsonutil.StringFromAny(publisher["artist"]))
}
if artistName == "" {
- artistName = strings.TrimSpace(stringFromAny(info["uploader"]))
+ artistName = strings.TrimSpace(jsonutil.StringFromAny(info["uploader"]))
}
if artistName == "" {
- artistName = strings.TrimSpace(stringFromAny(info["channel"]))
+ artistName = strings.TrimSpace(jsonutil.StringFromAny(info["channel"]))
}
- artistID := strings.TrimSpace(firstNonEmpty(
- stringFromAny(info["uploader_id"]),
- stringFromAny(info["channel_id"]),
- stringFromAny(nestedMap(info, "user")["id"]),
+ artistID := strings.TrimSpace(jsonutil.FirstNonEmpty(
+ jsonutil.StringFromAny(info["uploader_id"]),
+ jsonutil.StringFromAny(info["channel_id"]),
+ jsonutil.StringFromAny(jsonutil.NestedMap(info, "user")["id"]),
))
- trackNum := intFromAny(info["track_number"])
+ trackNum := jsonutil.IntFromAny(info["track_number"])
if trackNum <= 0 {
trackNum = 1
}
@@ -378,26 +378,26 @@ func trackMetadataFromInfo(id string, info map[string]any) map[string]any {
"artist": map[string]any{"name": artistName, "id": artistID},
"performer": map[string]any{"name": artistName, "id": artistID},
"album": map[string]any{
- "id": firstNonEmpty(strings.TrimSpace(stringFromAny(info["album"])), canonicalID),
+ "id": jsonutil.FirstNonEmpty(strings.TrimSpace(jsonutil.StringFromAny(info["album"])), canonicalID),
"title": albumTitle,
"artist": map[string]any{"name": artistName, "id": artistID},
},
- "description": strings.TrimSpace(stringFromAny(info["description"])),
- "genre": strings.TrimSpace(stringFromAny(info["genre"])),
- "isrc": strings.TrimSpace(stringFromAny(info["isrc"])),
- "label": strings.TrimSpace(firstNonEmpty(stringFromAny(info["label"]), stringFromAny(info["label_name"]))),
- "copyright": strings.TrimSpace(stringFromAny(publisher["p_line"])),
- "release_date": strings.TrimSpace(firstNonEmpty(
- stringFromAny(info["created_at"]),
- stringFromAny(info["release_date"]),
- stringFromAny(info["upload_date"]),
+ "description": strings.TrimSpace(jsonutil.StringFromAny(info["description"])),
+ "genre": strings.TrimSpace(jsonutil.StringFromAny(info["genre"])),
+ "isrc": strings.TrimSpace(jsonutil.StringFromAny(info["isrc"])),
+ "label": strings.TrimSpace(jsonutil.FirstNonEmpty(jsonutil.StringFromAny(info["label"]), jsonutil.StringFromAny(info["label_name"]))),
+ "copyright": strings.TrimSpace(jsonutil.StringFromAny(publisher["p_line"])),
+ "release_date": strings.TrimSpace(jsonutil.FirstNonEmpty(
+ jsonutil.StringFromAny(info["created_at"]),
+ jsonutil.StringFromAny(info["release_date"]),
+ jsonutil.StringFromAny(info["upload_date"]),
)),
}
- if trackID := strings.TrimSpace(stringFromAny(info["id"])); trackID != "" {
+ if trackID := strings.TrimSpace(jsonutil.StringFromAny(info["id"])); trackID != "" {
meta["source_track_id"] = trackID
}
- if boolFromAny(publisher["explicit"]) || intFromAny(info["age_limit"]) >= 18 {
+ if jsonutil.BoolFromAny(publisher["explicit"]) || jsonutil.IntFromAny(info["age_limit"]) >= 18 {
meta["explicit"] = true
}
@@ -405,11 +405,11 @@ func trackMetadataFromInfo(id string, info map[string]any) map[string]any {
delete(meta, "release_date")
}
- if thumb := strings.TrimSpace(stringFromAny(info["thumbnail"])); thumb != "" {
+ if thumb := strings.TrimSpace(jsonutil.StringFromAny(info["thumbnail"])); thumb != "" {
meta["image"] = soundcloudImageMap(thumb)
}
- if strings.TrimSpace(stringFromAny(info["album"])) == "" && strings.TrimSpace(stringFromAny(publisher["album_title"])) == "" {
+ if strings.TrimSpace(jsonutil.StringFromAny(info["album"])) == "" && strings.TrimSpace(jsonutil.StringFromAny(publisher["album_title"])) == "" {
meta["album"] = map[string]any{
"id": canonicalID,
"title": title,
@@ -417,7 +417,7 @@ func trackMetadataFromInfo(id string, info map[string]any) map[string]any {
}
}
- if durationSec := intFromAny(info["duration"]); durationSec > 0 {
+ if durationSec := jsonutil.IntFromAny(info["duration"]); durationSec > 0 {
meta["duration"] = durationSec
}
@@ -426,7 +426,7 @@ func trackMetadataFromInfo(id string, info map[string]any) map[string]any {
func canonicalSoundcloudURL(info map[string]any) string {
for _, key := range []string{"webpage_url", "original_url", "url"} {
- raw := strings.TrimSpace(stringFromAny(info[key]))
+ raw := strings.TrimSpace(jsonutil.StringFromAny(info[key]))
if raw == "" {
continue
}
@@ -478,72 +478,6 @@ func asAnySlice(v any) []any {
return items
}
-func stringFromAny(v any) string {
- switch t := v.(type) {
- case string:
- return t
- case int:
- return strconv.Itoa(t)
- case int64:
- return strconv.FormatInt(t, 10)
- case float64:
- return strconv.FormatFloat(t, 'f', -1, 64)
- default:
- return ""
- }
-}
-
-func intFromAny(v any) int {
- switch t := v.(type) {
- case int:
- return t
- case int64:
- return int(t)
- case float64:
- return int(t)
- case string:
- i, _ := strconv.Atoi(strings.TrimSpace(t))
- return i
- default:
- return 0
- }
-}
-
-func firstNonEmpty(items ...string) string {
- for _, item := range items {
- if strings.TrimSpace(item) != "" {
- return strings.TrimSpace(item)
- }
- }
- return ""
-}
-
-func nestedMap(m map[string]any, key string) map[string]any {
- v, ok := m[key].(map[string]any)
- if !ok {
- return map[string]any{}
- }
- return v
-}
-
-func boolFromAny(v any) bool {
- switch t := v.(type) {
- case bool:
- return t
- case string:
- l := strings.ToLower(strings.TrimSpace(t))
- return l == "1" || l == "true" || l == "yes"
- case int:
- return t != 0
- case int64:
- return t != 0
- case float64:
- return t != 0
- default:
- return false
- }
-}
-
func soundcloudImageMap(raw string) map[string]any {
base := strings.TrimSpace(raw)
if base == "" {
diff --git a/internal/provider/soundcloud/client_test.go b/internal/provider/soundcloud/client_test.go
index 0424e1c..ed1d0b4 100644
--- a/internal/provider/soundcloud/client_test.go
+++ b/internal/provider/soundcloud/client_test.go
@@ -8,6 +8,8 @@ import (
"strings"
"testing"
+ "streamrip-go/internal/jsonutil"
+
"streamrip-go/internal/config"
)
@@ -27,11 +29,11 @@ func TestGetTrackMetadataAndDownloadable(t *testing.T) {
if err != nil {
t.Fatalf("GetMetadata() error = %v", err)
}
- if stringFromAny(meta["title"]) != "Lean On" {
- t.Fatalf("title = %q, want Lean On", stringFromAny(meta["title"]))
+ if jsonutil.StringFromAny(meta["title"]) != "Lean On" {
+ t.Fatalf("title = %q, want Lean On", jsonutil.StringFromAny(meta["title"]))
}
- if stringFromAny(meta["id"]) != "https://soundcloud.com/a/b" {
- t.Fatalf("id = %q, want canonical soundcloud url", stringFromAny(meta["id"]))
+ if jsonutil.StringFromAny(meta["id"]) != "https://soundcloud.com/a/b" {
+ t.Fatalf("id = %q, want canonical soundcloud url", jsonutil.StringFromAny(meta["id"]))
}
d, err := c.GetDownloadable(context.Background(), "https://soundcloud.com/a/b", 0)
@@ -59,8 +61,8 @@ func TestGetPlaylistMetadata(t *testing.T) {
if err != nil {
t.Fatalf("GetMetadata() error = %v", err)
}
- if stringFromAny(meta["name"]) != "Road Trip" {
- t.Fatalf("name = %q, want Road Trip", stringFromAny(meta["name"]))
+ if jsonutil.StringFromAny(meta["name"]) != "Road Trip" {
+ t.Fatalf("name = %q, want Road Trip", jsonutil.StringFromAny(meta["name"]))
}
tracksMap, ok := meta["tracks"].(map[string]any)
if !ok {
@@ -70,8 +72,8 @@ func TestGetPlaylistMetadata(t *testing.T) {
if len(items) != 2 {
t.Fatalf("playlist items len = %d, want 2", len(items))
}
- if stringFromAny(meta["id"]) != "https://soundcloud.com/a/sets/road-trip" {
- t.Fatalf("playlist id not canonical: %q", stringFromAny(meta["id"]))
+ if jsonutil.StringFromAny(meta["id"]) != "https://soundcloud.com/a/sets/road-trip" {
+ t.Fatalf("playlist id not canonical: %q", jsonutil.StringFromAny(meta["id"]))
}
}
@@ -102,8 +104,8 @@ func TestSearchTrack(t *testing.T) {
if !ok {
t.Fatalf("expected first item map")
}
- if stringFromAny(item0["id"]) != "https://soundcloud.com/a/b" {
- t.Fatalf("track search id not canonical: %q", stringFromAny(item0["id"]))
+ if jsonutil.StringFromAny(item0["id"]) != "https://soundcloud.com/a/b" {
+ t.Fatalf("track search id not canonical: %q", jsonutil.StringFromAny(item0["id"]))
}
}
@@ -147,8 +149,8 @@ func TestSearchPlaylist(t *testing.T) {
if !ok {
t.Fatalf("expected first item map")
}
- if stringFromAny(item0["id"]) != "https://soundcloud.com/a/sets/road-trip" {
- t.Fatalf("playlist search id not canonical: %q", stringFromAny(item0["id"]))
+ if jsonutil.StringFromAny(item0["id"]) != "https://soundcloud.com/a/sets/road-trip" {
+ t.Fatalf("playlist search id not canonical: %q", jsonutil.StringFromAny(item0["id"]))
}
}
@@ -192,8 +194,8 @@ func TestSearchPlaylistAcceptsDotsInPath(t *testing.T) {
if !ok {
t.Fatalf("expected first item map")
}
- if stringFromAny(item0["id"]) != "https://soundcloud.com/artist.name/sets/road.trip" {
- t.Fatalf("playlist search id not canonical: %q", stringFromAny(item0["id"]))
+ if jsonutil.StringFromAny(item0["id"]) != "https://soundcloud.com/artist.name/sets/road.trip" {
+ t.Fatalf("playlist search id not canonical: %q", jsonutil.StringFromAny(item0["id"]))
}
}
@@ -221,18 +223,18 @@ func TestTrackMetadataIncludesExplicitAndISRC(t *testing.T) {
"thumbnail": "https://img",
"upload_date": "20240101",
})
- if stringFromAny(meta["isrc"]) != "US123" {
- t.Fatalf("isrc = %q, want US123", stringFromAny(meta["isrc"]))
+ if jsonutil.StringFromAny(meta["isrc"]) != "US123" {
+ t.Fatalf("isrc = %q, want US123", jsonutil.StringFromAny(meta["isrc"]))
}
explicit, _ := meta["explicit"].(bool)
if !explicit {
t.Fatalf("expected explicit=true")
}
- if stringFromAny(meta["source_track_id"]) != "9876" {
- t.Fatalf("source_track_id = %q, want 9876", stringFromAny(meta["source_track_id"]))
+ if jsonutil.StringFromAny(meta["source_track_id"]) != "9876" {
+ t.Fatalf("source_track_id = %q, want 9876", jsonutil.StringFromAny(meta["source_track_id"]))
}
- if stringFromAny(nestedMap(meta, "album")["title"]) != "T" {
- t.Fatalf("album title mismatch: %#v", nestedMap(meta, "album"))
+ if jsonutil.StringFromAny(jsonutil.NestedMap(meta, "album")["title"]) != "T" {
+ t.Fatalf("album title mismatch: %#v", jsonutil.NestedMap(meta, "album"))
}
}
diff --git a/internal/provider/tidal/client.go b/internal/provider/tidal/client.go
index 4fca37b..35e427a 100644
--- a/internal/provider/tidal/client.go
+++ b/internal/provider/tidal/client.go
@@ -15,6 +15,7 @@ import (
"time"
"streamrip-go/internal/config"
+ "streamrip-go/internal/jsonutil"
"streamrip-go/internal/netutil"
"streamrip-go/internal/provider"
"streamrip-go/internal/ratelimit"
@@ -149,7 +150,7 @@ func (c *Client) refreshAccessToken(ctx context.Context) error {
}
newRefresh := stringify(resp["refresh_token"])
- expiresIn := int64(intFromAny(resp["expires_in"]))
+ expiresIn := int64(jsonutil.IntFromAny(resp["expires_in"]))
if expiresIn <= 0 {
expiresIn = 7 * 24 * 3600
}
@@ -773,19 +774,3 @@ func tidalImageMap(cover string) map[string]any {
"original": base + "/1280x1280.jpg",
}
}
-
-func intFromAny(v any) int {
- switch t := v.(type) {
- case int:
- return t
- case int64:
- return int(t)
- case float64:
- return int(t)
- case string:
- i, _ := strconv.Atoi(t)
- return i
- default:
- return 0
- }
-}