mirror of
https://git.sr.ht/~joren/streamrip-go
synced 2026-10-16 13:57:25 +02:00
The repo had no README, Makefile, CI, linter config or version string. - README.md: requirements (ffmpeg/ffprobe, plus yt-dlp for SoundCloud), build, the per-source credentials table, usage, the quality scale and the download database's migration behaviour. Every claim was checked against the code rather than written from memory, which is how the quality-range bug below was found. It also records the two deferred refactors (no shared HTTP layer, and metadata as map[string]any) so they are documented rather than lore, and notes that there is no LICENSE file. - Makefile: build, install, test, race, cover, fmt, fmt-check, vet, lint, check, clean, help. `make build` stamps version/commit/date via -ldflags. - .github/workflows/ci.yml: gofmt, vet, build, test and test -race, plus a lint job. The linter is intentionally unpinned, with a comment: a golangci-lint built against an older Go cannot analyse a go1.25 module, which is the same reason a locally installed staticcheck built with go1.24 cannot run here. - .golangci.yml: standard set plus bodyclose, errorlint, nilerr and govet/copylocks. copylocks matters now that config.Config carries a mutex; bodyclose and errorlint target defects this codebase actually has. - `rip --version` / `-V`, documented in the help output, falling back to the VCS revision the toolchain embeds for `go install` builds. Fixes a bug introduced in the previous commit: Validate capped deezer quality at 2 and soundcloud at 1, but config.toml.example documents deezer as 2/3/4 and the global -q flag accepts 0-4, so a documented config would have failed to load. deezer's buildFormatPriority treats anything >= 2 as FLAC and soundcloud ignores the value entirely, so the real ceiling is 4 everywhere; only the Qobuz and Beatport floor of 1 is a genuine constraint. Pinned with a test that walks the whole documented range. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
262 lines
6.7 KiB
Go
262 lines
6.7 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
|
|
}
|
|
|
|
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 ""
|
|
}
|
|
}
|