From 99c531928e0c379b59aca35983fb3a5456109070 Mon Sep 17 00:00:00 2001 From: Joren Date: Sun, 12 Jul 2026 00:06:18 +0200 Subject: [PATCH] feat: add cli help menus --- cmd/rip/args.go | 4 ++ cmd/rip/help.go | 160 +++++++++++++++++++++++++++++++++++++++++++ cmd/rip/main.go | 52 +++++++++----- cmd/rip/main_test.go | 53 ++++++++++++++ 4 files changed, 252 insertions(+), 17 deletions(-) create mode 100644 cmd/rip/help.go diff --git a/cmd/rip/args.go b/cmd/rip/args.go index c571380..47ec727 100644 --- a/cmd/rip/args.go +++ b/cmd/rip/args.go @@ -25,6 +25,7 @@ type globalOptions struct { noProgress bool noSSLVerify bool verbose int + help bool command string commandArgs []string } @@ -45,6 +46,9 @@ func parseGlobalArgs(args []string) (globalOptions, error) { } switch { + case isHelpArg(arg): + opts.help = true + return opts, nil case arg == "-ndb" || arg == "--no-db": opts.noDB = true case arg == "--no-progress": diff --git a/cmd/rip/help.go b/cmd/rip/help.go new file mode 100644 index 0000000..240d1d7 --- /dev/null +++ b/cmd/rip/help.go @@ -0,0 +1,160 @@ +package main + +import ( + "fmt" + "io" + "strings" +) + +func isHelpArg(arg string) bool { + return arg == "-h" || arg == "--help" +} + +func commandWantsHelp(args []string) bool { + for _, arg := range args { + if isHelpArg(arg) { + return true + } + } + return false +} + +func printMainHelp(w io.Writer) { + fmt.Fprint(w, `streamrip-go + +Usage: + rip [global options] [command options] + rip help [command] + +Commands: + url Rip one or more URLs + file Rip URLs or IDs from a file + id Rip by source, media type, and ID + search Search a provider and download selected results + lastfm Import a Last.fm playlist + config Manage configuration + database Inspect download databases + +Global options: + -f, --folder Override downloads folder + -q, --quality <0-4> Override provider quality + -c, --codec Convert after download: ALAC, FLAC, OGG, MP3, AAC + -ndb, --no-db Ignore download database + --no-progress Disable progress bars + --no-ssl-verify Disable TLS verification + --config-path Use a custom config file + -v, -vv Verbose logging + -h, --help Show help + +Run 'rip help ' for command-specific help. +`) +} + +func printCommandHelp(w io.Writer, command string) bool { + switch strings.ToLower(strings.TrimSpace(command)) { + case "url": + fmt.Fprint(w, `Usage: + rip url [--force|--ignore-db] + +Rip one or more provider URLs. + +Examples: + rip url https://www.beatport.com/release/example/123 + rip url https://play.qobuz.com/album/abc --force + +Options: + --force, --ignore-db Redownload even if already in the database +`) + case "file": + fmt.Fprint(w, `Usage: + rip file [--force|--ignore-db] + +Rip URLs or JSON ID entries from a file. + +Options: + --force, --ignore-db Redownload even if already in the database +`) + case "id": + fmt.Fprint(w, `Usage: + rip id [quality] [--force|--ignore-db] + +Rip an item by provider ID. + +Sources: + qobuz, tidal, deezer, yandex, beatport, soundcloud + +Media types: + track, album, playlist, artist, label, chart, video + +Options: + quality Override quality for this rip, 0-4 + --force, --ignore-db Redownload even if already in the database +`) + case "search": + fmt.Fprint(w, `Usage: + rip search [options] + +Search a provider and optionally download selected results. + +Sources: + qobuz, tidal, deezer, yandex, beatport, soundcloud + +Media types: + track, album, playlist, artist, label, chart, video + +Options: + --limit N Maximum results to show + --first Download the first result without prompting + --no-download Show results only + --output-file Write results as JSON + --force, --ignore-db Redownload even if already in the database +`) + case "lastfm": + fmt.Fprint(w, `Usage: + rip lastfm [--source SOURCE] [--fallback-source SOURCE] + +Import a Last.fm playlist and resolve tracks through configured providers. + +Options: + --source SOURCE Primary lookup source + --fallback-source SOURCE Fallback lookup source +`) + case "config": + fmt.Fprint(w, `Usage: + rip config [options] + +Manage configuration. + +Commands: + open Open the config file in an editor + reset Reset config to defaults + path Print the config path + +Options: + -v, --vim Use Vim for 'rip config open' + -y, --yes Confirm 'rip config reset' without prompting +`) + case "database": + fmt.Fprint(w, `Usage: + rip database browse + +Inspect local download databases. +`) + case "dev-help": + printDeveloperHelp(w) + default: + return false + } + return true +} + +func printDeveloperHelp(w io.Writer) { + fmt.Fprint(w, `Developer smoke commands: + 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 +`) +} diff --git a/cmd/rip/main.go b/cmd/rip/main.go index c93eced..f726c34 100644 --- a/cmd/rip/main.go +++ b/cmd/rip/main.go @@ -25,10 +25,34 @@ func main() { fmt.Fprintf(os.Stderr, "option error: %v\n", err) os.Exit(2) } + if gopts.help { + printMainHelp(os.Stdout) + return + } if gopts.command == "" { - fmt.Println("usage: rip ") - fmt.Println("commands: url, file, config, database, id, search, lastfm") - fmt.Println("tip: run `rip dev-help` to list developer smoke commands") + printMainHelp(os.Stdout) + os.Exit(2) + } + if gopts.command == "help" { + if len(gopts.commandArgs) == 0 || isHelpArg(gopts.commandArgs[0]) { + printMainHelp(os.Stdout) + return + } + if printCommandHelp(os.Stdout, gopts.commandArgs[0]) { + return + } + fmt.Fprintf(os.Stderr, "unknown help topic: %s\n", gopts.commandArgs[0]) + os.Exit(2) + } + if gopts.command == "dev-help" { + printDeveloperHelp(os.Stdout) + return + } + if commandWantsHelp(gopts.commandArgs) { + if printCommandHelp(os.Stdout, gopts.command) { + return + } + fmt.Fprintf(os.Stderr, "unknown command: %s\n", gopts.command) os.Exit(2) } @@ -64,17 +88,11 @@ func main() { switch os.Args[1] { case "dev-help": - fmt.Println("developer smoke commands:") - fmt.Println(" soundcloud-smoke") - fmt.Println(" qobuz-smoke, qobuz-rip-smoke, qobuz-convert-rip-smoke") - fmt.Println(" qobuz-album-rip-smoke, qobuz-playlist-rip-smoke, qobuz-artist-rip-smoke, qobuz-label-rip-smoke") - fmt.Println(" qobuz-search-smoke") - fmt.Println(" tidal-search-smoke, tidal-metadata-smoke, tidal-video-smoke") - fmt.Println(" tidal-rip-smoke, tidal-album-rip-smoke, tidal-playlist-rip-smoke, tidal-artist-rip-smoke") + printDeveloperHelp(os.Stdout) return case "url": if len(os.Args) < 3 { - fmt.Println("usage: rip url [--force|--ignore-db]") + printCommandHelp(os.Stdout, "url") os.Exit(2) } @@ -119,7 +137,7 @@ func main() { fmt.Printf("url rip complete (%d item(s))\n", added) case "file": if len(os.Args) < 3 { - fmt.Println("usage: rip file [--force|--ignore-db]") + printCommandHelp(os.Stdout, "file") os.Exit(2) } @@ -192,7 +210,7 @@ func main() { fmt.Printf("file rip complete (%d item(s))\n", added) case "config": if len(os.Args) < 3 { - fmt.Println("usage: rip config [options]") + printCommandHelp(os.Stdout, "config") os.Exit(2) } switch os.Args[2] { @@ -258,7 +276,7 @@ func main() { } case "database": if len(os.Args) < 4 || os.Args[2] != "browse" { - fmt.Println("usage: rip database browse ") + printCommandHelp(os.Stdout, "database") os.Exit(2) } table := strings.ToLower(strings.TrimSpace(os.Args[3])) @@ -294,7 +312,7 @@ func main() { } case "id": if len(os.Args) < 5 { - fmt.Println("usage: rip id [quality] [--force|--ignore-db]") + printCommandHelp(os.Stdout, "id") os.Exit(2) } @@ -354,7 +372,7 @@ func main() { var sopts searchOptions if len(os.Args) < 5 { if !term.IsTerminal(int(os.Stdin.Fd())) { - fmt.Println("usage: rip search [--limit N] [--force|--ignore-db] [--no-download]") + printCommandHelp(os.Stdout, "search") os.Exit(2) } source, mediaType, sopts, err = promptSearchInteractive(cfg.Session.CLI.MaxSearchResults) @@ -549,7 +567,7 @@ func main() { opts, parseErr := parseLastFMArgs(os.Args[2:], cfg.Session.LastFM.Source, cfg.Session.LastFM.FallbackSource) if parseErr != nil { fmt.Fprintf(os.Stderr, "lastfm option error: %v\n", parseErr) - fmt.Println("usage: rip lastfm [--source SOURCE] [--fallback-source SOURCE] ") + printCommandHelp(os.Stdout, "lastfm") os.Exit(2) } diff --git a/cmd/rip/main_test.go b/cmd/rip/main_test.go index 004dedf..34dfe1f 100644 --- a/cmd/rip/main_test.go +++ b/cmd/rip/main_test.go @@ -1,6 +1,7 @@ package main import ( + "bytes" "errors" "os" "path/filepath" @@ -206,6 +207,58 @@ func TestParseGlobalArgsNoDBBeforeCommand(t *testing.T) { } } +func TestParseGlobalArgsHelp(t *testing.T) { + opts, err := parseGlobalArgs([]string{"--help"}) + if err != nil { + t.Fatalf("parseGlobalArgs() error = %v", err) + } + if !opts.help { + t.Fatalf("expected help=true") + } +} + +func TestCommandWantsHelp(t *testing.T) { + if !commandWantsHelp([]string{"https://example.com", "-h"}) { + t.Fatalf("expected command help") + } + if commandWantsHelp([]string{"https://example.com"}) { + t.Fatalf("did not expect command help") + } +} + +func TestMainHelpHidesDeveloperCommands(t *testing.T) { + var buf bytes.Buffer + printMainHelp(&buf) + out := buf.String() + if !strings.Contains(out, "rip help [command]") || !strings.Contains(out, "Commands:") { + t.Fatalf("main help missing expected text: %s", out) + } + if strings.Contains(out, "dev-help") || strings.Contains(out, "smoke") { + t.Fatalf("main help should hide developer commands: %s", out) + } +} + +func TestCommandHelpURL(t *testing.T) { + var buf bytes.Buffer + if !printCommandHelp(&buf, "url") { + t.Fatalf("expected url help") + } + out := buf.String() + if !strings.Contains(out, "rip url ") || !strings.Contains(out, "--ignore-db") { + t.Fatalf("url help missing expected text: %s", out) + } +} + +func TestPrintCommandHelpRejectsUnknown(t *testing.T) { + var buf bytes.Buffer + if printCommandHelp(&buf, "nope") { + t.Fatalf("unexpected help for unknown command") + } + if buf.Len() != 0 { + t.Fatalf("unexpected output for unknown command: %s", buf.String()) + } +} + func TestParseGlobalArgsAllOfficialFlags(t *testing.T) { opts, err := parseGlobalArgs([]string{ "--config-path", "/tmp/custom.toml",