mirror of
https://git.sr.ht/~joren/streamrip-go
synced 2026-06-17 15:05:39 +02:00
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
This commit is contained in:
211
cmd/rip/args.go
Normal file
211
cmd/rip/args.go
Normal file
@@ -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 ""
|
||||||
|
}
|
||||||
|
}
|
||||||
234
cmd/rip/helpers.go
Normal file
234
cmd/rip/helpers.go
Normal file
@@ -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(`<a\b[^>]*\btitle=(?:"([^"]+)"|'([^']+)')`)
|
||||||
|
lastFMDataTrackArtistRe = regexp.MustCompile(`data-track-name=(?:"([^"]+)"|'([^']+)')[^>]*data-artist-name=(?:"([^"]+)"|'([^']+)')`)
|
||||||
|
lastFMTotalTracksRe = regexp.MustCompile(`data-playlisting-entry-count="(\d+)"`)
|
||||||
|
lastFMPlaylistTitleRe = regexp.MustCompile(`<h1[^>]*class="[^"]*playlisting-playlist-header-title[^"]*"[^>]*>([^<]+)</h1>`)
|
||||||
|
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")
|
||||||
|
}
|
||||||
430
cmd/rip/lastfm.go
Normal file
430
cmd/rip/lastfm.go
Normal file
@@ -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
|
||||||
|
}
|
||||||
1364
cmd/rip/main.go
1364
cmd/rip/main.go
File diff suppressed because it is too large
Load Diff
527
cmd/rip/search.go
Normal file
527
cmd/rip/search.go
Normal file
@@ -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
|
||||||
|
}
|
||||||
@@ -18,6 +18,7 @@ import (
|
|||||||
"streamrip-go/internal/config"
|
"streamrip-go/internal/config"
|
||||||
"streamrip-go/internal/domain/media"
|
"streamrip-go/internal/domain/media"
|
||||||
"streamrip-go/internal/download"
|
"streamrip-go/internal/download"
|
||||||
|
"streamrip-go/internal/jsonutil"
|
||||||
"streamrip-go/internal/naming"
|
"streamrip-go/internal/naming"
|
||||||
"streamrip-go/internal/provider"
|
"streamrip-go/internal/provider"
|
||||||
deezerprovider "streamrip-go/internal/provider/deezer"
|
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 {
|
func (m *Main) ripCollection(ctx context.Context, p provider.Client, source, kind, id string, meta map[string]any) error {
|
||||||
name := titleFromMetadata(meta, id)
|
name := titleFromMetadata(meta, id)
|
||||||
if n := stringFromAny(meta["name"]); n != "" {
|
if n := jsonutil.StringFromAny(meta["name"]); n != "" {
|
||||||
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 {
|
func buildCollectionAlbum(id string, meta map[string]any) collectionAlbum {
|
||||||
trackCount := intFromAny(meta["tracks_count"])
|
trackCount := jsonutil.IntFromAny(meta["tracks_count"])
|
||||||
if trackCount == 0 {
|
if trackCount == 0 {
|
||||||
trackCount = intFromAny(meta["numberOfTracks"])
|
trackCount = jsonutil.IntFromAny(meta["numberOfTracks"])
|
||||||
}
|
}
|
||||||
return collectionAlbum{
|
return collectionAlbum{
|
||||||
ID: id,
|
ID: id,
|
||||||
Meta: meta,
|
Meta: meta,
|
||||||
Title: titleFromMetadata(meta, id),
|
Title: titleFromMetadata(meta, id),
|
||||||
AlbumArtist: nestedString(meta, "artist", "name"),
|
AlbumArtist: jsonutil.NestedString(meta, "artist", "name"),
|
||||||
BitDepth: intFromAny(meta["maximum_bit_depth"]),
|
BitDepth: jsonutil.IntFromAny(meta["maximum_bit_depth"]),
|
||||||
Sampling: floatFromAny(meta["maximum_sampling_rate"]),
|
Sampling: jsonutil.FloatFromAny(meta["maximum_sampling_rate"]),
|
||||||
Explicit: boolFromAny(meta["parental_warning"]),
|
Explicit: jsonutil.BoolFromAny(meta["parental_warning"]),
|
||||||
TrackCount: trackCount,
|
TrackCount: trackCount,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -459,10 +460,10 @@ func extractAlbumIDs(meta map[string]any) []string {
|
|||||||
if !ok {
|
if !ok {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
id := stringFromAny(itm["id"])
|
id := jsonutil.StringFromAny(itm["id"])
|
||||||
if id == "" {
|
if id == "" {
|
||||||
if nested, ok := itm["album"].(map[string]any); ok {
|
if nested, ok := itm["album"].(map[string]any); ok {
|
||||||
id = stringFromAny(nested["id"])
|
id = jsonutil.StringFromAny(nested["id"])
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if id == "" {
|
if id == "" {
|
||||||
@@ -517,23 +518,23 @@ func (m *Main) ripAlbum(ctx context.Context, p provider.Client, source, albumID
|
|||||||
}
|
}
|
||||||
|
|
||||||
albumTitle := titleFromMetadata(albumMeta, albumID)
|
albumTitle := titleFromMetadata(albumMeta, albumID)
|
||||||
albumArtist := nestedString(albumMeta, "artist", "name")
|
albumArtist := jsonutil.NestedString(albumMeta, "artist", "name")
|
||||||
if albumArtist == "" {
|
if albumArtist == "" {
|
||||||
albumArtist = "Unknown"
|
albumArtist = "Unknown"
|
||||||
}
|
}
|
||||||
releaseDate := stringFromAny(albumMeta["release_date_original"])
|
releaseDate := jsonutil.StringFromAny(albumMeta["release_date_original"])
|
||||||
if releaseDate == "" {
|
if releaseDate == "" {
|
||||||
releaseDate = stringFromAny(albumMeta["release_date"])
|
releaseDate = jsonutil.StringFromAny(albumMeta["release_date"])
|
||||||
}
|
}
|
||||||
if releaseDate == "" {
|
if releaseDate == "" {
|
||||||
releaseDate = stringFromAny(albumMeta["releaseDate"])
|
releaseDate = jsonutil.StringFromAny(albumMeta["releaseDate"])
|
||||||
}
|
}
|
||||||
if releaseDate == "" {
|
if releaseDate == "" {
|
||||||
releaseDate = stringFromAny(albumMeta["streamStartDate"])
|
releaseDate = jsonutil.StringFromAny(albumMeta["streamStartDate"])
|
||||||
}
|
}
|
||||||
year := naming.YearFromDate(releaseDate)
|
year := naming.YearFromDate(releaseDate)
|
||||||
bitDepth := intFromAny(albumMeta["maximum_bit_depth"])
|
bitDepth := jsonutil.IntFromAny(albumMeta["maximum_bit_depth"])
|
||||||
sampling := stringFromAny(albumMeta["maximum_sampling_rate"])
|
sampling := jsonutil.StringFromAny(albumMeta["maximum_sampling_rate"])
|
||||||
if bitDepth == 0 || sampling == "" {
|
if bitDepth == 0 || sampling == "" {
|
||||||
fallbackBitDepth, fallbackSampling := m.qualityProfileForSource(source)
|
fallbackBitDepth, fallbackSampling := m.qualityProfileForSource(source)
|
||||||
if bitDepth == 0 {
|
if bitDepth == 0 {
|
||||||
@@ -564,7 +565,7 @@ func (m *Main) ripAlbum(ctx context.Context, p provider.Client, source, albumID
|
|||||||
if !ok {
|
if !ok {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
id := stringFromAny(itm["id"])
|
id := jsonutil.StringFromAny(itm["id"])
|
||||||
if id != "" {
|
if id != "" {
|
||||||
trackIDs = append(trackIDs, 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)
|
folder := m.albumFolderPath(source, albumID, albumTitle, albumArtist, year, bitDepth, sampling)
|
||||||
artRes, _ := artwork.Prepare(ctx, m.DL, folder, albumMeta, m.Config.Session.Artwork, false)
|
artRes, _ := artwork.Prepare(ctx, m.DL, folder, albumMeta, m.Config.Session.Artwork, false)
|
||||||
total := len(trackIDs)
|
total := len(trackIDs)
|
||||||
discTotal := intFromAny(albumMeta["media_count"])
|
discTotal := jsonutil.IntFromAny(albumMeta["media_count"])
|
||||||
if discTotal == 0 {
|
if discTotal == 0 {
|
||||||
discTotal = intFromAny(albumMeta["numberOfVolumes"])
|
discTotal = jsonutil.IntFromAny(albumMeta["numberOfVolumes"])
|
||||||
}
|
}
|
||||||
m.logf("Album: %s (%d tracks)\n", albumTitle, total)
|
m.logf("Album: %s (%d tracks)\n", albumTitle, total)
|
||||||
failures := 0
|
failures := 0
|
||||||
@@ -631,12 +632,12 @@ func (m *Main) ripPlaylist(ctx context.Context, p provider.Client, source, playl
|
|||||||
}
|
}
|
||||||
|
|
||||||
name := titleFromMetadata(playlistMeta, playlistID)
|
name := titleFromMetadata(playlistMeta, playlistID)
|
||||||
if n := stringFromAny(playlistMeta["name"]); n != "" {
|
if n := jsonutil.StringFromAny(playlistMeta["name"]); n != "" {
|
||||||
name = n
|
name = n
|
||||||
}
|
}
|
||||||
base := m.Config.Session.Downloads.Folder
|
base := m.Config.Session.Downloads.Folder
|
||||||
if m.Config.Session.Downloads.SourceSubdirectories {
|
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{
|
folder := filepath.Join(base, naming.CleanName(name, naming.Config{
|
||||||
RestrictCharacters: m.Config.Session.Filepaths.RestrictCharacters,
|
RestrictCharacters: m.Config.Session.Filepaths.RestrictCharacters,
|
||||||
@@ -665,9 +666,9 @@ func (m *Main) ripPlaylist(ctx context.Context, p provider.Client, source, playl
|
|||||||
if !ok {
|
if !ok {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
id := stringFromAny(itm["id"])
|
id := jsonutil.StringFromAny(itm["id"])
|
||||||
if id == "" {
|
if id == "" {
|
||||||
id = stringFromAny(itm["track_id"])
|
id = jsonutil.StringFromAny(itm["track_id"])
|
||||||
}
|
}
|
||||||
if id != "" {
|
if id != "" {
|
||||||
ids = append(ids, 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 {
|
func (m *Main) ripTrack(ctx context.Context, p provider.Client, source, id, fallbackTitle string, opts ripTrackOptions) error {
|
||||||
|
if !m.IgnoreDB {
|
||||||
alreadyDownloaded, err := m.Store.IsDownloaded(ctx, source, id)
|
alreadyDownloaded, err := m.Store.IsDownloaded(ctx, source, id)
|
||||||
if err == nil && alreadyDownloaded {
|
if err == nil && alreadyDownloaded {
|
||||||
if m.IgnoreDB {
|
|
||||||
alreadyDownloaded = false
|
|
||||||
} else {
|
|
||||||
if opts.total > 0 {
|
if opts.total > 0 {
|
||||||
m.logf("[%d/%d] skip (already downloaded) id=%s\n", opts.index, opts.total, id)
|
m.logf("[%d/%d] skip (already downloaded) id=%s\n", opts.index, opts.total, id)
|
||||||
} else {
|
} 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")
|
meta, err := p.GetMetadata(ctx, id, "track")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
_ = m.Store.MarkFailed(ctx, source, "track", id)
|
_ = 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 {
|
func (m *Main) albumFolderPath(source, albumID, albumTitle, albumArtist, year string, bitDepth int, samplingRate string) string {
|
||||||
base := m.Config.Session.Downloads.Folder
|
base := m.Config.Session.Downloads.Folder
|
||||||
if m.Config.Session.Downloads.SourceSubdirectories {
|
if m.Config.Session.Downloads.SourceSubdirectories {
|
||||||
base = filepath.Join(base, strings.Title(source))
|
base = filepath.Join(base, jsonutil.TitleCase(source))
|
||||||
}
|
}
|
||||||
|
|
||||||
vals := map[string]string{
|
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 {
|
func (m *Main) trackOutputPath(source, id, title, ext string, trackMeta map[string]any, albumFolder string, albumDiscTotal int) string {
|
||||||
base := m.Config.Session.Downloads.Folder
|
base := m.Config.Session.Downloads.Folder
|
||||||
if m.Config.Session.Downloads.SourceSubdirectories {
|
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 {
|
if albumFolder == "" && m.Config.Session.Filepaths.AddSinglesToFolder {
|
||||||
albumTitle := nestedString(trackMeta, "album", "title")
|
albumTitle := jsonutil.NestedString(trackMeta, "album", "title")
|
||||||
albumID := nestedString(trackMeta, "album", "id")
|
albumID := jsonutil.NestedString(trackMeta, "album", "id")
|
||||||
if albumID == "" {
|
if albumID == "" {
|
||||||
albumID = id
|
albumID = id
|
||||||
}
|
}
|
||||||
albumArtist := nestedString(trackMeta, "album", "artist", "name")
|
albumArtist := jsonutil.NestedString(trackMeta, "album", "artist", "name")
|
||||||
if albumArtist == "" {
|
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" {
|
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 != "" {
|
if albumFolder != "" {
|
||||||
base = albumFolder
|
base = albumFolder
|
||||||
if m.Config.Session.Downloads.DiscSubdirectories && albumDiscTotal > 1 {
|
if m.Config.Session.Downloads.DiscSubdirectories && albumDiscTotal > 1 {
|
||||||
discNumber := intFromAny(trackMeta["media_number"])
|
discNumber := jsonutil.IntFromAny(trackMeta["media_number"])
|
||||||
if discNumber == 0 {
|
if discNumber == 0 {
|
||||||
discNumber = intFromAny(trackMeta["volumeNumber"])
|
discNumber = jsonutil.IntFromAny(trackMeta["volumeNumber"])
|
||||||
}
|
}
|
||||||
if discNumber == 0 {
|
if discNumber == 0 {
|
||||||
discNumber = intFromAny(trackMeta["disk_number"])
|
discNumber = jsonutil.IntFromAny(trackMeta["disk_number"])
|
||||||
}
|
}
|
||||||
if discNumber == 0 {
|
if discNumber == 0 {
|
||||||
discNumber = 1
|
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 {
|
if trackNumber == 0 {
|
||||||
trackNumber = intFromAny(trackMeta["trackNumber"])
|
trackNumber = jsonutil.IntFromAny(trackMeta["trackNumber"])
|
||||||
}
|
}
|
||||||
explicit := ""
|
explicit := ""
|
||||||
if boolFromAny(trackMeta["parental_warning"]) || boolFromAny(trackMeta["explicit"]) {
|
if jsonutil.BoolFromAny(trackMeta["parental_warning"]) || jsonutil.BoolFromAny(trackMeta["explicit"]) {
|
||||||
explicit = " (Explicit)"
|
explicit = " (Explicit)"
|
||||||
}
|
}
|
||||||
artist := nestedString(trackMeta, "performer", "name")
|
artist := jsonutil.NestedString(trackMeta, "performer", "name")
|
||||||
if artist == "" {
|
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 == "" {
|
if albumArtist == "" {
|
||||||
albumArtist = artist
|
albumArtist = artist
|
||||||
}
|
}
|
||||||
@@ -1073,7 +1059,7 @@ func (m *Main) videoOutputPath(source, id, title, ext string) string {
|
|||||||
}
|
}
|
||||||
base := m.Config.Session.Downloads.Folder
|
base := m.Config.Session.Downloads.Folder
|
||||||
if m.Config.Session.Downloads.SourceSubdirectories {
|
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{
|
fileName := naming.CleanName(title, naming.Config{
|
||||||
RestrictCharacters: m.Config.Session.Filepaths.RestrictCharacters,
|
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 {
|
func titleFromMetadata(meta map[string]any, fallback string) string {
|
||||||
if title, ok := meta["title"].(string); ok {
|
if title, ok := meta["title"].(string); ok {
|
||||||
title = strings.TrimSpace(title)
|
title = strings.TrimSpace(title)
|
||||||
version := strings.TrimSpace(stringFromAny(meta["version"]))
|
version := strings.TrimSpace(jsonutil.StringFromAny(meta["version"]))
|
||||||
if version != "" {
|
if version != "" {
|
||||||
return title + " (" + version + ")"
|
return title + " (" + version + ")"
|
||||||
}
|
}
|
||||||
@@ -1099,70 +1085,8 @@ func titleFromMetadata(meta map[string]any, fallback string) string {
|
|||||||
return fallback
|
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 {
|
func replaygainGainFromAny(v any) string {
|
||||||
s := strings.TrimSpace(stringFromAny(v))
|
s := strings.TrimSpace(jsonutil.StringFromAny(v))
|
||||||
if s == "" {
|
if s == "" {
|
||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
@@ -1183,7 +1107,7 @@ func replaygainGainFromAny(v any) string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func replaygainPeakFromAny(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 {
|
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 {
|
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 == "" {
|
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 == "" {
|
if albumArtist == "" {
|
||||||
albumArtist = artist
|
albumArtist = artist
|
||||||
}
|
}
|
||||||
if strings.TrimSpace(opts.albumArtist) != "" {
|
if strings.TrimSpace(opts.albumArtist) != "" {
|
||||||
albumArtist = strings.TrimSpace(opts.albumArtist)
|
albumArtist = strings.TrimSpace(opts.albumArtist)
|
||||||
}
|
}
|
||||||
trackNumber := intFromAny(trackMeta["track_number"])
|
trackNumber := jsonutil.IntFromAny(trackMeta["track_number"])
|
||||||
if trackNumber == 0 {
|
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 {
|
if discNumber == 0 {
|
||||||
discNumber = intFromAny(trackMeta["volumeNumber"])
|
discNumber = jsonutil.IntFromAny(trackMeta["volumeNumber"])
|
||||||
}
|
}
|
||||||
if discNumber == 0 {
|
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 == "" {
|
if date == "" {
|
||||||
date = stringFromAny(trackMeta["release_date"])
|
date = jsonutil.StringFromAny(trackMeta["release_date"])
|
||||||
}
|
}
|
||||||
if 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 == "" {
|
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 {
|
if trackTotal == 0 {
|
||||||
trackTotal = intFromAny(trackMeta["numberOfTracks"])
|
trackTotal = jsonutil.IntFromAny(trackMeta["numberOfTracks"])
|
||||||
}
|
}
|
||||||
if trackTotal == 0 {
|
if trackTotal == 0 {
|
||||||
trackTotal = intFromAny(trackMeta["track_total"])
|
trackTotal = jsonutil.IntFromAny(trackMeta["track_total"])
|
||||||
}
|
}
|
||||||
if opts.forPlaylist && opts.total > 0 {
|
if opts.forPlaylist && opts.total > 0 {
|
||||||
trackTotal = opts.total
|
trackTotal = opts.total
|
||||||
}
|
}
|
||||||
|
|
||||||
discTotal := intFromAny(trackMeta["media_count"])
|
discTotal := jsonutil.IntFromAny(trackMeta["media_count"])
|
||||||
if discTotal == 0 {
|
if discTotal == 0 {
|
||||||
discTotal = intFromAny(trackMeta["numberOfVolumes"])
|
discTotal = jsonutil.IntFromAny(trackMeta["numberOfVolumes"])
|
||||||
}
|
}
|
||||||
if discTotal == 0 && opts.albumDiscTotal > 0 {
|
if discTotal == 0 && opts.albumDiscTotal > 0 {
|
||||||
discTotal = opts.albumDiscTotal
|
discTotal = opts.albumDiscTotal
|
||||||
@@ -1253,15 +1177,15 @@ func buildTagMetadata(trackMeta map[string]any, title, source, trackID string, o
|
|||||||
discNumber = 1
|
discNumber = 1
|
||||||
}
|
}
|
||||||
|
|
||||||
genre := nestedString(trackMeta, "genre", "name")
|
genre := jsonutil.NestedString(trackMeta, "genre", "name")
|
||||||
if genre == "" {
|
if genre == "" {
|
||||||
genre = stringFromAny(trackMeta["genre"])
|
genre = jsonutil.StringFromAny(trackMeta["genre"])
|
||||||
}
|
}
|
||||||
|
|
||||||
comment := stringFromAny(trackMeta["comment"])
|
comment := jsonutil.StringFromAny(trackMeta["comment"])
|
||||||
description := stringFromAny(trackMeta["description"])
|
description := jsonutil.StringFromAny(trackMeta["description"])
|
||||||
lyrics := stringFromAny(trackMeta["lyrics"])
|
lyrics := jsonutil.StringFromAny(trackMeta["lyrics"])
|
||||||
if lrc := stringFromAny(trackMeta["lyrics_synced"]); lrc != "" {
|
if lrc := jsonutil.StringFromAny(trackMeta["lyrics_synced"]); lrc != "" {
|
||||||
lyrics = lrc
|
lyrics = lrc
|
||||||
}
|
}
|
||||||
trackGain := replaygainGainFromAny(trackMeta["replaygain_track_gain"])
|
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"])
|
albumGain := replaygainGainFromAny(trackMeta["replaygain_album_gain"])
|
||||||
if albumGain == "" {
|
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"])
|
trackPeak := replaygainPeakFromAny(trackMeta["replaygain_track_peak"])
|
||||||
if trackPeak == "" {
|
if trackPeak == "" {
|
||||||
@@ -1281,22 +1205,22 @@ func buildTagMetadata(trackMeta map[string]any, title, source, trackID string, o
|
|||||||
}
|
}
|
||||||
albumPeak := replaygainPeakFromAny(trackMeta["replaygain_album_peak"])
|
albumPeak := replaygainPeakFromAny(trackMeta["replaygain_album_peak"])
|
||||||
if albumPeak == "" {
|
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 == "" {
|
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 == "" {
|
if sourceArtistID == "" {
|
||||||
sourceArtistID = nestedString(trackMeta, "performer", "id")
|
sourceArtistID = jsonutil.NestedString(trackMeta, "performer", "id")
|
||||||
}
|
}
|
||||||
if sourceArtistID == "" {
|
if sourceArtistID == "" {
|
||||||
sourceArtistID = stringFromAny(trackMeta["source_artist_id"])
|
sourceArtistID = jsonutil.StringFromAny(trackMeta["source_artist_id"])
|
||||||
}
|
}
|
||||||
sourceTrackID := trackID
|
sourceTrackID := trackID
|
||||||
if v := stringFromAny(trackMeta["source_track_id"]); v != "" {
|
if v := jsonutil.StringFromAny(trackMeta["source_track_id"]); v != "" {
|
||||||
sourceTrackID = v
|
sourceTrackID = v
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1314,8 +1238,8 @@ func buildTagMetadata(trackMeta map[string]any, title, source, trackID string, o
|
|||||||
Comment: comment,
|
Comment: comment,
|
||||||
Description: description,
|
Description: description,
|
||||||
Lyrics: lyrics,
|
Lyrics: lyrics,
|
||||||
Copyright: stringFromAny(trackMeta["copyright"]),
|
Copyright: jsonutil.StringFromAny(trackMeta["copyright"]),
|
||||||
ISRC: stringFromAny(trackMeta["isrc"]),
|
ISRC: jsonutil.StringFromAny(trackMeta["isrc"]),
|
||||||
ReplaygainTrackGain: trackGain,
|
ReplaygainTrackGain: trackGain,
|
||||||
ReplaygainAlbumGain: albumGain,
|
ReplaygainAlbumGain: albumGain,
|
||||||
ReplaygainTrackPeak: trackPeak,
|
ReplaygainTrackPeak: trackPeak,
|
||||||
|
|||||||
@@ -553,31 +553,6 @@ const deezerBFChunkSize = 2048
|
|||||||
|
|
||||||
var deezerBFIV = []byte{0, 1, 2, 3, 4, 5, 6, 7}
|
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 {
|
func deriveDeezerBlowfishKey(trackID string) []byte {
|
||||||
sum := md5.Sum([]byte(trackID))
|
sum := md5.Sum([]byte(trackID))
|
||||||
md5Hex := fmt.Sprintf("%x", sum)
|
md5Hex := fmt.Sprintf("%x", sum)
|
||||||
@@ -588,20 +563,3 @@ func deriveDeezerBlowfishKey(trackID string) []byte {
|
|||||||
}
|
}
|
||||||
return key
|
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
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -66,36 +66,15 @@ func TestManifestDetection(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestNormalizeDeezerTrackID(t *testing.T) {
|
func TestDeezerBlowfishKeyDerivation(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) {
|
|
||||||
trackID := "3135556"
|
trackID := "3135556"
|
||||||
plain := make([]byte, deezerBFChunkSize*2)
|
key := deriveDeezerBlowfishKey(trackID)
|
||||||
for i := range plain {
|
if len(key) != 16 {
|
||||||
plain[i] = byte(i % 251)
|
t.Fatalf("blowfish key len = %d, want 16", len(key))
|
||||||
}
|
|
||||||
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")
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
func TestFileDeezerEncrypted(t *testing.T) {
|
func TestFileDeezerEncrypted(t *testing.T) {
|
||||||
trackID := "3135556"
|
trackID := "3135556"
|
||||||
plain := make([]byte, deezerBFChunkSize+777)
|
plain := make([]byte, deezerBFChunkSize+777)
|
||||||
|
|||||||
131
internal/jsonutil/jsonutil.go
Normal file
131
internal/jsonutil/jsonutil.go
Normal file
@@ -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)
|
||||||
|
}
|
||||||
@@ -17,6 +17,7 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
|
|
||||||
"streamrip-go/internal/config"
|
"streamrip-go/internal/config"
|
||||||
|
"streamrip-go/internal/jsonutil"
|
||||||
"streamrip-go/internal/netutil"
|
"streamrip-go/internal/netutil"
|
||||||
"streamrip-go/internal/provider"
|
"streamrip-go/internal/provider"
|
||||||
"streamrip-go/internal/ratelimit"
|
"streamrip-go/internal/ratelimit"
|
||||||
@@ -152,7 +153,7 @@ func (c *Client) GetMetadata(ctx context.Context, item, mediaType string) (map[s
|
|||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
enrichTrack(resp)
|
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) != "" {
|
if strings.TrimSpace(lyr.Text) != "" {
|
||||||
resp["lyrics"] = lyr.Text
|
resp["lyrics"] = lyr.Text
|
||||||
}
|
}
|
||||||
@@ -205,7 +206,7 @@ func (c *Client) GetMetadata(ctx context.Context, item, mediaType string) (map[s
|
|||||||
case "artist":
|
case "artist":
|
||||||
name := strings.TrimSpace(item)
|
name := strings.TrimSpace(item)
|
||||||
if artistMeta, artistErr := c.apiGet(ctx, "/artist/"+item, nil); artistErr == nil {
|
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
|
name = n
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -246,7 +247,7 @@ func (c *Client) getArtistAlbums(ctx context.Context, artistID string) (map[stri
|
|||||||
data, _ := resp["data"].([]any)
|
data, _ := resp["data"].([]any)
|
||||||
all = append(all, data...)
|
all = append(all, data...)
|
||||||
if total < 0 {
|
if total < 0 {
|
||||||
total = intFromAny(resp["total"])
|
total = jsonutil.IntFromAny(resp["total"])
|
||||||
}
|
}
|
||||||
if len(data) < pageSize {
|
if len(data) < pageSize {
|
||||||
break
|
break
|
||||||
@@ -288,7 +289,7 @@ func (c *Client) GetDownloadable(ctx context.Context, item string, _ int) (*prov
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
trackToken := strings.TrimSpace(stringFromAny(meta["track_token"]))
|
trackToken := strings.TrimSpace(jsonutil.StringFromAny(meta["track_token"]))
|
||||||
if trackToken == "" {
|
if trackToken == "" {
|
||||||
trackToken, err = c.getTrackToken(ctx, item)
|
trackToken, err = c.getTrackToken(ctx, item)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -303,7 +304,7 @@ func (c *Client) GetDownloadable(ctx context.Context, item string, _ int) (*prov
|
|||||||
if ext == "" {
|
if ext == "" {
|
||||||
ext = "mp3"
|
ext = "mp3"
|
||||||
}
|
}
|
||||||
trackID := strings.TrimSpace(stringFromAny(meta["id"]))
|
trackID := strings.TrimSpace(jsonutil.StringFromAny(meta["id"]))
|
||||||
if trackID == "" {
|
if trackID == "" {
|
||||||
trackID = strings.TrimSpace(item)
|
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))
|
return nil, fmt.Errorf("deezer api failed: status=%d body=%s", resp.StatusCode, string(body))
|
||||||
}
|
}
|
||||||
if errObj, ok := out["error"].(map[string]any); ok {
|
if errObj, ok := out["error"].(map[string]any); ok {
|
||||||
msg := strings.TrimSpace(stringFromAny(errObj["message"]))
|
msg := strings.TrimSpace(jsonutil.StringFromAny(errObj["message"]))
|
||||||
if msg == "" {
|
if msg == "" {
|
||||||
msg = strings.TrimSpace(stringFromAny(errObj["type"]))
|
msg = strings.TrimSpace(jsonutil.StringFromAny(errObj["type"]))
|
||||||
}
|
}
|
||||||
if msg == "" {
|
if msg == "" {
|
||||||
msg = "unknown deezer error"
|
msg = "unknown deezer error"
|
||||||
@@ -394,17 +395,17 @@ func (c *Client) refreshSessionFromARL(ctx context.Context) error {
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
if errObj, ok := out["error"].(map[string]any); ok && len(errObj) > 0 {
|
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)
|
results, _ := out["results"].(map[string]any)
|
||||||
if len(results) == 0 {
|
if len(results) == 0 {
|
||||||
return errors.New("deezer getUserData returned empty results")
|
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.license = findStringByKey(results, "license_token")
|
||||||
c.userID = findStringByKey(results, "USER_ID")
|
c.userID = findStringByKey(results, "USER_ID")
|
||||||
c.jwt = firstNonEmpty(c.jwt, findStringByKey(results, "JWT"))
|
c.jwt = jsonutil.FirstNonEmpty(c.jwt, findStringByKey(results, "JWT"))
|
||||||
c.refresh = firstNonEmpty(c.refresh, findStringByKey(results, "refresh_token"))
|
c.refresh = jsonutil.FirstNonEmpty(c.refresh, findStringByKey(results, "refresh_token"))
|
||||||
if c.sid == "" {
|
if c.sid == "" {
|
||||||
if sid, sidErr := c.bootstrapSID(ctx); sidErr == nil {
|
if sid, sidErr := c.bootstrapSID(ctx); sidErr == nil {
|
||||||
c.sid = sid
|
c.sid = sid
|
||||||
@@ -460,7 +461,7 @@ func (c *Client) loginWithCredentials(ctx context.Context, email, password strin
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
c.sid = firstNonEmpty(c.sid, sid)
|
c.sid = jsonutil.FirstNonEmpty(c.sid, sid)
|
||||||
|
|
||||||
encryptedPassword, err := encryptPassword(mobileToken, password)
|
encryptedPassword, err := encryptPassword(mobileToken, password)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -515,22 +516,22 @@ func (c *Client) loginWithCredentials(ctx context.Context, email, password strin
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
if errObj, ok := out["error"].(map[string]any); ok && len(errObj) > 0 {
|
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 == "" {
|
if msg == "" {
|
||||||
msg = "unknown mobile_userAuth error"
|
msg = "unknown mobile_userAuth error"
|
||||||
}
|
}
|
||||||
return errors.New(msg)
|
return errors.New(msg)
|
||||||
}
|
}
|
||||||
results := nestedMap(out, "results")
|
results := jsonutil.NestedMap(out, "results")
|
||||||
if len(results) == 0 {
|
if len(results) == 0 {
|
||||||
return errors.New("mobile_userAuth returned empty results")
|
return errors.New("mobile_userAuth returned empty results")
|
||||||
}
|
}
|
||||||
|
|
||||||
c.arl = firstNonEmpty(c.arl, findStringByKey(results, "ARL"))
|
c.arl = jsonutil.FirstNonEmpty(c.arl, findStringByKey(results, "ARL"))
|
||||||
c.jwt = firstNonEmpty(c.jwt, findStringByKey(results, "JWT"))
|
c.jwt = jsonutil.FirstNonEmpty(c.jwt, findStringByKey(results, "JWT"))
|
||||||
c.refresh = firstNonEmpty(c.refresh, findStringByKey(results, "refresh_token"))
|
c.refresh = jsonutil.FirstNonEmpty(c.refresh, findStringByKey(results, "refresh_token"))
|
||||||
c.license = firstNonEmpty(c.license, findStringByKey(results, "license_token"))
|
c.license = jsonutil.FirstNonEmpty(c.license, findStringByKey(results, "license_token"))
|
||||||
c.userID = firstNonEmpty(c.userID, findStringByKey(results, "USER_ID"))
|
c.userID = jsonutil.FirstNonEmpty(c.userID, findStringByKey(results, "USER_ID"))
|
||||||
|
|
||||||
if c.arl == "" {
|
if c.arl == "" {
|
||||||
return errors.New("mobile_userAuth missing 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 {
|
if err != nil {
|
||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
token := strings.TrimSpace(stringFromAny(resp["track_token"]))
|
token := strings.TrimSpace(jsonutil.StringFromAny(resp["track_token"]))
|
||||||
if token == "" {
|
if token == "" {
|
||||||
return "", errors.New("deezer track metadata missing track_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 := ""
|
msg := ""
|
||||||
typ := ""
|
typ := ""
|
||||||
if em, ok := errs[0].(map[string]any); ok {
|
if em, ok := errs[0].(map[string]any); ok {
|
||||||
msg = strings.TrimSpace(stringFromAny(em["message"]))
|
msg = strings.TrimSpace(jsonutil.StringFromAny(em["message"]))
|
||||||
typ = strings.TrimSpace(stringFromAny(em["type"]))
|
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") {
|
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
|
return nil, errDeezerJWTExpired
|
||||||
@@ -624,8 +625,8 @@ func (c *Client) fetchLyricsFromPipe(ctx context.Context, trackID string) (*lyri
|
|||||||
}
|
}
|
||||||
return nil, errors.New(msg)
|
return nil, errors.New(msg)
|
||||||
}
|
}
|
||||||
lyrics := nestedMap(nestedMap(nestedMap(out, "data"), "track"), "lyrics")
|
lyrics := jsonutil.NestedMap(jsonutil.NestedMap(jsonutil.NestedMap(out, "data"), "track"), "lyrics")
|
||||||
text := strings.TrimSpace(stringFromAny(lyrics["text"]))
|
text := strings.TrimSpace(jsonutil.StringFromAny(lyrics["text"]))
|
||||||
synced := buildSyncedLRC(lyrics["synchronizedLines"])
|
synced := buildSyncedLRC(lyrics["synchronizedLines"])
|
||||||
if text != "" || synced != "" {
|
if text != "" || synced != "" {
|
||||||
return &lyricsResult{Text: text, SyncedLRC: synced}, nil
|
return &lyricsResult{Text: text, SyncedLRC: synced}, nil
|
||||||
@@ -637,9 +638,9 @@ func (c *Client) fetchLyricsFromPipe(ctx context.Context, trackID string) (*lyri
|
|||||||
if !ok {
|
if !ok {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
line := strings.TrimSpace(stringFromAny(m["line"]))
|
line := strings.TrimSpace(jsonutil.StringFromAny(m["line"]))
|
||||||
if line == "" {
|
if line == "" {
|
||||||
line = strings.TrimSpace(stringFromAny(m["lineTranslated"]))
|
line = strings.TrimSpace(jsonutil.StringFromAny(m["lineTranslated"]))
|
||||||
}
|
}
|
||||||
if line != "" {
|
if line != "" {
|
||||||
parts = append(parts, line)
|
parts = append(parts, line)
|
||||||
@@ -682,14 +683,14 @@ func buildSyncedLRC(v any) string {
|
|||||||
if !ok {
|
if !ok {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
line := strings.TrimSpace(stringFromAny(m["line"]))
|
line := strings.TrimSpace(jsonutil.StringFromAny(m["line"]))
|
||||||
if line == "" {
|
if line == "" {
|
||||||
line = strings.TrimSpace(stringFromAny(m["lineTranslated"]))
|
line = strings.TrimSpace(jsonutil.StringFromAny(m["lineTranslated"]))
|
||||||
}
|
}
|
||||||
if line == "" {
|
if line == "" {
|
||||||
continue
|
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))
|
out = append(out, fmt.Sprintf("[%02d:%05.2f]%s", ms/60000, float64(ms%60000)/1000.0, line))
|
||||||
}
|
}
|
||||||
return strings.Join(out, "\n")
|
return strings.Join(out, "\n")
|
||||||
@@ -741,13 +742,13 @@ func (c *Client) mobileAuth(ctx context.Context) (string, error) {
|
|||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
if errObj, ok := out["error"].(map[string]any); ok && len(errObj) > 0 {
|
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 == "" {
|
if msg == "" {
|
||||||
msg = "mobile_auth returned an error"
|
msg = "mobile_auth returned an error"
|
||||||
}
|
}
|
||||||
return "", errors.New(msg)
|
return "", errors.New(msg)
|
||||||
}
|
}
|
||||||
token := findStringByKey(nestedMap(out, "results"), "TOKEN")
|
token := findStringByKey(jsonutil.NestedMap(out, "results"), "TOKEN")
|
||||||
if token == "" {
|
if token == "" {
|
||||||
return "", errors.New("mobile_auth returned empty 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
|
return "", err
|
||||||
}
|
}
|
||||||
if errObj, ok := out["error"].(map[string]any); ok && len(errObj) > 0 {
|
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 == "" {
|
if msg == "" {
|
||||||
msg = "api_checkToken returned an error"
|
msg = "api_checkToken returned an error"
|
||||||
}
|
}
|
||||||
return "", errors.New(msg)
|
return "", errors.New(msg)
|
||||||
}
|
}
|
||||||
sid := strings.TrimSpace(stringFromAny(out["results"]))
|
sid := strings.TrimSpace(jsonutil.StringFromAny(out["results"]))
|
||||||
if sid == "" {
|
if sid == "" {
|
||||||
return "", errors.New("api_checkToken returned empty 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 {
|
if errObj, ok := out["error"].(map[string]any); ok && len(errObj) > 0 {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
results := nestedMap(out, "results")
|
results := jsonutil.NestedMap(out, "results")
|
||||||
if len(results) == 0 {
|
if len(results) == 0 {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
c.jwt = firstNonEmpty(c.jwt, findStringByKey(results, "JWT"))
|
c.jwt = jsonutil.FirstNonEmpty(c.jwt, findStringByKey(results, "JWT"))
|
||||||
c.refresh = firstNonEmpty(c.refresh, findStringByKey(results, "refresh_token"))
|
c.refresh = jsonutil.FirstNonEmpty(c.refresh, findStringByKey(results, "refresh_token"))
|
||||||
c.license = firstNonEmpty(c.license, findStringByKey(results, "license_token"))
|
c.license = jsonutil.FirstNonEmpty(c.license, findStringByKey(results, "license_token"))
|
||||||
if c.jwt != "" || c.license != "" {
|
if c.jwt != "" || c.license != "" {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
@@ -895,16 +896,16 @@ func (c *Client) refreshJWT(ctx context.Context) error {
|
|||||||
return errors.New("invalid jwt refresh response")
|
return errors.New("invalid jwt refresh response")
|
||||||
}
|
}
|
||||||
if errObj, ok := out["error"].(map[string]any); ok && len(errObj) > 0 {
|
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 == "" {
|
if msg == "" {
|
||||||
msg = "jwt refresh returned an error"
|
msg = "jwt refresh returned an error"
|
||||||
}
|
}
|
||||||
return errors.New(msg)
|
return errors.New(msg)
|
||||||
}
|
}
|
||||||
if jwt := strings.TrimSpace(stringFromAny(out["jwt"])); jwt != "" {
|
if jwt := strings.TrimSpace(jsonutil.StringFromAny(out["jwt"])); jwt != "" {
|
||||||
c.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
|
c.refresh = rt
|
||||||
}
|
}
|
||||||
if c.jwt == "" {
|
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 {
|
if errs, ok := out["errors"].([]any); ok && len(errs) > 0 {
|
||||||
msg := ""
|
msg := ""
|
||||||
if em, ok := errs[0].(map[string]any); ok {
|
if em, ok := errs[0].(map[string]any); ok {
|
||||||
msg = strings.TrimSpace(stringFromAny(em["message"]))
|
msg = strings.TrimSpace(jsonutil.StringFromAny(em["message"]))
|
||||||
}
|
}
|
||||||
if msg == "" {
|
if msg == "" {
|
||||||
msg = "pipe response returned graphql error"
|
msg = "pipe response returned graphql error"
|
||||||
@@ -1228,7 +1229,7 @@ func findStringByKey(v any, wantedKey string) string {
|
|||||||
case map[string]any:
|
case map[string]any:
|
||||||
for k, value := range x {
|
for k, value := range x {
|
||||||
if strings.ToLower(k) == w {
|
if strings.ToLower(k) == w {
|
||||||
if s := stringFromAny(value); strings.TrimSpace(s) != "" {
|
if s := jsonutil.StringFromAny(value); strings.TrimSpace(s) != "" {
|
||||||
return s
|
return s
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1246,17 +1247,9 @@ func findStringByKey(v any, wantedKey string) string {
|
|||||||
return ""
|
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) {
|
func enrichTrack(track map[string]any) {
|
||||||
if artist, ok := track["artist"].(map[string]any); ok {
|
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 {
|
if album, ok := track["album"].(map[string]any); ok {
|
||||||
enrichAlbumImage(album)
|
enrichAlbumImage(album)
|
||||||
@@ -1271,7 +1264,7 @@ func enrichTrack(track map[string]any) {
|
|||||||
track["media_number"] = d
|
track["media_number"] = d
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if boolFromAny(track["explicit_lyrics"]) {
|
if jsonutil.BoolFromAny(track["explicit_lyrics"]) {
|
||||||
track["explicit"] = true
|
track["explicit"] = true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1280,11 +1273,11 @@ func enrichAlbumImage(meta map[string]any) {
|
|||||||
if _, ok := meta["image"].(map[string]any); ok {
|
if _, ok := meta["image"].(map[string]any); ok {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
cover := firstNonEmpty(
|
cover := jsonutil.FirstNonEmpty(
|
||||||
stringFromAny(meta["cover_xl"]),
|
jsonutil.StringFromAny(meta["cover_xl"]),
|
||||||
stringFromAny(meta["cover_big"]),
|
jsonutil.StringFromAny(meta["cover_big"]),
|
||||||
stringFromAny(meta["cover_medium"]),
|
jsonutil.StringFromAny(meta["cover_medium"]),
|
||||||
stringFromAny(meta["cover_small"]),
|
jsonutil.StringFromAny(meta["cover_small"]),
|
||||||
)
|
)
|
||||||
if cover == "" {
|
if cover == "" {
|
||||||
return
|
return
|
||||||
@@ -1296,48 +1289,3 @@ func enrichAlbumImage(meta map[string]any) {
|
|||||||
"original": cover,
|
"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
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -9,6 +9,8 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
|
"streamrip-go/internal/jsonutil"
|
||||||
|
|
||||||
"streamrip-go/internal/config"
|
"streamrip-go/internal/config"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -89,7 +91,7 @@ func TestGetMetadataArtistPaginatesAlbums(t *testing.T) {
|
|||||||
if len(items) != 101 {
|
if len(items) != 101 {
|
||||||
t.Fatalf("albums len = %d, want 101", len(items))
|
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)
|
t.Fatalf("artist name = %q, want Lost Frequencies", got)
|
||||||
}
|
}
|
||||||
if callCount != 2 {
|
if callCount != 2 {
|
||||||
@@ -220,11 +222,11 @@ func TestGetMetadataAddsLyricsFromPipe(t *testing.T) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("GetMetadata() error = %v", err)
|
t.Fatalf("GetMetadata() error = %v", err)
|
||||||
}
|
}
|
||||||
if !strings.Contains(stringFromAny(meta["lyrics"]), "Go shawty") {
|
if !strings.Contains(jsonutil.StringFromAny(meta["lyrics"]), "Go shawty") {
|
||||||
t.Fatalf("expected lyrics text, got %q", stringFromAny(meta["lyrics"]))
|
t.Fatalf("expected lyrics text, got %q", jsonutil.StringFromAny(meta["lyrics"]))
|
||||||
}
|
}
|
||||||
if !strings.Contains(stringFromAny(meta["lyrics_synced"]), "[00:00.00]Go, go, go") {
|
if !strings.Contains(jsonutil.StringFromAny(meta["lyrics_synced"]), "[00:00.00]Go, go, go") {
|
||||||
t.Fatalf("expected synced lyrics, got %q", stringFromAny(meta["lyrics_synced"]))
|
t.Fatalf("expected synced lyrics, got %q", jsonutil.StringFromAny(meta["lyrics_synced"]))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -243,7 +245,7 @@ func TestLoginWithCredentials(t *testing.T) {
|
|||||||
case "mobile_userAuth":
|
case "mobile_userAuth":
|
||||||
var payload map[string]any
|
var payload map[string]any
|
||||||
_ = json.NewDecoder(r.Body).Decode(&payload)
|
_ = 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)
|
w.WriteHeader(http.StatusBadRequest)
|
||||||
_ = json.NewEncoder(w).Encode(map[string]any{"error": map[string]any{"message": "missing creds"}})
|
_ = json.NewEncoder(w).Encode(map[string]any{"error": map[string]any{"message": "missing creds"}})
|
||||||
return
|
return
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
|
|
||||||
"streamrip-go/internal/config"
|
"streamrip-go/internal/config"
|
||||||
|
"streamrip-go/internal/jsonutil"
|
||||||
"streamrip-go/internal/netutil"
|
"streamrip-go/internal/netutil"
|
||||||
"streamrip-go/internal/provider"
|
"streamrip-go/internal/provider"
|
||||||
"streamrip-go/internal/ratelimit"
|
"streamrip-go/internal/ratelimit"
|
||||||
@@ -694,7 +695,7 @@ func (c *Client) fetchAppIDAndSecrets(ctx context.Context) (string, []string, er
|
|||||||
|
|
||||||
tzNames := make([]string, 0, len(ordered))
|
tzNames := make([]string, 0, len(ordered))
|
||||||
for _, o := range 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, "|")))
|
infoRe := regexp.MustCompile(fmt.Sprintf(infoExtrasTemplate, strings.Join(tzNames, "|")))
|
||||||
idxInfo := infoRe.SubexpIndex("info")
|
idxInfo := infoRe.SubexpIndex("info")
|
||||||
|
|||||||
@@ -10,12 +10,12 @@ import (
|
|||||||
"net/url"
|
"net/url"
|
||||||
"os/exec"
|
"os/exec"
|
||||||
"regexp"
|
"regexp"
|
||||||
"strconv"
|
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"streamrip-go/internal/config"
|
"streamrip-go/internal/config"
|
||||||
|
"streamrip-go/internal/jsonutil"
|
||||||
"streamrip-go/internal/provider"
|
"streamrip-go/internal/provider"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -102,14 +102,14 @@ func (c *Client) searchTracks(ctx context.Context, query string, limit int) ([]m
|
|||||||
if id == "" {
|
if id == "" {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
artist := strings.TrimSpace(stringFromAny(m["uploader"]))
|
artist := strings.TrimSpace(jsonutil.StringFromAny(m["uploader"]))
|
||||||
if artist == "" {
|
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{
|
item := map[string]any{
|
||||||
"id": id,
|
"id": id,
|
||||||
"title": stringFromAny(m["title"]),
|
"title": jsonutil.StringFromAny(m["title"]),
|
||||||
"artist": map[string]any{
|
"artist": map[string]any{
|
||||||
"name": artist,
|
"name": artist,
|
||||||
},
|
},
|
||||||
@@ -117,7 +117,7 @@ func (c *Client) searchTracks(ctx context.Context, query string, limit int) ([]m
|
|||||||
if artistID != "" {
|
if artistID != "" {
|
||||||
item["artist"] = map[string]any{"name": artist, "id": 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
|
item["source_track_id"] = trackID
|
||||||
}
|
}
|
||||||
items = append(items, item)
|
items = append(items, item)
|
||||||
@@ -163,17 +163,17 @@ func (c *Client) searchPlaylists(ctx context.Context, query string, limit int) (
|
|||||||
if infoErr != nil {
|
if infoErr != nil {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
title := strings.TrimSpace(stringFromAny(info["title"]))
|
title := strings.TrimSpace(jsonutil.StringFromAny(info["title"]))
|
||||||
if title == "" {
|
if title == "" {
|
||||||
title = strings.Trim(strings.ReplaceAll(path, "/", " "), " ")
|
title = strings.Trim(strings.ReplaceAll(path, "/", " "), " ")
|
||||||
}
|
}
|
||||||
artist := strings.TrimSpace(firstNonEmpty(stringFromAny(info["uploader"]), stringFromAny(info["channel"])))
|
artist := strings.TrimSpace(jsonutil.FirstNonEmpty(jsonutil.StringFromAny(info["uploader"]), jsonutil.StringFromAny(info["channel"])))
|
||||||
artistID := strings.TrimSpace(firstNonEmpty(stringFromAny(info["uploader_id"]), stringFromAny(info["channel_id"])))
|
artistID := strings.TrimSpace(jsonutil.FirstNonEmpty(jsonutil.StringFromAny(info["uploader_id"]), jsonutil.StringFromAny(info["channel_id"])))
|
||||||
trackCount := 0
|
trackCount := 0
|
||||||
if entries := asAnySlice(info["entries"]); len(entries) > 0 {
|
if entries := asAnySlice(info["entries"]); len(entries) > 0 {
|
||||||
trackCount = len(entries)
|
trackCount = len(entries)
|
||||||
}
|
}
|
||||||
canonical := firstNonEmpty(canonicalSoundcloudURL(info), playlistURL)
|
canonical := jsonutil.FirstNonEmpty(canonicalSoundcloudURL(info), playlistURL)
|
||||||
item := map[string]any{
|
item := map[string]any{
|
||||||
"id": canonical,
|
"id": canonical,
|
||||||
"title": title,
|
"title": title,
|
||||||
@@ -183,10 +183,10 @@ func (c *Client) searchPlaylists(ctx context.Context, query string, limit int) (
|
|||||||
if artistID != "" {
|
if artistID != "" {
|
||||||
item["artist"] = map[string]any{"name": artist, "id": 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
|
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)
|
item["image"] = soundcloudImageMap(thumb)
|
||||||
}
|
}
|
||||||
items = append(items, item)
|
items = append(items, item)
|
||||||
@@ -228,15 +228,15 @@ func (c *Client) GetMetadata(ctx context.Context, item, mediaType string) (map[s
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
track := map[string]any{"id": id}
|
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
|
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
|
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}
|
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
|
artistMap["id"] = artistID
|
||||||
}
|
}
|
||||||
track["artist"] = artistMap
|
track["artist"] = artistMap
|
||||||
@@ -244,23 +244,23 @@ func (c *Client) GetMetadata(ctx context.Context, item, mediaType string) (map[s
|
|||||||
track["track_number"] = i + 1
|
track["track_number"] = i + 1
|
||||||
tracks = append(tracks, track)
|
tracks = append(tracks, track)
|
||||||
}
|
}
|
||||||
name := strings.TrimSpace(stringFromAny(root["title"]))
|
name := strings.TrimSpace(jsonutil.StringFromAny(root["title"]))
|
||||||
if name == "" {
|
if name == "" {
|
||||||
name = "SoundCloud Playlist"
|
name = "SoundCloud Playlist"
|
||||||
}
|
}
|
||||||
meta := map[string]any{
|
meta := map[string]any{
|
||||||
"id": firstNonEmpty(canonicalSoundcloudURL(root), item),
|
"id": jsonutil.FirstNonEmpty(canonicalSoundcloudURL(root), item),
|
||||||
"name": name,
|
"name": name,
|
||||||
"description": strings.TrimSpace(stringFromAny(root["description"])),
|
"description": strings.TrimSpace(jsonutil.StringFromAny(root["description"])),
|
||||||
"tracks": map[string]any{"items": tracks},
|
"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
|
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}
|
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)
|
meta["image"] = soundcloudImageMap(thumb)
|
||||||
}
|
}
|
||||||
if entries := asAnySlice(root["entries"]); len(entries) > 0 {
|
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 {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
streamURL := strings.TrimSpace(stringFromAny(info["url"]))
|
streamURL := strings.TrimSpace(jsonutil.StringFromAny(info["url"]))
|
||||||
if streamURL == "" {
|
if streamURL == "" {
|
||||||
return nil, errors.New("yt-dlp output missing url (track may be unavailable or region-restricted)")
|
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 == "" {
|
if ext == "" {
|
||||||
ext = "m4a"
|
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 {
|
func trackMetadataFromInfo(id string, info map[string]any) map[string]any {
|
||||||
canonicalID := firstNonEmpty(canonicalSoundcloudURL(info), id)
|
canonicalID := jsonutil.FirstNonEmpty(canonicalSoundcloudURL(info), id)
|
||||||
publisher := nestedMap(info, "publisher_metadata")
|
publisher := jsonutil.NestedMap(info, "publisher_metadata")
|
||||||
title := strings.TrimSpace(stringFromAny(info["title"]))
|
title := strings.TrimSpace(jsonutil.StringFromAny(info["title"]))
|
||||||
if title == "" {
|
if title == "" {
|
||||||
title = canonicalID
|
title = canonicalID
|
||||||
}
|
}
|
||||||
albumTitle := strings.TrimSpace(stringFromAny(publisher["album_title"]))
|
albumTitle := strings.TrimSpace(jsonutil.StringFromAny(publisher["album_title"]))
|
||||||
if albumTitle == "" {
|
if albumTitle == "" {
|
||||||
albumTitle = strings.TrimSpace(stringFromAny(info["album"]))
|
albumTitle = strings.TrimSpace(jsonutil.StringFromAny(info["album"]))
|
||||||
}
|
}
|
||||||
if albumTitle == "" {
|
if albumTitle == "" {
|
||||||
albumTitle = title
|
albumTitle = title
|
||||||
}
|
}
|
||||||
artistName := strings.TrimSpace(stringFromAny(info["artist"]))
|
artistName := strings.TrimSpace(jsonutil.StringFromAny(info["artist"]))
|
||||||
if artistName == "" {
|
if artistName == "" {
|
||||||
artistName = strings.TrimSpace(stringFromAny(publisher["artist"]))
|
artistName = strings.TrimSpace(jsonutil.StringFromAny(publisher["artist"]))
|
||||||
}
|
}
|
||||||
if artistName == "" {
|
if artistName == "" {
|
||||||
artistName = strings.TrimSpace(stringFromAny(info["uploader"]))
|
artistName = strings.TrimSpace(jsonutil.StringFromAny(info["uploader"]))
|
||||||
}
|
}
|
||||||
if artistName == "" {
|
if artistName == "" {
|
||||||
artistName = strings.TrimSpace(stringFromAny(info["channel"]))
|
artistName = strings.TrimSpace(jsonutil.StringFromAny(info["channel"]))
|
||||||
}
|
}
|
||||||
artistID := strings.TrimSpace(firstNonEmpty(
|
artistID := strings.TrimSpace(jsonutil.FirstNonEmpty(
|
||||||
stringFromAny(info["uploader_id"]),
|
jsonutil.StringFromAny(info["uploader_id"]),
|
||||||
stringFromAny(info["channel_id"]),
|
jsonutil.StringFromAny(info["channel_id"]),
|
||||||
stringFromAny(nestedMap(info, "user")["id"]),
|
jsonutil.StringFromAny(jsonutil.NestedMap(info, "user")["id"]),
|
||||||
))
|
))
|
||||||
|
|
||||||
trackNum := intFromAny(info["track_number"])
|
trackNum := jsonutil.IntFromAny(info["track_number"])
|
||||||
if trackNum <= 0 {
|
if trackNum <= 0 {
|
||||||
trackNum = 1
|
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},
|
"artist": map[string]any{"name": artistName, "id": artistID},
|
||||||
"performer": map[string]any{"name": artistName, "id": artistID},
|
"performer": map[string]any{"name": artistName, "id": artistID},
|
||||||
"album": map[string]any{
|
"album": map[string]any{
|
||||||
"id": firstNonEmpty(strings.TrimSpace(stringFromAny(info["album"])), canonicalID),
|
"id": jsonutil.FirstNonEmpty(strings.TrimSpace(jsonutil.StringFromAny(info["album"])), canonicalID),
|
||||||
"title": albumTitle,
|
"title": albumTitle,
|
||||||
"artist": map[string]any{"name": artistName, "id": artistID},
|
"artist": map[string]any{"name": artistName, "id": artistID},
|
||||||
},
|
},
|
||||||
"description": strings.TrimSpace(stringFromAny(info["description"])),
|
"description": strings.TrimSpace(jsonutil.StringFromAny(info["description"])),
|
||||||
"genre": strings.TrimSpace(stringFromAny(info["genre"])),
|
"genre": strings.TrimSpace(jsonutil.StringFromAny(info["genre"])),
|
||||||
"isrc": strings.TrimSpace(stringFromAny(info["isrc"])),
|
"isrc": strings.TrimSpace(jsonutil.StringFromAny(info["isrc"])),
|
||||||
"label": strings.TrimSpace(firstNonEmpty(stringFromAny(info["label"]), stringFromAny(info["label_name"]))),
|
"label": strings.TrimSpace(jsonutil.FirstNonEmpty(jsonutil.StringFromAny(info["label"]), jsonutil.StringFromAny(info["label_name"]))),
|
||||||
"copyright": strings.TrimSpace(stringFromAny(publisher["p_line"])),
|
"copyright": strings.TrimSpace(jsonutil.StringFromAny(publisher["p_line"])),
|
||||||
"release_date": strings.TrimSpace(firstNonEmpty(
|
"release_date": strings.TrimSpace(jsonutil.FirstNonEmpty(
|
||||||
stringFromAny(info["created_at"]),
|
jsonutil.StringFromAny(info["created_at"]),
|
||||||
stringFromAny(info["release_date"]),
|
jsonutil.StringFromAny(info["release_date"]),
|
||||||
stringFromAny(info["upload_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
|
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
|
meta["explicit"] = true
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -405,11 +405,11 @@ func trackMetadataFromInfo(id string, info map[string]any) map[string]any {
|
|||||||
delete(meta, "release_date")
|
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)
|
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{
|
meta["album"] = map[string]any{
|
||||||
"id": canonicalID,
|
"id": canonicalID,
|
||||||
"title": title,
|
"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
|
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 {
|
func canonicalSoundcloudURL(info map[string]any) string {
|
||||||
for _, key := range []string{"webpage_url", "original_url", "url"} {
|
for _, key := range []string{"webpage_url", "original_url", "url"} {
|
||||||
raw := strings.TrimSpace(stringFromAny(info[key]))
|
raw := strings.TrimSpace(jsonutil.StringFromAny(info[key]))
|
||||||
if raw == "" {
|
if raw == "" {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
@@ -478,72 +478,6 @@ func asAnySlice(v any) []any {
|
|||||||
return items
|
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 {
|
func soundcloudImageMap(raw string) map[string]any {
|
||||||
base := strings.TrimSpace(raw)
|
base := strings.TrimSpace(raw)
|
||||||
if base == "" {
|
if base == "" {
|
||||||
|
|||||||
@@ -8,6 +8,8 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
|
"streamrip-go/internal/jsonutil"
|
||||||
|
|
||||||
"streamrip-go/internal/config"
|
"streamrip-go/internal/config"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -27,11 +29,11 @@ func TestGetTrackMetadataAndDownloadable(t *testing.T) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("GetMetadata() error = %v", err)
|
t.Fatalf("GetMetadata() error = %v", err)
|
||||||
}
|
}
|
||||||
if stringFromAny(meta["title"]) != "Lean On" {
|
if jsonutil.StringFromAny(meta["title"]) != "Lean On" {
|
||||||
t.Fatalf("title = %q, want Lean On", stringFromAny(meta["title"]))
|
t.Fatalf("title = %q, want Lean On", jsonutil.StringFromAny(meta["title"]))
|
||||||
}
|
}
|
||||||
if stringFromAny(meta["id"]) != "https://soundcloud.com/a/b" {
|
if jsonutil.StringFromAny(meta["id"]) != "https://soundcloud.com/a/b" {
|
||||||
t.Fatalf("id = %q, want canonical soundcloud url", stringFromAny(meta["id"]))
|
t.Fatalf("id = %q, want canonical soundcloud url", jsonutil.StringFromAny(meta["id"]))
|
||||||
}
|
}
|
||||||
|
|
||||||
d, err := c.GetDownloadable(context.Background(), "https://soundcloud.com/a/b", 0)
|
d, err := c.GetDownloadable(context.Background(), "https://soundcloud.com/a/b", 0)
|
||||||
@@ -59,8 +61,8 @@ func TestGetPlaylistMetadata(t *testing.T) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("GetMetadata() error = %v", err)
|
t.Fatalf("GetMetadata() error = %v", err)
|
||||||
}
|
}
|
||||||
if stringFromAny(meta["name"]) != "Road Trip" {
|
if jsonutil.StringFromAny(meta["name"]) != "Road Trip" {
|
||||||
t.Fatalf("name = %q, want Road Trip", stringFromAny(meta["name"]))
|
t.Fatalf("name = %q, want Road Trip", jsonutil.StringFromAny(meta["name"]))
|
||||||
}
|
}
|
||||||
tracksMap, ok := meta["tracks"].(map[string]any)
|
tracksMap, ok := meta["tracks"].(map[string]any)
|
||||||
if !ok {
|
if !ok {
|
||||||
@@ -70,8 +72,8 @@ func TestGetPlaylistMetadata(t *testing.T) {
|
|||||||
if len(items) != 2 {
|
if len(items) != 2 {
|
||||||
t.Fatalf("playlist items len = %d, want 2", len(items))
|
t.Fatalf("playlist items len = %d, want 2", len(items))
|
||||||
}
|
}
|
||||||
if stringFromAny(meta["id"]) != "https://soundcloud.com/a/sets/road-trip" {
|
if jsonutil.StringFromAny(meta["id"]) != "https://soundcloud.com/a/sets/road-trip" {
|
||||||
t.Fatalf("playlist id not canonical: %q", stringFromAny(meta["id"]))
|
t.Fatalf("playlist id not canonical: %q", jsonutil.StringFromAny(meta["id"]))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -102,8 +104,8 @@ func TestSearchTrack(t *testing.T) {
|
|||||||
if !ok {
|
if !ok {
|
||||||
t.Fatalf("expected first item map")
|
t.Fatalf("expected first item map")
|
||||||
}
|
}
|
||||||
if stringFromAny(item0["id"]) != "https://soundcloud.com/a/b" {
|
if jsonutil.StringFromAny(item0["id"]) != "https://soundcloud.com/a/b" {
|
||||||
t.Fatalf("track search id not canonical: %q", stringFromAny(item0["id"]))
|
t.Fatalf("track search id not canonical: %q", jsonutil.StringFromAny(item0["id"]))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -147,8 +149,8 @@ func TestSearchPlaylist(t *testing.T) {
|
|||||||
if !ok {
|
if !ok {
|
||||||
t.Fatalf("expected first item map")
|
t.Fatalf("expected first item map")
|
||||||
}
|
}
|
||||||
if stringFromAny(item0["id"]) != "https://soundcloud.com/a/sets/road-trip" {
|
if jsonutil.StringFromAny(item0["id"]) != "https://soundcloud.com/a/sets/road-trip" {
|
||||||
t.Fatalf("playlist search id not canonical: %q", stringFromAny(item0["id"]))
|
t.Fatalf("playlist search id not canonical: %q", jsonutil.StringFromAny(item0["id"]))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -192,8 +194,8 @@ func TestSearchPlaylistAcceptsDotsInPath(t *testing.T) {
|
|||||||
if !ok {
|
if !ok {
|
||||||
t.Fatalf("expected first item map")
|
t.Fatalf("expected first item map")
|
||||||
}
|
}
|
||||||
if stringFromAny(item0["id"]) != "https://soundcloud.com/artist.name/sets/road.trip" {
|
if jsonutil.StringFromAny(item0["id"]) != "https://soundcloud.com/artist.name/sets/road.trip" {
|
||||||
t.Fatalf("playlist search id not canonical: %q", stringFromAny(item0["id"]))
|
t.Fatalf("playlist search id not canonical: %q", jsonutil.StringFromAny(item0["id"]))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -221,18 +223,18 @@ func TestTrackMetadataIncludesExplicitAndISRC(t *testing.T) {
|
|||||||
"thumbnail": "https://img",
|
"thumbnail": "https://img",
|
||||||
"upload_date": "20240101",
|
"upload_date": "20240101",
|
||||||
})
|
})
|
||||||
if stringFromAny(meta["isrc"]) != "US123" {
|
if jsonutil.StringFromAny(meta["isrc"]) != "US123" {
|
||||||
t.Fatalf("isrc = %q, want US123", stringFromAny(meta["isrc"]))
|
t.Fatalf("isrc = %q, want US123", jsonutil.StringFromAny(meta["isrc"]))
|
||||||
}
|
}
|
||||||
explicit, _ := meta["explicit"].(bool)
|
explicit, _ := meta["explicit"].(bool)
|
||||||
if !explicit {
|
if !explicit {
|
||||||
t.Fatalf("expected explicit=true")
|
t.Fatalf("expected explicit=true")
|
||||||
}
|
}
|
||||||
if stringFromAny(meta["source_track_id"]) != "9876" {
|
if jsonutil.StringFromAny(meta["source_track_id"]) != "9876" {
|
||||||
t.Fatalf("source_track_id = %q, want 9876", stringFromAny(meta["source_track_id"]))
|
t.Fatalf("source_track_id = %q, want 9876", jsonutil.StringFromAny(meta["source_track_id"]))
|
||||||
}
|
}
|
||||||
if stringFromAny(nestedMap(meta, "album")["title"]) != "T" {
|
if jsonutil.StringFromAny(jsonutil.NestedMap(meta, "album")["title"]) != "T" {
|
||||||
t.Fatalf("album title mismatch: %#v", nestedMap(meta, "album"))
|
t.Fatalf("album title mismatch: %#v", jsonutil.NestedMap(meta, "album"))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
|
|
||||||
"streamrip-go/internal/config"
|
"streamrip-go/internal/config"
|
||||||
|
"streamrip-go/internal/jsonutil"
|
||||||
"streamrip-go/internal/netutil"
|
"streamrip-go/internal/netutil"
|
||||||
"streamrip-go/internal/provider"
|
"streamrip-go/internal/provider"
|
||||||
"streamrip-go/internal/ratelimit"
|
"streamrip-go/internal/ratelimit"
|
||||||
@@ -149,7 +150,7 @@ func (c *Client) refreshAccessToken(ctx context.Context) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
newRefresh := stringify(resp["refresh_token"])
|
newRefresh := stringify(resp["refresh_token"])
|
||||||
expiresIn := int64(intFromAny(resp["expires_in"]))
|
expiresIn := int64(jsonutil.IntFromAny(resp["expires_in"]))
|
||||||
if expiresIn <= 0 {
|
if expiresIn <= 0 {
|
||||||
expiresIn = 7 * 24 * 3600
|
expiresIn = 7 * 24 * 3600
|
||||||
}
|
}
|
||||||
@@ -773,19 +774,3 @@ func tidalImageMap(cover string) map[string]any {
|
|||||||
"original": base + "/1280x1280.jpg",
|
"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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
Reference in New Issue
Block a user