Files
goget/cmd/goget/commands.go
T

1124 lines
31 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//go:build linux || freebsd
// +build linux freebsd
package main
import (
"context"
"encoding/json"
"fmt"
"io"
stdhttp "net/http"
"net/url"
"os"
"os/signal"
"path/filepath"
"strconv"
"strings"
"syscall"
"time"
"codeberg.org/petrbalvin/goget/internal/cli"
"codeberg.org/petrbalvin/goget/internal/config"
"codeberg.org/petrbalvin/goget/internal/core"
format "codeberg.org/petrbalvin/goget/internal/format"
"codeberg.org/petrbalvin/goget/internal/metalink"
"codeberg.org/petrbalvin/goget/internal/output"
"codeberg.org/petrbalvin/goget/internal/protocol"
httpConfig "codeberg.org/petrbalvin/goget/internal/protocol/http"
"codeberg.org/petrbalvin/goget/internal/queue"
"codeberg.org/petrbalvin/goget/internal/transport"
"codeberg.org/petrbalvin/interpres"
)
func extractConfigPath(args []string) string {
for i := 0; i < len(args); i++ {
arg := args[i]
if arg == "--config" && i+1 < len(args) {
return args[i+1]
}
if strings.HasPrefix(arg, "--config=") {
return strings.TrimPrefix(arg, "--config=")
}
}
return ""
}
// parseRateLimit parses rate limit string like "100KB/s" or "1MB/s" to bytes per second.
// It returns 0 with no error for the empty string and literal "0" (both mean
// "unlimited" in the rest of the codebase). Any other unparsable input
// returns a non-nil error so the caller can warn the user instead of
// silently disabling rate limiting — a typo like "1oMB/s" (letter 'o'
// instead of digit '0') must not be mistaken for "no limit".
func parseRateLimit(s string) (int64, error) {
s = strings.TrimSpace(s)
if s == "" || s == "0" {
return 0, nil
}
// Remove /s suffix if present
s = strings.TrimSuffix(s, "/s")
s = strings.ToUpper(s)
// Parse number and unit
var multiplier int64 = 1
numStr := s
if strings.HasSuffix(s, "PB") {
multiplier = 1000 * 1000 * 1000 * 1000 * 1000
numStr = strings.TrimSuffix(s, "PB")
} else if strings.HasSuffix(s, "TB") {
multiplier = 1000 * 1000 * 1000 * 1000
numStr = strings.TrimSuffix(s, "TB")
} else if strings.HasSuffix(s, "GB") {
multiplier = 1000 * 1000 * 1000
numStr = strings.TrimSuffix(s, "GB")
} else if strings.HasSuffix(s, "MB") {
multiplier = 1000 * 1000
numStr = strings.TrimSuffix(s, "MB")
} else if strings.HasSuffix(s, "KB") {
multiplier = 1000
numStr = strings.TrimSuffix(s, "KB")
} else if strings.HasSuffix(s, "B") {
numStr = strings.TrimSuffix(s, "B")
}
num, err := strconv.ParseFloat(strings.TrimSpace(numStr), 64)
if err != nil {
return 0, fmt.Errorf("invalid rate limit %q: %w", s, err)
}
return int64(num * float64(multiplier)), nil
}
// extractURLs extracts all --url values from args
func extractURLs(args []string) []string {
var urls []string
for i := 0; i < len(args); i++ {
if args[i] == "--url" && i+1 < len(args) {
urls = append(urls, args[i+1])
i++
} else if strings.HasPrefix(args[i], "--url=") {
urls = append(urls, strings.TrimPrefix(args[i], "--url="))
}
}
return urls
}
// downloadSingleURL parses and downloads a single URL, returning the result or an error.
// It creates the HTTP client, validates the URL, handles output, and performs the download.
func downloadSingleURL(rawURL string, output string, cfg *config.Config, verbose bool, ctx context.Context) (*core.DownloadResult, error) {
parsedURL, err := url.Parse(rawURL)
if err != nil {
return nil, fmt.Errorf("invalid url: %w", err)
}
// Look up the protocol
proto, err := protocol.Get(parsedURL)
if err != nil {
return nil, err
}
// Build the download request (use defaults when cfg is nil)
httpCfg := httpConfig.DefaultConfig()
timeout := 30 * time.Minute
userAgent := core.Name + "/" + core.Version
ipv4Fallback := true
autoResume := true
if cfg != nil {
timeout = cfg.Timeout
userAgent = cfg.UserAgent
ipv4Fallback = cfg.IPv4Fallback
autoResume = cfg.AutoResume
}
httpCfg.Transport.Timeout = timeout
httpCfg.UserAgent = userAgent
httpCfg.Output.Verbose = verbose
httpCfg.Transport.Dialer.IPv4Fallback = ipv4Fallback
req := &core.DownloadRequest{
URL: parsedURL,
Output: output,
Resume: autoResume,
Timeout: timeout,
Verbose: verbose,
Headers: map[string]string{
"User-Agent": userAgent,
},
Ctx: ctx,
}
return proto.Download(ctx, req)
}
// handleMultipleURLs processes multiple URLs sequentially
func handleMultipleURLs(urls []string, outputDir string, cfg *config.Config, ctx context.Context) int {
var successCount, failCount int
var totalBytes int64
startTime := time.Now()
for i, urlStr := range urls {
if cfg.Verbose {
fmt.Fprintf(os.Stderr, "\n[%d/%d] %s\n", i+1, len(urls), core.SafeURL(mustParseURL(urlStr)))
}
// Determine output path from URL
outputPath := outputDir
if outputPath == "" || outputPath == "." {
outputPath = urlToFilename(urlStr, i+1)
}
result, err := downloadSingleURL(urlStr, outputPath, cfg, cfg.Verbose, ctx)
if err != nil {
cli.PrintWarning(os.Stderr, fmt.Sprintf("Failed: %s - %v",
core.SafeURL(mustParseURL(urlStr)), err))
failCount++
continue
}
totalBytes += result.BytesDownloaded
successCount++
if cfg.Verbose {
cli.PrintSuccess(os.Stderr, fmt.Sprintf("Downloaded: %s (%s)",
filepath.Base(outputPath), format.Bytes(result.BytesDownloaded)))
}
}
duration := time.Since(startTime)
if cfg.Verbose {
fmt.Fprintf(os.Stderr, "\nAll downloads complete: %d succeeded, %d failed, %s total in %v\n",
successCount, failCount, format.Bytes(totalBytes), duration.Round(time.Second))
}
if failCount > 0 {
return 1
}
return 0
}
// urlToFilename extracts a safe filename from a URL string, falling back to a
// generated name. It is used for auto-generating output filenames.
func urlToFilename(rawURL string, index int) string {
parsed, err := url.Parse(rawURL)
if err != nil {
return fmt.Sprintf("download_%d", index)
}
name := filepath.Base(parsed.Path)
if name == "" || name == "/" || name == "." {
return fmt.Sprintf("download_%d", index)
}
return name
}
// mustParseURL parses a URL and returns nil on failure safe for use with
// core.SafeURL which handles nil gracefully.
func mustParseURL(raw string) *url.URL {
u, _ := url.Parse(raw)
return u
}
func handleQueueList(queueFile string) int {
q, err := queue.NewQueue(&queue.QueueConfig{QueueFile: queueFile})
if err != nil {
cli.PrintError(os.Stderr, fmt.Errorf("failed to load queue: %w", err))
return 1
}
stats := q.GetStats()
fmt.Printf("Queue Statistics:\n")
fmt.Printf(" Total: %d | Pending: %d | Downloading: %d | Completed: %d | Failed: %d\n\n",
stats.Total, stats.Pending, stats.Downloading, stats.Completed, stats.Failed)
items := q.GetAll()
if len(items) == 0 {
fmt.Println("Queue is empty.")
return 0
}
fmt.Printf("%-12s %-8s %-10s %-10s %-10s %s\n", "ID", "Priority", "Status", "Downloaded", "Size", "URL")
fmt.Println(strings.Repeat("-", 80))
for _, item := range items {
statusIcon := "○"
switch item.Status {
case "completed":
statusIcon = "✓"
case "failed":
statusIcon = "✗"
case "downloading":
statusIcon = "⟳"
}
fmt.Printf("%-12s %-8d %-10s %-10s %-10s %s\n",
statusIcon+" "+item.ID,
item.Priority,
item.Status,
format.Bytes(item.Downloaded),
format.Bytes(item.Size),
item.URL)
}
return 0
}
// handleQueueProcess processes the download queue
func handleQueueProcess(queueFile string, verbose bool) int {
q, err := queue.NewQueue(&queue.QueueConfig{QueueFile: queueFile})
if err != nil {
cli.PrintError(os.Stderr, fmt.Errorf("failed to load queue: %w", err))
return 1
}
if q.Len() == 0 {
if verbose {
cli.PrintInfo(os.Stderr, "Queue is empty")
}
return 0
}
stats := q.GetStats()
if verbose {
fmt.Printf("Processing queue with %d items (%d pending, %d failed)\n",
stats.Total, stats.Pending, stats.Failed)
}
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
sigChan := make(chan os.Signal, 1)
signal.Notify(sigChan, syscall.SIGINT, syscall.SIGTERM)
go func() {
<-sigChan
cancel()
}()
var totalDownloaded int64
var successCount, failCount int
for {
item := q.GetNext()
if item == nil {
break
}
if verbose {
fmt.Printf("\n[%d/%d] Downloading: %s\n",
successCount+failCount+1, stats.Pending, item.URL)
}
q.UpdateStatus(item.ID, "downloading")
result, err := downloadSingleURL(item.URL, item.Output, nil, verbose, ctx)
if err != nil {
q.UpdateStatus(item.ID, "failed")
q.UpdateProgress(item.ID, 0, 0)
cli.PrintWarning(os.Stderr, fmt.Sprintf("Failed: %s - %v", item.URL, err))
failCount++
continue
}
q.UpdateStatus(item.ID, "completed")
q.UpdateProgress(item.ID, result.BytesDownloaded, result.TotalSize)
totalDownloaded += result.BytesDownloaded
successCount++
if verbose {
cli.PrintSuccess(os.Stderr, fmt.Sprintf("Downloaded: %s (%s)",
item.URL, format.Bytes(result.BytesDownloaded)))
}
}
if verbose {
fmt.Printf("\nQueue processing complete: %d succeeded, %d failed, %s total\n",
successCount, failCount, format.Bytes(totalDownloaded))
}
return 0
}
// handleQueueClear clears completed items from the queue
func handleQueueClear(queueFile string, verbose bool) int {
q, err := queue.NewQueue(&queue.QueueConfig{QueueFile: queueFile})
if err != nil {
cli.PrintError(os.Stderr, fmt.Errorf("failed to load queue: %w", err))
return 1
}
count := q.ClearCompleted()
if verbose {
fmt.Printf("Cleared %d completed items from queue.\n", count)
} else {
fmt.Printf("Cleared %d items.\n", count)
}
return 0
}
// handleMetalinkFile processes a local metalink file
func handleMetalinkFile(metalinkPath, outputDir string, verbose, addToQueue bool, priority int, queueFile string) int {
ml, err := metalink.ParseFile(metalinkPath)
if err != nil {
cli.PrintError(os.Stderr, fmt.Errorf("failed to parse metalink: %w", err))
return 1
}
if err := ml.Validate(); err != nil {
cli.PrintError(os.Stderr, fmt.Errorf("invalid metalink: %w", err))
return 1
}
if verbose {
fmt.Printf("Metalink: %d file(s), total size: %s\n",
len(ml.Files), format.Bytes(ml.GetTotalSize()))
}
if outputDir == "" {
outputDir = "."
}
if addToQueue {
// Add all files to queue
q, err := queue.NewQueue(&queue.QueueConfig{QueueFile: queueFile})
if err != nil {
cli.PrintError(os.Stderr, fmt.Errorf("failed to load queue: %w", err))
return 1
}
var items []queue.QueueItem
for _, file := range ml.Files {
items = append(items, queue.QueueItem{
URL: file.GetBestURL().URL,
Output: filepath.Join(outputDir, file.Name),
Priority: priority,
Size: file.Size,
})
}
q.AddMultiple(items)
if verbose {
fmt.Printf("Added %d items to queue.\n", len(items))
}
} else {
// Download directly using multi-source downloader
if verbose {
fmt.Println("Downloading from metalink (multi-source)...")
}
dl := metalink.NewMultiSourceDownloader(metalink.DefaultDownloaderConfig())
for _, file := range ml.Files {
outputPath := filepath.Join(outputDir, file.Name)
dlCtx, dlCancel := context.WithCancel(context.Background())
defer dlCancel()
dlSigChan := make(chan os.Signal, 1)
signal.Notify(dlSigChan, syscall.SIGINT, syscall.SIGTERM)
go func() {
<-dlSigChan
dlCancel()
}()
result, err := dl.DownloadWithFailover(dlCtx, &file, outputPath)
if err != nil {
cli.PrintError(os.Stderr, fmt.Errorf("failed to download %s: %w", file.Name, err))
continue
}
if verbose {
fmt.Printf("✓ Downloaded %s: %s in %v (%s/s)\n",
file.Name, format.Bytes(result.BytesDownloaded),
result.Duration.Round(time.Second), format.Speed(result.Speed))
}
}
}
return 0
}
// handleMetalinkURL downloads from a metalink URL
func handleMetalinkURL(metalinkURL, outputDir string, verbose, addToQueue bool, priority int, queueFile string) int {
if verbose {
fmt.Printf("Fetching metalink from: %s\n", metalinkURL)
}
// Download the raw metalink XML
resp, err := stdhttp.Get(metalinkURL)
if err != nil {
cli.PrintError(os.Stderr, fmt.Errorf("failed to download metalink: %w", err))
return 1
}
defer resp.Body.Close()
if resp.StatusCode != stdhttp.StatusOK {
cli.PrintError(os.Stderr, fmt.Errorf("metalink url returned status %s", resp.Status))
return 1
}
// Save to temp file for processing
tempFile, err := os.CreateTemp("", "goget-metalink-*.xml")
if err != nil {
cli.PrintError(os.Stderr, fmt.Errorf("failed to create temp file: %w", err))
return 1
}
tempPath := tempFile.Name()
if _, err := io.Copy(tempFile, resp.Body); err != nil {
tempFile.Close()
os.Remove(tempPath)
cli.PrintError(os.Stderr, fmt.Errorf("failed to save metalink: %w", err))
return 1
}
tempFile.Close()
defer os.Remove(tempPath)
// Process the downloaded metalink file
return handleMetalinkFile(tempPath, outputDir, verbose, addToQueue, priority, queueFile)
}
// handleUpload handles file upload via PUT or POST
func handleUpload(targetURL, method, filePath, formDataStr, fileField string, verbose bool, cfg *config.Config) int {
if filePath == "" {
cli.PrintError(os.Stderr, fmt.Errorf("--upload requires --upload-file"))
return 1
}
// Check if file exists
if _, err := os.Stat(filePath); os.IsNotExist(err) {
cli.PrintError(os.Stderr, fmt.Errorf("file not found: %s", filePath))
return 1
}
if verbose {
fmt.Fprintf(os.Stderr, "[upload] Uploading %s to %s (%s)\n", filePath, targetURL, method)
}
// Create HTTP client
httpCfg := httpConfig.DefaultConfig()
httpCfg.Output.Verbose = verbose
httpCfg.Transport.Timeout = cfg.GetEffectiveTimeout(-1, 0)
httpCfg.Auth.Username = cfg.Auth.Username
httpCfg.Auth.Password = cfg.Auth.Password
httpCfg.Auth.AuthType = cfg.Auth.AuthType
httpCfg.Auth.OAuth = &cfg.Auth.OAuth
if cfg.MaxSpeed > 0 {
httpCfg.RateLimiter = transport.NewTokenBucket(cfg.MaxSpeed, cfg.MaxSpeed/10)
}
client, err := httpConfig.NewClient(httpCfg)
if err != nil {
cli.PrintError(os.Stderr, fmt.Errorf("failed to create http client: %w", err))
return 1
}
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
sigChan := make(chan os.Signal, 1)
signal.Notify(sigChan, syscall.SIGINT, syscall.SIGTERM)
go func() {
<-sigChan
cancel()
}()
// Parse form data
formData := make(map[string]string)
if formDataStr != "" {
for _, pair := range strings.Split(formDataStr, ",") {
pair = strings.TrimSpace(pair)
if parts := strings.SplitN(pair, "=", 2); len(parts) == 2 {
formData[strings.TrimSpace(parts[0])] = strings.TrimSpace(parts[1])
}
}
}
var result *httpConfig.UploadResult
if len(formData) > 0 || fileField != "" {
// Multipart POST upload
files := []httpConfig.UploadFile{
{
FieldName: fileField,
FilePath: filePath,
},
}
result, err = client.UploadMultipart(ctx, targetURL, files, formData, verbose)
} else {
// Simple PUT or POST upload
result, err = client.UploadSimple(ctx, method, targetURL, filePath, verbose)
}
if err != nil {
cli.PrintError(os.Stderr, fmt.Errorf("upload failed: %w", err))
return 1
}
if verbose {
fmt.Fprintf(os.Stderr, "\n")
fmt.Fprintf(os.Stderr, "[upload] Upload complete\n")
fmt.Fprintf(os.Stderr, " Status: %d\n", result.StatusCode)
fmt.Fprintf(os.Stderr, " Uploaded: %s in %v (%s/s)\n",
format.Bytes(result.BytesUploaded),
result.Duration.Round(time.Second),
format.Speed(result.Speed))
if result.ContentLength > 0 {
fmt.Fprintf(os.Stderr, " Response: %s\n", format.Bytes(result.ContentLength))
}
if result.ResponseBody != "" {
fmt.Fprintf(os.Stderr, " Response body: %s\n", result.ResponseBody)
}
} else {
fmt.Printf("Upload complete: %d %s\n", result.StatusCode, format.Bytes(result.BytesUploaded))
}
return 0
}
// handleBatchFile downloads multiple URLs from a file
func handleBatchFile(batchPath, outputDir string, cfg *config.Config, ctx context.Context) int {
data, err := os.ReadFile(batchPath)
if err != nil {
cli.PrintError(os.Stderr, fmt.Errorf("failed to read batch file: %w", err))
return 1
}
urls := strings.Split(strings.TrimSpace(string(data)), "\n")
var validURLs []string
for _, line := range urls {
line = strings.TrimSpace(line)
if line == "" || strings.HasPrefix(line, "#") || strings.HasPrefix(line, "//") {
continue
}
validURLs = append(validURLs, line)
}
if len(validURLs) == 0 {
cli.PrintError(os.Stderr, fmt.Errorf("no valid URLs found in batch file"))
return 1
}
if cfg.Verbose {
cli.PrintInfo(os.Stderr, fmt.Sprintf("Batch downloading %d URL(s) from %s",
len(validURLs), batchPath))
}
var successCount, failCount int
var totalBytes int64
startTime := time.Now()
for i, urlStr := range validURLs {
if cfg.Verbose {
fmt.Fprintf(os.Stderr, "\n[%d/%d] %s\n", i+1, len(validURLs),
core.SafeURL(mustParseURL(urlStr)))
}
// Auto-generate output filename from URL
outputPath := outputDir
if outputPath == "" || outputPath == "." {
outputPath = urlToFilename(urlStr, i+1)
} else if fi, err := os.Stat(outputPath); err == nil && fi.IsDir() {
outputPath = filepath.Join(outputPath, urlToFilename(urlStr, i+1))
}
result, err := downloadSingleURL(urlStr, outputPath, cfg, cfg.Verbose, ctx)
if err != nil {
cli.PrintWarning(os.Stderr, fmt.Sprintf("Failed: %s - %v",
core.SafeURL(mustParseURL(urlStr)), err))
failCount++
continue
}
totalBytes += result.BytesDownloaded
successCount++
if cfg.Verbose {
cli.PrintSuccess(os.Stderr, fmt.Sprintf("Downloaded: %s (%s)",
filepath.Base(outputPath), format.Bytes(result.BytesDownloaded)))
}
}
duration := time.Since(startTime)
if cfg.Verbose {
fmt.Fprintf(os.Stderr, "\nBatch complete: %d succeeded, %d failed, %s total in %v\n",
successCount, failCount, format.Bytes(totalBytes), duration.Round(time.Second))
}
if failCount > 0 {
return 1
}
return 0
}
// handleCompletion generates shell completion script
func handleCompletion(shell string) int {
var script string
switch strings.ToLower(shell) {
case "bash":
script = generateBashCompletion()
case "zsh":
script = generateZshCompletion()
case "fish":
script = generateFishCompletion()
default:
cli.PrintError(os.Stderr, fmt.Errorf("unsupported shell: %s (supported: bash, zsh, fish)", shell))
return 1
}
fmt.Print(script)
return 0
}
// handleInfo shows file information from HEAD request
func handleInfo(urlStr string, cfg *config.Config) int {
parsedURL, err := url.Parse(urlStr)
if err != nil {
cli.PrintError(os.Stderr, fmt.Errorf("invalid url: %w", err))
return 1
}
client := &stdhttp.Client{
Timeout: 30 * time.Second,
}
req, err := stdhttp.NewRequest("HEAD", urlStr, nil)
if err != nil {
cli.PrintError(os.Stderr, fmt.Errorf("failed to create request: %w", err))
return 1
}
req.Header.Set("User-Agent", cfg.UserAgent)
resp, err := client.Do(req)
if err != nil {
cli.PrintError(os.Stderr, fmt.Errorf("head request failed: %w", err))
return 1
}
resp.Body.Close()
fmt.Printf("URL: %s\n", parsedURL.String())
fmt.Printf("Status: %s\n", resp.Status)
fmt.Printf("Content-Type: %s\n", resp.Header.Get("Content-Type"))
fmt.Printf("Content-Length: %s (%d bytes)\n", format.Bytes(resp.ContentLength), resp.ContentLength)
fmt.Printf("Last-Modified: %s\n", resp.Header.Get("Last-Modified"))
fmt.Printf("ETag: %s\n", resp.Header.Get("ETag"))
fmt.Printf("Accept-Ranges: %s\n", resp.Header.Get("Accept-Ranges"))
fmt.Printf("Server: %s\n", resp.Header.Get("Server"))
// Check if supports compression
if resp.Header.Get("Content-Encoding") != "" {
fmt.Printf("Content-Encoding: %s\n", resp.Header.Get("Content-Encoding"))
}
return 0
}
// handleBenchmark benchmarks download speed from a URL
func handleBenchmark(urlStr string, cfg *config.Config) int {
fmt.Printf("Benchmarking: %s\n\n", urlStr)
_, err := url.Parse(urlStr)
if err != nil {
cli.PrintError(os.Stderr, fmt.Errorf("invalid url: %w", err))
return 1
}
client := &stdhttp.Client{
Timeout: 30 * time.Second,
}
// First, send a HEAD request to get file info
headReq, err := stdhttp.NewRequest("HEAD", urlStr, nil)
if err != nil {
cli.PrintError(os.Stderr, fmt.Errorf("failed to create head request: %w", err))
return 1
}
headReq.Header.Set("User-Agent", cfg.UserAgent)
headResp, err := client.Do(headReq)
if err != nil {
cli.PrintError(os.Stderr, fmt.Errorf("head request failed: %w", err))
return 1
}
headResp.Body.Close()
totalSize := headResp.ContentLength
fmt.Printf("File size: %s\n", format.Bytes(totalSize))
fmt.Printf("Server: %s\n", headResp.Header.Get("Server"))
fmt.Printf("Content-Type: %s\n", headResp.Header.Get("Content-Type"))
// Download a portion to benchmark (first 10MB or whole file if smaller)
downloadSize := int64(10 * 1024 * 1024) // 10MB
if totalSize > 0 && totalSize < downloadSize {
downloadSize = totalSize
}
req, err := stdhttp.NewRequest("GET", urlStr, nil)
if err != nil {
cli.PrintError(os.Stderr, fmt.Errorf("failed to create get request: %w", err))
return 1
}
req.Header.Set("User-Agent", cfg.UserAgent)
req.Header.Set("Range", fmt.Sprintf("bytes=0-%d", downloadSize-1))
start := time.Now()
resp, err := client.Do(req)
if err != nil {
cli.PrintError(os.Stderr, fmt.Errorf("get request failed: %w", err))
return 1
}
defer resp.Body.Close()
// Read the benchmark data
buf := make([]byte, 32*1024)
var totalRead int64
for {
n, err := resp.Body.Read(buf)
totalRead += int64(n)
if err != nil {
break
}
// Only read up to our download limit
if totalRead >= downloadSize {
break
}
}
duration := time.Since(start)
speed := float64(totalRead) / duration.Seconds()
fmt.Printf("\n--- Benchmark Results ---\n")
fmt.Printf("Downloaded: %s in %v\n", format.Bytes(totalRead), duration.Round(time.Millisecond))
fmt.Printf("Speed: %s/s\n", format.Speed(speed))
// Show in different units
bitsPerSec := speed * 8
fmt.Printf("Bandwidth: %.2f Mbps\n", bitsPerSec/1000000)
// Estimate total download time
if totalSize > 0 && speed > 0 {
estTime := time.Duration(float64(totalSize)/speed) * time.Second
fmt.Printf("ETA (full): %v\n", estTime.Round(time.Second))
}
return 0
}
// handleWriteOut prints metadata after download in curl-compatible format.
func handleWriteOut(format string, result *core.DownloadResult, size int64, duration time.Duration, httpCode int) {
replacer := strings.NewReplacer(
"%{http_code}", fmt.Sprintf("%d", httpCode),
"%{size_download}", fmt.Sprintf("%d", result.BytesDownloaded),
"%{size_total}", fmt.Sprintf("%d", result.TotalSize),
"%{speed_download}", fmt.Sprintf("%d", int64(result.Speed)),
"%{time_total}", fmt.Sprintf("%.3f", duration.Seconds()),
"%{url}", result.OutputPath,
"%{protocol}", result.Protocol,
"%{num_connects}", fmt.Sprintf("%d", result.ChunksCount),
)
fmt.Println(replacer.Replace(format))
}
// handleSpider checks URL existence via HEAD request without downloading.
func handleSpider(urlStr string, cfg *config.Config) int {
parsedURL, err := url.Parse(urlStr)
if err != nil {
cli.PrintError(os.Stderr, fmt.Errorf("invalid url: %w", err))
return 1
}
client := &stdhttp.Client{Timeout: 30 * time.Second}
req, err := stdhttp.NewRequest("HEAD", urlStr, nil)
if err != nil {
cli.PrintError(os.Stderr, fmt.Errorf("failed to create request: %w", err))
return 1
}
req.Header.Set("User-Agent", cfg.UserAgent)
resp, err := client.Do(req)
if err != nil {
fmt.Printf("%s: error (%v)\n", parsedURL.String(), err)
return 1
}
resp.Body.Close()
fmt.Printf("%s: HTTP %d %s\n", parsedURL.String(), resp.StatusCode, resp.Status)
if resp.StatusCode >= 400 {
return 1
}
return 0
}
// handleShowMetadata prints the contents of the .goget.meta sidecar for
// debugging interrupted / resumed downloads. The argument accepts either the
// path to the downloaded file (the .goget.meta sidecar is located next to it)
// or a direct path to the sidecar file itself (anything ending in the
// .goget.meta suffix). When --json is set the metadata is printed as raw
// JSON, otherwise a human-readable summary is emitted on stdout and the
// corresponding JSON is reproduced verbatim from the on-disk file so the user
// sees exactly what the resume logic would consume.
func handleShowMetadata(pathArg string, asJSON bool) int {
if pathArg == "" {
cli.PrintError(os.Stderr, fmt.Errorf("--show-metadata requires a path (output file or .goget.meta sidecar)"))
return 1
}
var metaPath string
if strings.HasSuffix(pathArg, output.ResumeMetaFileSuffix) {
metaPath = pathArg
} else {
metaPath = pathArg + output.ResumeMetaFileSuffix
}
data, err := os.ReadFile(metaPath)
if err != nil {
if os.IsNotExist(err) {
cli.PrintError(os.Stderr, fmt.Errorf("no resume metadata found at %s", metaPath))
return 1
}
cli.PrintError(os.Stderr, fmt.Errorf("failed to read metadata: %w", err))
return 1
}
if asJSON {
fmt.Println(string(data))
return 0
}
// Parse for the human-readable summary. If parsing fails we still print
// the raw bytes so the user sees what is on disk — better than silent
// success followed by a confusing --resume later.
var rm output.ResumeMetadata
parseErr := json.Unmarshal(data, &rm)
if parseErr != nil {
cli.PrintWarning(os.Stderr, fmt.Sprintf("metadata file is not valid JSON, printing raw contents from %s", metaPath))
fmt.Println(string(data))
return 0
}
fmt.Printf("Path: %s\n", metaPath)
fmt.Printf("URL: %s\n", safeMetadataURL(rm.URL))
fmt.Printf("Downloaded: %s (%d bytes)\n", format.Bytes(rm.Downloaded), rm.Downloaded)
if rm.Total > 0 {
fmt.Printf("Total: %s (%d bytes)\n", format.Bytes(rm.Total), rm.Total)
fmt.Printf("Progress: %.1f%%\n", percent(rm.Downloaded, rm.Total))
} else {
fmt.Printf("Total: unknown\n")
}
if rm.ETag != "" {
fmt.Printf("ETag: %s\n", rm.ETag)
}
if rm.LastModified != "" {
fmt.Printf("Last-Modified: %s\n", rm.LastModified)
}
if !rm.LastWrite.IsZero() {
fmt.Printf("Last-Write: %s\n", rm.LastWrite.Format(time.RFC3339))
}
fmt.Printf("Version: %s\n", rm.Version)
if len(rm.Chunks) > 0 {
fmt.Printf("Chunks: %d parallel ranges with progress\n", len(rm.Chunks))
}
return 0
}
// safeMetadataURL masks any credentials embedded in the metadata URL so the
// displayed value can be pasted into chats or issue trackers without leaking
// tokens. The on-disk metadata is still printed verbatim when --json is set
// — the file is owned by the user and chmod 0600, so the operator already
// consented to its contents on the filesystem.
func safeMetadataURL(raw string) string {
parsed, err := url.Parse(raw)
if err != nil || parsed.User == nil {
return raw
}
if _, hasPass := parsed.User.Password(); hasPass {
parsed.User = url.UserPassword(parsed.User.Username(), "xxxxx")
} else {
parsed.User = url.User(parsed.User.Username())
}
return parsed.String()
}
// percent returns value/total*100 rounded to one decimal place. When total
// is zero or negative the result is 0 — callers gate the printed percentage
// on a non-empty total themselves.
func percent(value, total int64) float64 {
if total <= 0 {
return 0
}
return float64(value) * 100 / float64(total)
}
// handleGenManPage generates a man page for goget
// handleInitConfig generates a default config file
func handleInitConfig(configPath string) int {
cfg, err := config.Load(configPath)
if err != nil {
cli.PrintError(os.Stderr, fmt.Errorf("failed to load config: %w", err))
return 1
}
if err := cfg.Save(); err != nil {
cli.PrintError(os.Stderr, fmt.Errorf("failed to save config: %w", err))
return 1
}
fmt.Printf("Config saved to %s\n", cfg.Path())
return 0
}
// handleConfigList prints all config values
func handleConfigList(configPath string) int {
if configPath == "" {
fmt.Println("No config file loaded")
return 0
}
cfg, err := config.Load(configPath)
if err != nil {
cli.PrintError(os.Stderr, fmt.Errorf("failed to load config: %w", err))
return 1
}
data, err := interpres.Marshal(*cfg)
if err != nil {
cli.PrintError(os.Stderr, fmt.Errorf("failed to marshal config: %w", err))
return 1
}
fmt.Print(string(data))
return 0
}
// handleConfigGet prints a single config value
func handleConfigGet(configPath, key string) int {
if configPath == "" {
fmt.Println("No config file loaded")
return 0
}
cfg, err := config.Load(configPath)
if err != nil {
cli.PrintError(os.Stderr, fmt.Errorf("failed to load config: %w", err))
return 1
}
// Use reflection to get the value
cfgMap := make(map[string]interface{})
data, _ := json.Marshal(cfg)
json.Unmarshal(data, &cfgMap)
value, exists := cfgMap[key]
if !exists {
cli.PrintError(os.Stderr, fmt.Errorf("unknown config key: %s", key))
return 1
}
fmt.Printf("%s = %v\n", key, value)
return 0
}
// handleConfigSet sets a config value and saves
func handleConfigSet(configPath, key string, args []string) int {
if configPath == "" {
cli.PrintError(os.Stderr, fmt.Errorf("no config file loaded, use --init-config first"))
return 1
}
// Find the value from args (--config-set key value)
var value string
for i := 0; i < len(args); i++ {
if args[i] == "--config-set" && i+2 < len(args) {
value = args[i+2]
break
}
}
if value == "" {
cli.PrintError(os.Stderr, fmt.Errorf("--config-set requires a value: --config-set key value"))
return 1
}
cfg, err := config.Load(configPath)
if err != nil {
cli.PrintError(os.Stderr, fmt.Errorf("failed to load config: %w", err))
return 1
}
// Parse value to the right type
cfgMap := make(map[string]interface{})
data, _ := json.Marshal(cfg)
json.Unmarshal(data, &cfgMap)
// Try to parse as number first
if numVal, err := strconv.ParseInt(value, 10, 64); err == nil {
cfgMap[key] = numVal
} else if floatVal, err := strconv.ParseFloat(value, 64); err == nil {
cfgMap[key] = floatVal
} else if boolVal, err := strconv.ParseBool(value); err == nil {
cfgMap[key] = boolVal
} else {
cfgMap[key] = value
}
// Marshal the modified map back to a Config struct, then save as TOML.
jsonData, _ := json.Marshal(cfgMap)
var updatedCfg config.Config
if err := json.Unmarshal(jsonData, &updatedCfg); err != nil {
cli.PrintError(os.Stderr, fmt.Errorf("failed to parse updated config: %w", err))
return 1
}
tomlData, err := interpres.Marshal(updatedCfg)
if err != nil {
cli.PrintError(os.Stderr, fmt.Errorf("failed to marshal config: %w", err))
return 1
}
if err := os.WriteFile(configPath, tomlData, 0600); err != nil {
cli.PrintError(os.Stderr, fmt.Errorf("failed to save config: %w", err))
return 1
}
fmt.Printf("Set %s = %v in %s\n", key, cfgMap[key], configPath)
return 0
}
// handleConfigUnset removes a config key
func handleConfigUnset(configPath, key string) int {
if configPath == "" {
cli.PrintError(os.Stderr, fmt.Errorf("no config file loaded"))
return 1
}
cfg, err := config.Load(configPath)
if err != nil {
cli.PrintError(os.Stderr, fmt.Errorf("failed to load config: %w", err))
return 1
}
cfgMap := make(map[string]interface{})
data, _ := json.Marshal(cfg)
json.Unmarshal(data, &cfgMap)
delete(cfgMap, key)
// Marshal the modified map back to a Config struct, then save as TOML.
jsonData, _ := json.Marshal(cfgMap)
var updatedCfg config.Config
if err := json.Unmarshal(jsonData, &updatedCfg); err != nil {
cli.PrintError(os.Stderr, fmt.Errorf("failed to parse updated config: %w", err))
return 1
}
tomlData, err := interpres.Marshal(updatedCfg)
if err != nil {
cli.PrintError(os.Stderr, fmt.Errorf("failed to marshal config: %w", err))
return 1
}
if err := os.WriteFile(configPath, tomlData, 0600); err != nil {
cli.PrintError(os.Stderr, fmt.Errorf("failed to save config: %w", err))
return 1
}
fmt.Printf("Unset %s in %s\n", key, configPath)
return 0
}