mirror of
https://git.sr.ht/~joren/streamrip-go
synced 2026-06-17 15:05:39 +02:00
add CLI parity flags and expand provider support
This brings the Go CLI closer to upstream behavior with global flag handling and clearer resolve failures, while adding Tidal video downloads plus initial Deezer and SoundCloud no-account flows for broader end-to-end coverage.
This commit is contained in:
383
cmd/rip/main.go
383
cmd/rip/main.go
@@ -31,17 +31,28 @@ import (
|
||||
)
|
||||
|
||||
func main() {
|
||||
if len(os.Args) < 2 {
|
||||
gopts, err := parseGlobalArgs(os.Args[1:])
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "option error: %v\n", err)
|
||||
os.Exit(2)
|
||||
}
|
||||
if gopts.command == "" {
|
||||
fmt.Println("usage: rip <command>")
|
||||
fmt.Println("commands: url, file, config, database, id, search, lastfm, qobuz-smoke, qobuz-rip-smoke, qobuz-convert-rip-smoke, qobuz-album-rip-smoke, qobuz-playlist-rip-smoke, qobuz-artist-rip-smoke, qobuz-label-rip-smoke, qobuz-search-smoke, tidal-search-smoke, tidal-metadata-smoke, tidal-rip-smoke, tidal-album-rip-smoke, tidal-playlist-rip-smoke, tidal-artist-rip-smoke")
|
||||
fmt.Println("commands: url, file, config, database, id, search, lastfm, soundcloud-smoke, qobuz-smoke, qobuz-rip-smoke, qobuz-convert-rip-smoke, qobuz-album-rip-smoke, qobuz-playlist-rip-smoke, qobuz-artist-rip-smoke, qobuz-label-rip-smoke, qobuz-search-smoke, tidal-search-smoke, tidal-metadata-smoke, tidal-video-smoke, tidal-rip-smoke, tidal-album-rip-smoke, tidal-playlist-rip-smoke, tidal-artist-rip-smoke")
|
||||
os.Exit(2)
|
||||
}
|
||||
|
||||
cfg, err := config.Load("")
|
||||
cfg, err := config.Load(gopts.configPath)
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "config error: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
applyGlobalConfigOverrides(cfg, gopts)
|
||||
if gopts.verbose {
|
||||
fmt.Fprintln(os.Stderr, "verbose mode enabled")
|
||||
}
|
||||
|
||||
os.Args = append([]string{os.Args[0], gopts.command}, gopts.commandArgs...)
|
||||
|
||||
ctx := context.Background()
|
||||
|
||||
@@ -68,7 +79,7 @@ func main() {
|
||||
}
|
||||
rawArgs = append(rawArgs, arg)
|
||||
}
|
||||
mainApp.IgnoreDB = ignoreDB
|
||||
mainApp.IgnoreDB = ignoreDB || gopts.noDB
|
||||
|
||||
added := 0
|
||||
for _, raw := range rawArgs {
|
||||
@@ -126,7 +137,7 @@ func main() {
|
||||
os.Exit(1)
|
||||
}
|
||||
defer func() { _ = mainApp.Close() }()
|
||||
mainApp.IgnoreDB = ignoreDB
|
||||
mainApp.IgnoreDB = ignoreDB || gopts.noDB
|
||||
|
||||
added := 0
|
||||
if jsonInput {
|
||||
@@ -268,7 +279,7 @@ func main() {
|
||||
}
|
||||
case "id":
|
||||
if len(os.Args) < 5 {
|
||||
fmt.Println("usage: rip id <source> <track|album|playlist|artist|label> <id> [quality] [--force|--ignore-db]")
|
||||
fmt.Println("usage: rip id <source> <track|album|playlist|artist|label|video> <id> [quality] [--force|--ignore-db]")
|
||||
os.Exit(2)
|
||||
}
|
||||
|
||||
@@ -300,7 +311,7 @@ func main() {
|
||||
os.Exit(1)
|
||||
}
|
||||
defer func() { _ = mainApp.Close() }()
|
||||
mainApp.IgnoreDB = opts.ignoreDB
|
||||
mainApp.IgnoreDB = opts.ignoreDB || gopts.noDB
|
||||
|
||||
if err = mainApp.AddByID(ctx, source, mediaType, itemID); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "add error: %v\n", err)
|
||||
@@ -320,7 +331,7 @@ func main() {
|
||||
var sopts searchOptions
|
||||
if len(os.Args) < 5 {
|
||||
if !term.IsTerminal(int(os.Stdin.Fd())) {
|
||||
fmt.Println("usage: rip search <qobuz|tidal> <track|album|playlist|artist|label> <query...> [--limit N] [--force|--ignore-db] [--no-download]")
|
||||
fmt.Println("usage: rip search <qobuz|tidal|deezer|soundcloud> <track|album|playlist|artist|label|video> <query...> [--limit N] [--force|--ignore-db] [--no-download]")
|
||||
os.Exit(2)
|
||||
}
|
||||
source, mediaType, sopts, err = promptSearchInteractive(cfg.Session.CLI.MaxSearchResults)
|
||||
@@ -346,6 +357,10 @@ func main() {
|
||||
fmt.Fprintf(os.Stderr, "unsupported media type %q\n", mediaType)
|
||||
os.Exit(2)
|
||||
}
|
||||
if source == "soundcloud" && mediaType != "track" {
|
||||
fmt.Fprintln(os.Stderr, "soundcloud search currently supports media type track only")
|
||||
os.Exit(2)
|
||||
}
|
||||
if sopts.query == "" {
|
||||
fmt.Fprintln(os.Stderr, "search query cannot be empty")
|
||||
os.Exit(2)
|
||||
@@ -375,12 +390,11 @@ func main() {
|
||||
fmt.Println("no results")
|
||||
return
|
||||
}
|
||||
|
||||
fmt.Printf("results: %d\n", len(results))
|
||||
for i, result := range results {
|
||||
fmt.Printf("%2d. id=%s | %s\n", i+1, result.ID, result.Title)
|
||||
}
|
||||
if sopts.outputFile != "" {
|
||||
fmt.Printf("results: %d\n", len(results))
|
||||
for i, result := range results {
|
||||
fmt.Printf("%2d. id=%s | %s\n", i+1, result.ID, result.Title)
|
||||
}
|
||||
if err = writeSearchResultsToFile(source, mediaType, results, sopts.outputFile); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "write results error: %v\n", err)
|
||||
os.Exit(1)
|
||||
@@ -389,15 +403,24 @@ func main() {
|
||||
return
|
||||
}
|
||||
if sopts.first {
|
||||
fmt.Printf("results: %d\n", len(results))
|
||||
fmt.Printf(" 1. id=%s | %s\n", results[0].ID, results[0].Title)
|
||||
results = results[:1]
|
||||
}
|
||||
if sopts.noDownload {
|
||||
fmt.Printf("results: %d\n", len(results))
|
||||
for i, result := range results {
|
||||
fmt.Printf("%2d. id=%s | %s\n", i+1, result.ID, result.Title)
|
||||
}
|
||||
return
|
||||
}
|
||||
if !sopts.first {
|
||||
fmt.Printf("results: %d\n", len(results))
|
||||
}
|
||||
|
||||
if sopts.first {
|
||||
selection := []int{0}
|
||||
mainApp.IgnoreDB = sopts.ignoreDB
|
||||
mainApp.IgnoreDB = sopts.ignoreDB || gopts.noDB
|
||||
skippedDownloaded := 0
|
||||
added := 0
|
||||
for _, idx := range selection {
|
||||
@@ -451,7 +474,7 @@ func main() {
|
||||
return
|
||||
}
|
||||
|
||||
mainApp.IgnoreDB = sopts.ignoreDB
|
||||
mainApp.IgnoreDB = sopts.ignoreDB || gopts.noDB
|
||||
skippedDownloaded := 0
|
||||
added := 0
|
||||
for _, idx := range selection {
|
||||
@@ -530,6 +553,17 @@ func main() {
|
||||
os.Exit(1)
|
||||
}
|
||||
fmt.Printf("lastfm rip complete (%d track(s))\n", len(mainApp.Pending))
|
||||
case "soundcloud-smoke":
|
||||
if len(os.Args) < 3 {
|
||||
fmt.Println("usage: rip soundcloud-smoke <soundcloud_url>")
|
||||
os.Exit(2)
|
||||
}
|
||||
meta, err := fetchSoundcloudOEmbed(ctx, cfg.Session.Downloads.VerifySSL, strings.TrimSpace(os.Args[2]))
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "soundcloud smoke error: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
fmt.Printf("soundcloud oembed ok: title=%q author=%q provider=%q\n", asString(meta["title"]), asString(meta["author_name"]), asString(meta["provider_name"]))
|
||||
case "qobuz-smoke":
|
||||
if len(os.Args) < 3 {
|
||||
fmt.Println("usage: rip qobuz-smoke <track_id> [quality]")
|
||||
@@ -593,7 +627,7 @@ func main() {
|
||||
os.Exit(1)
|
||||
}
|
||||
defer func() { _ = mainApp.Close() }()
|
||||
mainApp.IgnoreDB = opts.ignoreDB
|
||||
mainApp.IgnoreDB = opts.ignoreDB || gopts.noDB
|
||||
|
||||
trackID := os.Args[2]
|
||||
if err = mainApp.AddByID(ctx, "qobuz", "track", trackID); err != nil {
|
||||
@@ -632,7 +666,7 @@ func main() {
|
||||
os.Exit(1)
|
||||
}
|
||||
defer func() { _ = mainApp.Close() }()
|
||||
mainApp.IgnoreDB = opts.ignoreDB
|
||||
mainApp.IgnoreDB = opts.ignoreDB || gopts.noDB
|
||||
|
||||
trackID := os.Args[2]
|
||||
if err = mainApp.AddByID(ctx, "qobuz", "track", trackID); err != nil {
|
||||
@@ -668,7 +702,7 @@ func main() {
|
||||
os.Exit(1)
|
||||
}
|
||||
defer func() { _ = mainApp.Close() }()
|
||||
mainApp.IgnoreDB = opts.ignoreDB
|
||||
mainApp.IgnoreDB = opts.ignoreDB || gopts.noDB
|
||||
|
||||
albumID := os.Args[2]
|
||||
if err = mainApp.AddByID(ctx, "qobuz", "album", albumID); err != nil {
|
||||
@@ -705,7 +739,7 @@ func main() {
|
||||
os.Exit(1)
|
||||
}
|
||||
defer func() { _ = mainApp.Close() }()
|
||||
mainApp.IgnoreDB = opts.ignoreDB
|
||||
mainApp.IgnoreDB = opts.ignoreDB || gopts.noDB
|
||||
|
||||
playlistID := os.Args[2]
|
||||
if err = mainApp.AddByID(ctx, "qobuz", "playlist", playlistID); err != nil {
|
||||
@@ -740,7 +774,7 @@ func main() {
|
||||
os.Exit(1)
|
||||
}
|
||||
defer func() { _ = mainApp.Close() }()
|
||||
mainApp.IgnoreDB = opts.ignoreDB
|
||||
mainApp.IgnoreDB = opts.ignoreDB || gopts.noDB
|
||||
artistID := os.Args[2]
|
||||
if err = mainApp.AddByID(ctx, "qobuz", "artist", artistID); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "add error: %v\n", err)
|
||||
@@ -774,7 +808,7 @@ func main() {
|
||||
os.Exit(1)
|
||||
}
|
||||
defer func() { _ = mainApp.Close() }()
|
||||
mainApp.IgnoreDB = opts.ignoreDB
|
||||
mainApp.IgnoreDB = opts.ignoreDB || gopts.noDB
|
||||
labelID := os.Args[2]
|
||||
if err = mainApp.AddByID(ctx, "qobuz", "label", labelID); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "add error: %v\n", err)
|
||||
@@ -920,6 +954,49 @@ func main() {
|
||||
}
|
||||
}
|
||||
fmt.Printf("tidal metadata ok: type=%s id=%s title=%q tracks=%d\n", mediaType, itemID, title, trackCount)
|
||||
case "tidal-video-smoke":
|
||||
if len(os.Args) < 3 {
|
||||
fmt.Println("usage: rip tidal-video-smoke <video_id>")
|
||||
os.Exit(2)
|
||||
}
|
||||
|
||||
mainApp, err := app.New(cfg)
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "app init error: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
defer func() { _ = mainApp.Close() }()
|
||||
|
||||
providerClient, err := mainApp.GetLoggedInProvider(ctx, "tidal")
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "tidal login error: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
videoProvider, ok := providerClient.(interface {
|
||||
GetVideoDownloadable(context.Context, string) (*provider.Downloadable, error)
|
||||
})
|
||||
if !ok {
|
||||
fmt.Fprintln(os.Stderr, "tidal provider does not support video downloadable")
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
videoID := strings.TrimSpace(os.Args[2])
|
||||
meta, err := providerClient.GetMetadata(ctx, videoID, "video")
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "video metadata error: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
d, err := videoProvider.GetVideoDownloadable(ctx, videoID)
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "video downloadable error: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
title := asString(meta["title"])
|
||||
if title == "" {
|
||||
title = asString(meta["name"])
|
||||
}
|
||||
fmt.Printf("tidal video ok: id=%s title=%q ext=%s\n", videoID, title, d.Extension)
|
||||
fmt.Printf("stream_url=%s\n", d.URL)
|
||||
case "tidal-rip-smoke":
|
||||
if len(os.Args) < 3 {
|
||||
fmt.Println("usage: rip tidal-rip-smoke <track_id> [quality] [--force|--ignore-db]")
|
||||
@@ -940,7 +1017,7 @@ func main() {
|
||||
os.Exit(1)
|
||||
}
|
||||
defer func() { _ = mainApp.Close() }()
|
||||
mainApp.IgnoreDB = opts.ignoreDB
|
||||
mainApp.IgnoreDB = opts.ignoreDB || gopts.noDB
|
||||
|
||||
trackID := os.Args[2]
|
||||
if err = mainApp.AddByID(ctx, "tidal", "track", trackID); err != nil {
|
||||
@@ -976,7 +1053,7 @@ func main() {
|
||||
os.Exit(1)
|
||||
}
|
||||
defer func() { _ = mainApp.Close() }()
|
||||
mainApp.IgnoreDB = opts.ignoreDB
|
||||
mainApp.IgnoreDB = opts.ignoreDB || gopts.noDB
|
||||
|
||||
albumID := os.Args[2]
|
||||
if err = mainApp.AddByID(ctx, "tidal", "album", albumID); err != nil {
|
||||
@@ -1012,7 +1089,7 @@ func main() {
|
||||
os.Exit(1)
|
||||
}
|
||||
defer func() { _ = mainApp.Close() }()
|
||||
mainApp.IgnoreDB = opts.ignoreDB
|
||||
mainApp.IgnoreDB = opts.ignoreDB || gopts.noDB
|
||||
|
||||
playlistID := os.Args[2]
|
||||
if err = mainApp.AddByID(ctx, "tidal", "playlist", playlistID); err != nil {
|
||||
@@ -1047,7 +1124,7 @@ func main() {
|
||||
os.Exit(1)
|
||||
}
|
||||
defer func() { _ = mainApp.Close() }()
|
||||
mainApp.IgnoreDB = opts.ignoreDB
|
||||
mainApp.IgnoreDB = opts.ignoreDB || gopts.noDB
|
||||
artistID := os.Args[2]
|
||||
if err = mainApp.AddByID(ctx, "tidal", "artist", artistID); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "add error: %v\n", err)
|
||||
@@ -1074,6 +1151,143 @@ type smokeOptions struct {
|
||||
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 parseSmokeOptions(args []string, minQuality int, maxQuality int) (smokeOptions, error) {
|
||||
opts := smokeOptions{}
|
||||
for _, arg := range args {
|
||||
@@ -1154,11 +1368,11 @@ func addURLToQueue(ctx context.Context, mainApp *app.Main, raw string) bool {
|
||||
fmt.Printf("invalid: %s\n", raw)
|
||||
return false
|
||||
}
|
||||
if parsed.Kind != urlparse.KindGeneric {
|
||||
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" {
|
||||
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
|
||||
}
|
||||
@@ -1506,6 +1720,43 @@ func queueLastFMTracks(ctx context.Context, mainApp *app.Main, opts lastFMOption
|
||||
return 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 {
|
||||
@@ -1701,7 +1952,11 @@ func promptSearchSelectionMenu(source, mediaType, query string, results []search
|
||||
labels := make([]string, 0, len(results))
|
||||
labelToIndex := map[string]int{}
|
||||
for i, r := range results {
|
||||
label := fmt.Sprintf("%2d. %s", i+1, r.Title)
|
||||
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
|
||||
}
|
||||
@@ -1712,10 +1967,11 @@ func promptSearchSelectionMenu(source, mediaType, query string, results []search
|
||||
Help: "SPACE: select ENTER: download /: filter ESC: cancel",
|
||||
Options: labels,
|
||||
Description: func(value string, index int) string {
|
||||
if index < 0 || index >= len(results) {
|
||||
resultIndex, ok := labelToIndex[value]
|
||||
if !ok || resultIndex < 0 || resultIndex >= len(results) {
|
||||
return ""
|
||||
}
|
||||
return formatSearchDetails(results[index])
|
||||
return formatSearchDetails(results[resultIndex])
|
||||
},
|
||||
PageSize: 15,
|
||||
}
|
||||
@@ -1762,12 +2018,12 @@ func writeSearchResultsToFile(source, mediaType string, results []searchResult,
|
||||
}
|
||||
|
||||
func isAllowedSearchSource(source string) bool {
|
||||
return source == "qobuz" || source == "tidal"
|
||||
return source == "qobuz" || source == "tidal" || source == "deezer" || source == "soundcloud"
|
||||
}
|
||||
|
||||
func isAllowedMediaType(mediaType string) bool {
|
||||
switch mediaType {
|
||||
case "track", "album", "playlist", "artist", "label":
|
||||
case "track", "album", "playlist", "artist", "label", "video":
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
@@ -1787,7 +2043,7 @@ func promptSearchInteractive(defaultLimit int) (string, string, searchOptions, e
|
||||
}
|
||||
|
||||
for {
|
||||
source, err := read("Source [qobuz/tidal]: ")
|
||||
source, err := read("Source [qobuz/tidal/deezer/soundcloud]: ")
|
||||
if err != nil {
|
||||
return "", "", searchOptions{}, err
|
||||
}
|
||||
@@ -1797,7 +2053,7 @@ func promptSearchInteractive(defaultLimit int) (string, string, searchOptions, e
|
||||
continue
|
||||
}
|
||||
|
||||
mediaType, err := read("Type [track/album/playlist/artist/label]: ")
|
||||
mediaType, err := read("Type [track/album/playlist/artist/label/video]: ")
|
||||
if err != nil {
|
||||
return "", "", searchOptions{}, err
|
||||
}
|
||||
@@ -1806,6 +2062,10 @@ func promptSearchInteractive(defaultLimit int) (string, string, searchOptions, e
|
||||
fmt.Println("Invalid media type.")
|
||||
continue
|
||||
}
|
||||
if source == "soundcloud" && mediaType != "track" {
|
||||
fmt.Println("SoundCloud search supports track only.")
|
||||
continue
|
||||
}
|
||||
|
||||
query, err := read("Query: ")
|
||||
if err != nil {
|
||||
@@ -1911,25 +2171,72 @@ func normalizeSearchResults(source, mediaType string, pages []map[string]any) []
|
||||
results = append(results, 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"])
|
||||
if id != "" && title != "" {
|
||||
results = append(results, 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")
|
||||
if id != "" && title != "" {
|
||||
results = append(results, searchResult{ID: id, Title: title, Artist: artist})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return results
|
||||
}
|
||||
|
||||
func formatSearchDetails(r searchResult) string {
|
||||
lines := []string{fmt.Sprintf("ID: %s", r.ID), fmt.Sprintf("Title: %s", r.Title)}
|
||||
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))
|
||||
lines = append(lines, fmt.Sprintf("Artist : %s", r.Artist))
|
||||
}
|
||||
if strings.TrimSpace(r.Album) != "" {
|
||||
lines = append(lines, fmt.Sprintf("Album: %s", r.Album))
|
||||
lines = append(lines, fmt.Sprintf("Album : %s", r.Album))
|
||||
}
|
||||
if r.TrackCount > 0 {
|
||||
lines = append(lines, fmt.Sprintf("Tracks: %d", r.TrackCount))
|
||||
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")
|
||||
}
|
||||
|
||||
|
||||
@@ -115,3 +115,54 @@ func TestExtractLastFMPlaylistInfoAndPairs(t *testing.T) {
|
||||
t.Fatalf("unexpected first pair: %+v", pairs[0])
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseGlobalArgsNoDBBeforeCommand(t *testing.T) {
|
||||
opts, err := parseGlobalArgs([]string{"-ndb", "url", "https://play.qobuz.com/album/0004228000522"})
|
||||
if err != nil {
|
||||
t.Fatalf("parseGlobalArgs() error = %v", err)
|
||||
}
|
||||
if !opts.noDB {
|
||||
t.Fatalf("expected noDB true")
|
||||
}
|
||||
if opts.command != "url" {
|
||||
t.Fatalf("command = %q, want %q", opts.command, "url")
|
||||
}
|
||||
if len(opts.commandArgs) != 1 {
|
||||
t.Fatalf("command args len = %d, want 1", len(opts.commandArgs))
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseGlobalArgsAllOfficialFlags(t *testing.T) {
|
||||
opts, err := parseGlobalArgs([]string{
|
||||
"--config-path", "/tmp/custom.toml",
|
||||
"-f", "/tmp/music",
|
||||
"--no-db",
|
||||
"-q", "3",
|
||||
"-c", "ogg",
|
||||
"--no-progress",
|
||||
"--no-ssl-verify",
|
||||
"-v",
|
||||
"search", "tidal", "track", "dreams",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("parseGlobalArgs() error = %v", err)
|
||||
}
|
||||
if opts.configPath != "/tmp/custom.toml" || opts.folder != "/tmp/music" {
|
||||
t.Fatalf("unexpected path/folder opts: %+v", opts)
|
||||
}
|
||||
if !opts.noDB || !opts.qualitySet || opts.quality != 3 || !opts.codecSet || opts.codec != "VORBIS" {
|
||||
t.Fatalf("unexpected quality/codec/db opts: %+v", opts)
|
||||
}
|
||||
if !opts.noProgress || !opts.noSSLVerify || !opts.verbose {
|
||||
t.Fatalf("unexpected boolean opts: %+v", opts)
|
||||
}
|
||||
if opts.command != "search" {
|
||||
t.Fatalf("command = %q, want search", opts.command)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizeCodecRejectsUnknown(t *testing.T) {
|
||||
if _, err := normalizeCodec("wav"); err == nil {
|
||||
t.Fatalf("expected error for unsupported codec")
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user