diff --git a/cmd/rip/args.go b/cmd/rip/args.go index 8fbf60d..c571380 100644 --- a/cmd/rip/args.go +++ b/cmd/rip/args.go @@ -24,7 +24,7 @@ type globalOptions struct { codec string noProgress bool noSSLVerify bool - verbose bool + verbose int command string commandArgs []string } @@ -52,7 +52,13 @@ func parseGlobalArgs(args []string) (globalOptions, error) { case arg == "--no-ssl-verify": opts.noSSLVerify = true case arg == "-v" || arg == "--verbose": - opts.verbose = true + 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) diff --git a/cmd/rip/main.go b/cmd/rip/main.go index 9d64184..f206754 100644 --- a/cmd/rip/main.go +++ b/cmd/rip/main.go @@ -14,6 +14,7 @@ import ( "streamrip-go/internal/app" "streamrip-go/internal/config" "streamrip-go/internal/provider" + "streamrip-go/internal/verbose" _ "modernc.org/sqlite" ) @@ -49,8 +50,11 @@ func main() { os.Exit(1) } applyGlobalConfigOverrides(cfg, gopts) - if gopts.verbose { - fmt.Fprintln(os.Stderr, "verbose mode enabled") + verbose.SetLevel(gopts.verbose) + if gopts.verbose >= 2 { + fmt.Fprintln(os.Stderr, "verbose mode enabled (level 2: downloads + http)") + } else if gopts.verbose >= 1 { + fmt.Fprintln(os.Stderr, "verbose mode enabled (level 1: downloads)") } os.Args = append([]string{os.Args[0], gopts.command}, gopts.commandArgs...) diff --git a/cmd/rip/main_test.go b/cmd/rip/main_test.go index d19335d..004dedf 100644 --- a/cmd/rip/main_test.go +++ b/cmd/rip/main_test.go @@ -227,7 +227,7 @@ func TestParseGlobalArgsAllOfficialFlags(t *testing.T) { if !opts.noDB || !opts.qualitySet || opts.quality != 3 || !opts.codecSet || opts.codec != "VORBIS" { t.Fatalf("unexpected quality/codec/db opts: %+v", opts) } - if !opts.noProgress || !opts.noSSLVerify || !opts.verbose { + if !opts.noProgress || !opts.noSSLVerify || opts.verbose != 1 { t.Fatalf("unexpected boolean opts: %+v", opts) } if opts.command != "search" { diff --git a/internal/app/app.go b/internal/app/app.go index 7bcc419..c43614e 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -26,6 +26,7 @@ import ( soundcloudprovider "streamrip-go/internal/provider/soundcloud" tidalprovider "streamrip-go/internal/provider/tidal" "streamrip-go/internal/store" + "streamrip-go/internal/verbose" ) type Main struct { @@ -114,7 +115,7 @@ func New(cfg *config.Config) (*Main, error) { "soundcloud": soundcloudprovider.New(cfg), } - return &Main{ + m := &Main{ Config: cfg, Providers: providers, Store: db, @@ -122,7 +123,9 @@ func New(cfg *config.Config) (*Main, error) { Tagger: tag.New(), Pending: []media.Pending{}, Media: []media.Media{}, - }, nil + } + verbose.SetSink(func(msg string) { m.DL.Logf("%s", msg) }) + return m, nil } // downloaderMaxConnsPerHost picks the per-host idle connection cap for the @@ -137,6 +140,7 @@ func downloaderMaxConnsPerHost(maxConnections int) int { } func (m *Main) Close() error { + verbose.SetSink(nil) m.DL.Close() artwork.CleanupTempDirs() for _, p := range m.Providers { diff --git a/internal/download/downloader.go b/internal/download/downloader.go index 9c5ca5b..f357ac7 100644 --- a/internal/download/downloader.go +++ b/internal/download/downloader.go @@ -20,6 +20,7 @@ import ( "golang.org/x/term" "streamrip-go/internal/netutil" + "streamrip-go/internal/verbose" "golang.org/x/crypto/blowfish" ) @@ -67,6 +68,7 @@ func (d *Downloader) FileVideo(ctx context.Context, sourceURL, outputPath string } func (d *Downloader) FileDeezerEncrypted(ctx context.Context, sourceURL, outputPath, trackID string) error { + logDownloadStart(sourceURL, outputPath) if err := os.MkdirAll(filepath.Dir(outputPath), 0o755); err != nil { return err } @@ -181,6 +183,7 @@ func (d *Downloader) FileDeezerEncrypted(ctx context.Context, sourceURL, outputP } func (d *Downloader) file(ctx context.Context, sourceURL, outputPath string, allowProgress bool, includeVideo bool) error { + logDownloadStart(sourceURL, outputPath) if err := os.MkdirAll(filepath.Dir(outputPath), 0o755); err != nil { return err } @@ -319,6 +322,16 @@ func (d *Downloader) Logf(format string, args ...any) { fmt.Print(msg) } +// logDownloadStart emits the source URL and destination filename when the +// user passed -v or higher. The transport-level logger covers the same +// requests at -vv, but this line gives a friendlier per-track summary. +func logDownloadStart(sourceURL, outputPath string) { + if !verbose.Enabled(verbose.V) { + return + } + verbose.Printf(verbose.V, "download %s -> %s\n", sourceURL, filepath.Base(outputPath)) +} + func shortenName(name string, max int) string { if max <= 0 { return name diff --git a/internal/netutil/http.go b/internal/netutil/http.go index 1e9e3fe..9f5cbfc 100644 --- a/internal/netutil/http.go +++ b/internal/netutil/http.go @@ -3,7 +3,10 @@ package netutil import ( "crypto/tls" "net/http" + "net/url" "time" + + "streamrip-go/internal/verbose" ) const defaultMaxConnsPerHost = 16 @@ -40,6 +43,63 @@ func NewHTTPClient(timeout time.Duration, verifySSL bool, maxConnsPerHost int) * return &http.Client{ Timeout: timeout, - Transport: transport, + Transport: &loggingTransport{base: transport}, } } + +// loggingTransport emits one verbose line per HTTP request when verbose +// level >= VV. The check is per-call so toggling the level at runtime +// affects subsequent requests without rebuilding clients. +type loggingTransport struct { + base http.RoundTripper +} + +func (t *loggingTransport) RoundTrip(req *http.Request) (*http.Response, error) { + if !verbose.Enabled(verbose.VV) { + return t.base.RoundTrip(req) + } + start := time.Now() + resp, err := t.base.RoundTrip(req) + elapsed := time.Since(start).Round(time.Millisecond) + target := redactURL(req.URL) + if err != nil { + verbose.Printf(verbose.VV, "http %s %s -> error %v (%s)\n", req.Method, target, err, elapsed) + return resp, err + } + verbose.Printf(verbose.VV, "http %s %s -> %d (%s)\n", req.Method, target, resp.StatusCode, elapsed) + return resp, err +} + +// redactURL hides values for query parameters that commonly carry +// credentials so -vv output is safe to paste in an issue. +func redactURL(u *url.URL) string { + if u == nil { + return "" + } + if u.RawQuery == "" { + return u.String() + } + q := u.Query() + redacted := false + for k := range q { + if isSensitiveParam(k) { + q.Set(k, "REDACTED") + redacted = true + } + } + if !redacted { + return u.String() + } + cp := *u + cp.RawQuery = q.Encode() + return cp.String() +} + +func isSensitiveParam(name string) bool { + switch name { + case "user_auth_token", "api_token", "access_token", "refresh_token", + "request_sig", "signature", "password", "secret", "token", "code", "auth", "key": + return true + } + return false +} diff --git a/internal/verbose/verbose.go b/internal/verbose/verbose.go new file mode 100644 index 0000000..6fddc27 --- /dev/null +++ b/internal/verbose/verbose.go @@ -0,0 +1,68 @@ +// Package verbose provides a process-wide verbosity level and a pluggable +// log sink so verbose output integrates with the downloader's progress bars. +// +// Level meaning: +// +// 0 (Off) - no extra output +// 1 (V) - log per-track CDN URLs from the downloader +// 2 (VV) - additionally log every outbound HTTP request via the +// netutil-wrapped transport (covers all provider API calls +// and downloads) +package verbose + +import ( + "fmt" + "os" + "sync/atomic" +) + +const ( + Off byte = 0 + V byte = 1 + VV byte = 2 +) + +var ( + level atomic.Uint32 + sink atomic.Pointer[func(string)] +) + +// SetLevel clamps and stores the verbosity level. Pass 0 to disable. +func SetLevel(l int) { + if l < 0 { + l = 0 + } + if l > int(VV) { + l = int(VV) + } + level.Store(uint32(l)) +} + +func Level() byte { return byte(level.Load()) } + +func Enabled(l byte) bool { return Level() >= l } + +// SetSink installs a writer for verbose output. Use this to route logs +// through the downloader so they don't tear progress bars. Pass nil to +// fall back to stderr. +func SetSink(fn func(string)) { + if fn == nil { + sink.Store(nil) + return + } + sink.Store(&fn) +} + +// Printf emits a line at the given level if verbosity is enabled. The +// caller is responsible for including a trailing newline. +func Printf(l byte, format string, args ...any) { + if !Enabled(l) { + return + } + msg := fmt.Sprintf(format, args...) + if p := sink.Load(); p != nil { + (*p)(msg) + return + } + _, _ = fmt.Fprint(os.Stderr, msg) +}