refactor: split the command dispatch out of main.go

Completes the restructuring started when main() became run() int. main.go goes
from 1217 lines to 130: global option handling, config loading, signal setup,
and a dispatch switch that delegates to one function per command.

The command bodies move to cmd_rip.go (url, file, id), cmd_search.go (search,
lastfm), cmd_config.go (config, database) and cmd_smoke.go (the developer smoke
commands), each taking (ctx, cfg, gopts, args) and returning an exit code.

They also stop reading the process global. Previously run() did

    os.Args = append([]string{os.Args[0], gopts.command}, gopts.commandArgs...)

and the arms then indexed back into os.Args[2:], os.Args[5:] and so on, so the
already-parsed options were round-tripped through os.Args. The arms now take
the parsed gopts.commandArgs directly, which is a uniform index shift of two
(os.Args[5:] -> args[3:], len(os.Args) < 5 -> len(args) < 3).

No behaviour change. Verified by running each command's argument validation and
help path against the built binary, plus the existing suite.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
JorenandClaude Opus 5 committed 2026-10-03 17:17:24 +02:00
1 parent ff5fb1cdb5
commit 78d4f0316d
5 files changed
+936 -800

No files matched your search

+281
View File
@@ -0,0 +1,281 @@
package main
import (
"context"
"fmt"
"os"
"strings"
"streamrip-go/internal/app"
"streamrip-go/internal/config"
"golang.org/x/term"
)
// Commands that discover media before ripping it: search and lastfm.
func cmdSearch(ctx context.Context, cfg *config.Config, gopts globalOptions, args []string) int {
var err error
var source, mediaType string
var sopts searchOptions
if len(args) < 3 {
if !term.IsTerminal(int(os.Stdin.Fd())) {
printCommandHelp(os.Stdout, "search")
return 2
}
source, mediaType, sopts, err = promptSearchInteractive(cfg.Session.CLI.MaxSearchResults)
if err != nil {
fmt.Fprintf(os.Stderr, "search prompt error: %v\n", err)
return 2
}
} else {
source = strings.ToLower(strings.TrimSpace(args[0]))
mediaType = strings.ToLower(strings.TrimSpace(args[1]))
sopts, err = parseSearchArgs(args[2:], cfg.Session.CLI.MaxSearchResults)
}
if err != nil {
fmt.Fprintf(os.Stderr, "search option error: %v\n", err)
return 2
}
if sopts.first && sopts.outputFile != "" {
fmt.Fprintln(os.Stderr, "cannot choose --first and --output-file together")
return 2
}
if !isAllowedSearchSource(source) {
fmt.Fprintf(os.Stderr, "unsupported search source %q\n", source)
return 2
}
if !isAllowedMediaType(mediaType) {
fmt.Fprintf(os.Stderr, "unsupported media type %q\n", mediaType)
return 2
}
if source == "soundcloud" && mediaType != "track" && mediaType != "playlist" {
fmt.Fprintln(os.Stderr, "soundcloud search currently supports media types track and playlist")
return 2
}
if source == "yandex" && mediaType != "track" && mediaType != "album" && mediaType != "playlist" && mediaType != "artist" {
fmt.Fprintln(os.Stderr, "yandex search currently supports media types track, album, playlist, and artist")
return 2
}
if source == "beatport" && mediaType != "track" && mediaType != "album" && mediaType != "label" {
fmt.Fprintln(os.Stderr, "beatport search currently supports media types track, album, and label")
return 2
}
if sopts.query == "" {
fmt.Fprintln(os.Stderr, "search query cannot be empty")
return 2
}
mainApp, err := app.New(cfg)
if err != nil {
fmt.Fprintf(os.Stderr, "app init error: %v\n", err)
return 1
}
defer func() { _ = mainApp.Close() }()
provider, err := mainApp.GetLoggedInProvider(ctx, source)
if err != nil {
fmt.Fprintf(os.Stderr, "%s login error: %v\n", source, err)
return 1
}
pages, err := provider.Search(ctx, mediaType, sopts.query, sopts.limit)
if err != nil {
fmt.Fprintf(os.Stderr, "search error: %v\n", err)
return 1
}
results := normalizeSearchResults(source, mediaType, pages)
if len(results) == 0 {
fmt.Println("no results")
return 0
}
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)
return 1
}
fmt.Printf("wrote %d results to %s\n", len(results), sopts.outputFile)
return 0
}
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 0
}
if !sopts.first {
fmt.Printf("results: %d\n", len(results))
}
if sopts.first {
selection := []int{0}
mainApp.IgnoreDB = sopts.ignoreDB || gopts.noDB
skippedDownloaded := 0
added := 0
for _, idx := range selection {
item := results[idx]
if !sopts.ignoreDB {
already, checkErr := mainApp.Store.IsDownloaded(ctx, source, item.ID)
if checkErr == nil && already {
skippedDownloaded++
fmt.Printf("skip (already downloaded): id=%s | %s\n", item.ID, item.Title)
continue
}
}
if err = mainApp.AddByID(ctx, source, mediaType, item.ID); err != nil {
fmt.Printf("add failed: id=%s err=%v\n", item.ID, err)
continue
}
added++
}
if added == 0 {
if skippedDownloaded > 0 {
fmt.Println("selected item was already downloaded (use --force to redownload)")
} else {
fmt.Println("nothing selected to download")
}
return 0
}
if err = mainApp.Resolve(ctx); err != nil {
fmt.Fprintf(os.Stderr, "resolve error: %s\n", errorWithActionableHint(err, gopts))
return 1
}
if err = mainApp.Rip(ctx); err != nil {
fmt.Fprintf(os.Stderr, "rip error: %s\n", errorWithActionableHint(err, gopts))
return 1
}
fmt.Printf("search download complete (%d item(s))\n", added)
return 0
}
if !term.IsTerminal(int(os.Stdin.Fd())) {
fmt.Println("non-interactive input; use `rip id` to download specific results")
return 0
}
selection, err := promptSearchSelectionMenu(source, mediaType, sopts.query, results)
if err != nil {
fmt.Fprintf(os.Stderr, "selection error: %v\n", err)
return 2
}
if len(selection) == 0 {
fmt.Println("download cancelled")
return 0
}
mainApp.IgnoreDB = sopts.ignoreDB || gopts.noDB
skippedDownloaded := 0
added := 0
for _, idx := range selection {
item := results[idx]
if !sopts.ignoreDB {
already, checkErr := mainApp.Store.IsDownloaded(ctx, source, item.ID)
if checkErr == nil && already {
skippedDownloaded++
fmt.Printf("skip (already downloaded): id=%s | %s\n", item.ID, item.Title)
continue
}
}
if err = mainApp.AddByID(ctx, source, mediaType, item.ID); err != nil {
fmt.Printf("add failed: id=%s err=%v\n", item.ID, err)
continue
}
added++
}
if added == 0 {
if skippedDownloaded > 0 {
fmt.Println("all selected items were already downloaded (use --force to redownload)")
} else {
fmt.Println("nothing selected to download")
}
return 0
}
if err = mainApp.Resolve(ctx); err != nil {
fmt.Fprintf(os.Stderr, "resolve error: %s\n", errorWithActionableHint(err, gopts))
return 1
}
if err = mainApp.Rip(ctx); err != nil {
fmt.Fprintf(os.Stderr, "rip error: %s\n", errorWithActionableHint(err, gopts))
return 1
}
fmt.Printf("search download complete (%d item(s))\n", added)
return 0
}
func cmdLastFM(ctx context.Context, cfg *config.Config, gopts globalOptions, args []string) int {
opts, parseErr := parseLastFMArgs(args, cfg.Session.LastFM.Source, cfg.Session.LastFM.FallbackSource)
if parseErr != nil {
fmt.Fprintf(os.Stderr, "lastfm option error: %v\n", parseErr)
printCommandHelp(os.Stdout, "lastfm")
return 2
}
mainApp, err := app.New(cfg)
if err != nil {
fmt.Fprintf(os.Stderr, "app init error: %v\n", err)
return 1
}
defer func() { _ = mainApp.Close() }()
mainApp.IgnoreDB = gopts.noDB
title, tracks, err := fetchLastFMPlaylist(ctx, cfg.Session.Downloads.VerifySSL, opts.PlaylistURL)
if err != nil {
fmt.Fprintf(os.Stderr, "lastfm parse error: %v\n", err)
return 1
}
if len(tracks) == 0 {
fmt.Println("no tracks found in playlist")
return 0
}
fmt.Printf("lastfm playlist: %s (%d tracks)\n", title, len(tracks))
resolvedTracks, err := resolveLastFMTracks(ctx, mainApp, opts, tracks)
if err != nil {
fmt.Fprintf(os.Stderr, "lastfm resolve error: %v\n", err)
return 1
}
if len(resolvedTracks) == 0 {
fmt.Println("no lastfm tracks resolved")
return 0
}
playlistID := fmt.Sprintf("lastfm:%s", strings.ToLower(strings.ReplaceAll(title, " ", "_")))
refs := make([]app.PlaylistTrackRef, 0, len(resolvedTracks))
for _, item := range resolvedTracks {
refs = append(refs, app.PlaylistTrackRef{Source: item.Source, ID: item.ID})
}
if addErr := mainApp.AddMixedPlaylistByTrackRefs(ctx, playlistID, title, refs); addErr != nil {
fmt.Printf("playlist queue failed: err=%v\n", addErr)
fmt.Println("no lastfm playlists queued")
return 0
}
fmt.Printf("queued lastfm playlist: %s (%d tracks)\n", title, len(refs))
if len(refs) == 0 {
fmt.Println("no lastfm playlists queued")
return 0
}
if err = mainApp.Resolve(ctx); err != nil {
fmt.Fprintf(os.Stderr, "resolve error: %v\n", err)
return 1
}
if err = mainApp.Rip(ctx); err != nil {
fmt.Fprintf(os.Stderr, "rip error: %v\n", err)
return 1
}
fmt.Printf("lastfm rip complete (%d track(s) across 1 playlist)\n", len(resolvedTracks))
return 0
}