mirror of
https://git.sr.ht/~joren/streamrip-go
synced 2026-10-16 22:07:30 +02:00
Duplicated helpers cmd/rip had its own copies of six helpers that already existed in internal/jsonutil: asString (StringFromAny), searchInt (IntFromAny), nestedSearchInt and nestedSearchString (NestedAny/NestedString), firstNonEmpty (FirstNonEmpty) and firstPositiveInt. firstPositiveInt was duplicated a third and fourth time in provider/yandex and provider/beatport, so it moves into jsonutil alongside the rest. jsonutil is back to 100% coverage. searchBool is deliberately left alone, with a comment saying why: it accepts only a real bool, and switching it to jsonutil.BoolFromAny would start coercing strings and numbers, changing how search results get labelled. config auto-upgrade is no longer destructive UpgradeOutdated re-serializes the whole config from the parsed struct, so any key this version does not know about is dropped, and it runs automatically from run() without prompting. It now copies the original to <path>.bak-<unixtime> (0600, since it holds the same credentials) and validates the result before writing. It returns the backup path so the CLI can tell the user where it went. The comment states plainly that this is a version stamp rather than a migration framework. cmd/rip tests The command bodies are testable now that they are functions returning exit codes rather than arms of a switch that called os.Exit. Covers the usage errors for every command, the quality floor on `rip id`, config path output, the --version flag, and that applyQualityOverride reaches all six sources and clamps only the two with a floor. cmd/rip coverage 17% -> 26%. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
247 lines
6.4 KiB
Go
247 lines
6.4 KiB
Go
package main
|
|
|
|
import (
|
|
"fmt"
|
|
"os"
|
|
"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 int
|
|
help bool
|
|
version 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 isHelpArg(arg):
|
|
opts.help = true
|
|
return opts, nil
|
|
case arg == "--version" || arg == "-V":
|
|
opts.version = true
|
|
return opts, nil
|
|
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":
|
|
if opts.verbose < 2 {
|
|
opts.verbose++
|
|
}
|
|
case arg == "-vv":
|
|
if opts.verbose < 2 {
|
|
opts.verbose = 2
|
|
}
|
|
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
|
|
}
|
|
|
|
// normalizeCodec delegates to config so the --codec flag and a codec set in
|
|
// the config file are validated by the same rules.
|
|
func normalizeCodec(raw string) (string, error) {
|
|
return config.NormalizeCodec(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 {
|
|
applyQualityOverride(cfg, 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
|
|
}
|
|
}
|
|
|
|
// minQualityForSource is the lowest quality id each source accepts. Qobuz and
|
|
// Beatport have no equivalent of the "lowest" tier that 0 means elsewhere.
|
|
var minQualityForSource = map[string]int{
|
|
"qobuz": 1,
|
|
"beatport": 1,
|
|
}
|
|
|
|
// applyQualityOverride applies a global -q/--quality to every source.
|
|
//
|
|
// It previously set only qobuz, tidal, deezer and soundcloud, so
|
|
// `rip -q 4 url <beatport-url>` was a silent no-op even though
|
|
// Main.qualityForSource reads Yandex and Beatport too. Sources with a higher
|
|
// floor are clamped rather than erroring, because one global flag has to work
|
|
// across a mixed queue.
|
|
func applyQualityOverride(cfg *config.Config, quality int) {
|
|
clamped := map[string]int{}
|
|
for _, source := range []string{"qobuz", "tidal", "deezer", "soundcloud", "yandex", "beatport"} {
|
|
q := quality
|
|
if min, ok := minQualityForSource[source]; ok && q < min {
|
|
q = min
|
|
clamped[source] = q
|
|
}
|
|
switch source {
|
|
case "qobuz":
|
|
cfg.Session.Qobuz.Quality = q
|
|
case "tidal":
|
|
cfg.Session.Tidal.Quality = q
|
|
case "deezer":
|
|
cfg.Session.Deezer.Quality = q
|
|
case "soundcloud":
|
|
cfg.Session.Soundcloud.Quality = q
|
|
case "yandex":
|
|
cfg.Session.Yandex.Quality = q
|
|
case "beatport":
|
|
cfg.Session.Beatport.Quality = q
|
|
}
|
|
}
|
|
for _, source := range []string{"qobuz", "beatport"} {
|
|
if q, ok := clamped[source]; ok {
|
|
fmt.Fprintf(os.Stderr, "note: %s has no quality %d; using %d for %s\n", source, quality, q, source)
|
|
}
|
|
}
|
|
}
|
|
|
|
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
|
|
}
|