1124 lines
31 KiB
Go
1124 lines
31 KiB
Go
//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
|
||
}
|