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:
2026-04-20 00:56:10 +02:00
parent 4da5114a70
commit b2688ce949
15 changed files with 1746 additions and 57 deletions

View File

@@ -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")
}

View File

@@ -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")
}
}