From 6bc4b3b319246b11d8c701541ba0c3f7b54f3149 Mon Sep 17 00:00:00 2001 From: Joren Date: Tue, 21 Apr 2026 23:38:41 +0200 Subject: [PATCH] Refactor: comprehensive cleanup and modularization - Extracted common JSON parsing helpers into internal/jsonutil - Removed duplicated helper functions from provider packages - Removed dead code in internal/app/app.go and downloader.go - Replaced deprecated strings.Title with jsonutil.TitleCase - Added graceful shutdown with signal handling in main.go - Split monolithic cmd/rip/main.go into args.go, helpers.go, lastfm.go, search.go --- cmd/rip/args.go | 211 +++ cmd/rip/helpers.go | 234 ++++ cmd/rip/lastfm.go | 430 ++++++ cmd/rip/main.go | 1364 +------------------ cmd/rip/search.go | 527 +++++++ internal/app/app.go | 242 ++-- internal/download/downloader.go | 42 - internal/download/downloader_test.go | 31 +- internal/jsonutil/jsonutil.go | 131 ++ internal/provider/deezer/client.go | 152 +-- internal/provider/deezer/client_test.go | 14 +- internal/provider/qobuz/client.go | 3 +- internal/provider/soundcloud/client.go | 174 +-- internal/provider/soundcloud/client_test.go | 42 +- internal/provider/tidal/client.go | 19 +- 15 files changed, 1763 insertions(+), 1853 deletions(-) create mode 100644 cmd/rip/args.go create mode 100644 cmd/rip/helpers.go create mode 100644 cmd/rip/lastfm.go create mode 100644 cmd/rip/search.go create mode 100644 internal/jsonutil/jsonutil.go 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 - } -}