Files
streamrip-go/cmd/rip/args.go
JorenandClaude Opus 5 26fb466afc refactor: finish the jsonutil dedupe; back up config before auto-upgrade
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>
2026-10-03 17:26:24 +02:00

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
}