feat: initial goget release — modern IPv6-first download utility

This commit is contained in:
2026-06-26 21:21:58 +02:00
commit 53db81e1f7
147 changed files with 45931 additions and 0 deletions
+341
View File
@@ -0,0 +1,341 @@
//go:build linux || freebsd
// +build linux freebsd
package api
import (
"context"
"fmt"
"net/url"
"time"
"codeberg.org/petrbalvin/goget/internal/config"
"codeberg.org/petrbalvin/goget/internal/core"
"codeberg.org/petrbalvin/goget/internal/metalink"
"codeberg.org/petrbalvin/goget/internal/protocol"
"codeberg.org/petrbalvin/goget/internal/queue"
)
// Version returns the current goget version.
func Version() string {
return core.Version
}
// Client is the public API client for goget downloads and uploads.
type Client struct {
ctx context.Context
cancel context.CancelFunc
cfg *clientConfig
}
// ClientOption configures a Client.
type ClientOption func(*clientConfig)
type clientConfig struct {
timeout time.Duration
userAgent string
verbose bool
}
// WithTimeout sets the request timeout.
func WithTimeout(d time.Duration) ClientOption {
return func(c *clientConfig) {
c.timeout = d
}
}
// WithUserAgent sets the User-Agent header.
func WithUserAgent(ua string) ClientOption {
return func(c *clientConfig) {
c.userAgent = ua
}
}
// WithVerbose enables verbose output.
func WithVerbose(v bool) ClientOption {
return func(c *clientConfig) {
c.verbose = v
}
}
// NewClient creates a new goget client with the given options.
// The client registers HTTP and FTP protocol handlers automatically.
func NewClient(opts ...ClientOption) *Client {
cfg := &clientConfig{
timeout: 30 * time.Minute,
userAgent: "Goget/" + Version() + " (Library)",
verbose: false,
}
for _, opt := range opts {
opt(cfg)
}
registerDefaultProtocols()
// The client-scoped context is cancellable but has no deadline. It
// lives until Close() is called and is the parent of every per-request
// context created by requestContext(). A deadline here would cap the
// *lifetime* of the client, so a long-lived Client could only serve
// requests for cfg.timeout before every subsequent call failed with
// "context deadline exceeded". The per-request deadline is applied
// below in Download and Upload.
ctx, cancel := context.WithCancel(context.Background())
return &Client{ctx: ctx, cancel: cancel, cfg: cfg}
}
// requestContext returns a child context of the client scope with the
// configured per-request timeout applied. If the timeout is zero or
// negative, the request inherits the client scope without a deadline.
func (c *Client) requestContext() (context.Context, context.CancelFunc) {
if c.cfg.timeout > 0 {
return context.WithTimeout(c.ctx, c.cfg.timeout)
}
return context.WithCancel(c.ctx)
}
// DownloadRequest represents a download request.
type DownloadRequest struct {
URL string
Output string
Resume bool
Verbose bool
}
// DownloadResult contains the result of a download.
type DownloadResult struct {
BytesDownloaded int64
TotalSize int64
Speed float64
Duration time.Duration
OutputPath string
Protocol string
IPVersion int
Resumed bool
Parallel bool
ChunksCount int
}
// Download downloads a file from the given URL.
func (c *Client) Download(req *DownloadRequest) (*DownloadResult, error) {
if req == nil || req.URL == "" {
return nil, fmt.Errorf("url is required")
}
parsedURL, err := url.Parse(req.URL)
if err != nil {
return nil, fmt.Errorf("invalid url: %w", err)
}
proto, err := protocol.Get(parsedURL)
if err != nil {
return nil, fmt.Errorf("unsupported protocol: %w", err)
}
// Apply client defaults if request doesn't override
verbose := req.Verbose || c.cfg.verbose
headers := make(map[string]string)
if c.cfg.userAgent != "" {
headers["User-Agent"] = c.cfg.userAgent
}
coreReq := &core.DownloadRequest{
URL: parsedURL,
Output: req.Output,
Resume: req.Resume,
Verbose: verbose,
Headers: headers,
Timeout: c.cfg.timeout,
}
// Derive a per-request context from the client scope so a long-lived
// Client can serve many requests and each one gets its own deadline.
reqCtx, reqCancel := c.requestContext()
defer reqCancel()
coreReq.Ctx = reqCtx
result, err := proto.Download(reqCtx, coreReq)
if err != nil {
return nil, err
}
return &DownloadResult{
BytesDownloaded: result.BytesDownloaded,
TotalSize: result.TotalSize,
Speed: result.Speed,
Duration: result.Duration,
OutputPath: result.OutputPath,
Protocol: result.Protocol,
IPVersion: result.IPVersion,
Resumed: result.Resumed,
Parallel: result.Parallel,
ChunksCount: result.ChunksCount,
}, nil
}
// UploadRequest represents an upload request.
type UploadRequest struct {
URL string
FilePath string
Method string // PUT or POST
Verbose bool
}
// UploadResult contains the result of an upload.
type UploadResult struct {
BytesUploaded int64
Duration time.Duration
Protocol string
ResultURL string
}
// Upload uploads a file to the given URL.
func (c *Client) Upload(req *UploadRequest) (*UploadResult, error) {
if req == nil || req.URL == "" || req.FilePath == "" {
return nil, fmt.Errorf("url and file path are required")
}
parsedURL, err := url.Parse(req.URL)
if err != nil {
return nil, fmt.Errorf("invalid url: %w", err)
}
proto, err := protocol.Get(parsedURL)
if err != nil {
return nil, fmt.Errorf("unsupported protocol: %w", err)
}
uploader, ok := proto.(core.Uploader)
if !ok {
return nil, fmt.Errorf("protocol does not support upload: %s", parsedURL.Scheme)
}
coreReq := &core.UploadRequest{
URL: parsedURL,
Input: req.FilePath,
Verbose: req.Verbose,
}
// Per-request context so a long-lived Client can serve many uploads
// without each one being bounded by the time elapsed since client
// creation. See NewClient for the rationale.
reqCtx, reqCancel := c.requestContext()
defer reqCancel()
coreResult, err := uploader.Upload(reqCtx, coreReq)
if err != nil {
return nil, err
}
return &UploadResult{
BytesUploaded: coreResult.BytesUploaded,
Duration: coreResult.Duration,
Protocol: coreResult.Protocol,
ResultURL: coreResult.ResultURL,
}, nil
}
// Close releases resources associated with the client.
func (c *Client) Close() {
c.cancel()
}
// Download is a convenience function for a one-shot download.
func Download(urlStr, output string) (*DownloadResult, error) {
client := NewClient()
defer client.Close()
return client.Download(&DownloadRequest{
URL: urlStr,
Output: output,
Resume: true,
})
}
// Upload is a convenience function for a one-shot upload.
func Upload(urlStr, filePath, method string) (*UploadResult, error) {
client := NewClient()
defer client.Close()
return client.Upload(&UploadRequest{
URL: urlStr,
FilePath: filePath,
Method: method,
})
}
// ============================================================================
// Queue API
// ============================================================================
// QueueClient wraps the download queue for programmatic use.
type QueueClient struct {
q *queue.Queue
}
// NewQueueClient creates a new queue client.
func NewQueueClient(queueFile string) (*QueueClient, error) {
q, err := queue.NewQueue(&queue.QueueConfig{QueueFile: queueFile})
if err != nil {
return nil, err
}
return &QueueClient{q: q}, nil
}
// Add adds a URL to the download queue.
func (qc *QueueClient) Add(urlStr, output string, priority int) string {
item := qc.q.Add(urlStr, output, priority)
return item.ID
}
// List returns all items in the queue.
func (qc *QueueClient) List() []*queue.QueueItem {
return qc.q.GetAll()
}
// Stats returns queue statistics.
func (qc *QueueClient) Stats() queue.QueueStats {
return qc.q.GetStats()
}
// Remove removes an item from the queue by ID.
func (qc *QueueClient) Remove(id string) bool {
return qc.q.Remove(id)
}
// ClearCompleted removes all completed items.
func (qc *QueueClient) ClearCompleted() int {
return qc.q.ClearCompleted()
}
// ============================================================================
// Metalink API
// ============================================================================
// ParseMetalink parses a Metalink file.
func ParseMetalink(path string) (*metalink.Metalink, error) {
return metalink.ParseFile(path)
}
// FetchMetalink downloads and parses a Metalink from a URL.
func FetchMetalink(urlStr string) (*metalink.Metalink, error) {
return metalink.ParseURL(urlStr)
}
// ============================================================================
// Config API
// ============================================================================
// LoadConfig loads the configuration file.
func LoadConfig(path string) (*config.Config, error) {
return config.Load(path)
}
// DefaultConfig returns the default configuration.
func DefaultConfig() *config.Config {
return config.DefaultConfig()
}
// ParseSpeed parses a speed string like "10MB/s" to bytes/sec.
func ParseSpeed(s string) int64 {
return config.ParseSpeed(s)
}
+197
View File
@@ -0,0 +1,197 @@
//go:build linux || freebsd
// +build linux freebsd
package api
import (
"strings"
"testing"
"time"
)
func TestVersion(t *testing.T) {
v := Version()
if v == "" {
t.Error("Version() returned an empty string")
}
}
func TestVersionFormat(t *testing.T) {
v := Version()
if !strings.Contains(v, ".") {
t.Errorf("Version() = %q; expected semver-like format", v)
}
}
func TestNewClient(t *testing.T) {
client := NewClient()
if client == nil {
t.Fatal("NewClient() returned nil")
}
defer client.Close()
}
func TestNewClientWithOptions(t *testing.T) {
client := NewClient(WithVerbose(true), WithUserAgent("Test/1.0"))
if client == nil {
t.Fatal("NewClient() returned nil")
}
defer client.Close()
}
func TestDownloadRequestValidation(t *testing.T) {
client := NewClient()
defer client.Close()
if _, err := client.Download(nil); err == nil {
t.Error("expected error for nil request")
}
if _, err := client.Download(&DownloadRequest{}); err == nil {
t.Error("expected error for empty URL")
}
}
func TestUploadRequestValidation(t *testing.T) {
client := NewClient()
defer client.Close()
if _, err := client.Upload(nil); err == nil {
t.Error("expected error for nil request")
}
if _, err := client.Upload(&UploadRequest{URL: "http://example.com"}); err == nil {
t.Error("expected error for missing FilePath")
}
if _, err := client.Upload(&UploadRequest{FilePath: "/tmp/test"}); err == nil {
t.Error("expected error for missing URL")
}
}
func TestDownloadResultFields(t *testing.T) {
result := &DownloadResult{
BytesDownloaded: 1024,
Protocol: "HTTP/2",
}
if result.BytesDownloaded != 1024 {
t.Errorf("BytesDownloaded = %d", result.BytesDownloaded)
}
}
func TestUploadResultFields(t *testing.T) {
result := &UploadResult{
BytesUploaded: 512,
}
if result.BytesUploaded != 512 {
t.Errorf("BytesUploaded = %d", result.BytesUploaded)
}
}
func TestDownloadConvenience(t *testing.T) {
_, err := Download("", "")
if err == nil {
t.Error("expected error for empty URL")
}
}
func TestUploadConvenience(t *testing.T) {
_, err := Upload("", "", "")
if err == nil {
t.Error("expected error for empty fields")
}
}
// TestRequestContextTimeout is a regression guard for the BACKLOG entry
// "`pkg/api` Client timeout is lifetime, not per-request". The previous
// implementation created a single context.WithTimeout in NewClient, so a
// Client that lived longer than cfg.timeout could no longer serve any
// request. The fixed implementation uses a cancellable background context
// for the Client scope and derives a per-request child with the timeout
// applied at call time.
func TestRequestContextTimeout(t *testing.T) {
client := NewClient(WithTimeout(5 * time.Second))
defer client.Close()
reqCtx, cancel := client.requestContext()
defer cancel()
deadline, ok := reqCtx.Deadline()
if !ok {
t.Fatal("per-request context has no deadline; want cfg.timeout")
}
remaining := time.Until(deadline)
// Allow generous slack to avoid flakes on a busy CI runner, but
// enforce that the deadline is at most the configured timeout and
// has not already passed.
if remaining <= 0 {
t.Errorf("per-request deadline already passed: %v remaining", remaining)
}
if remaining > 5*time.Second {
t.Errorf("per-request deadline = %v from now; want <= 5s (cfg.timeout)", remaining)
}
}
// TestRequestContextNoTimeout verifies that a Client constructed with
// WithTimeout(0) yields a per-request context without a deadline. This
// preserves the "no timeout" semantics that callers relied on before the
// per-request refactor, when the only way to disable the lifetime cap
// was to never call Download at all.
func TestRequestContextNoTimeout(t *testing.T) {
client := NewClient(WithTimeout(0))
defer client.Close()
reqCtx, cancel := client.requestContext()
defer cancel()
if _, ok := reqCtx.Deadline(); ok {
t.Error("per-request context has a deadline; want none when cfg.timeout == 0")
}
}
// TestRequestContextCancelledByClientClose verifies that cancelling the
// Client scope propagates to per-request children. A long-running
// Download must abort when Close() is called, even if its own deadline
// has not been reached.
func TestRequestContextCancelledByClientClose(t *testing.T) {
client := NewClient(WithTimeout(time.Hour))
defer client.Close()
reqCtx, cancel := client.requestContext()
defer cancel()
// Sanity: nothing is cancelled yet.
if err := reqCtx.Err(); err != nil {
t.Fatalf("reqCtx unexpectedly cancelled before Close: %v", err)
}
client.Close()
// Close cancels the client scope, which propagates to the child.
select {
case <-reqCtx.Done():
case <-time.After(2 * time.Second):
t.Fatal("per-request context not cancelled after Client.Close()")
}
if err := reqCtx.Err(); err == nil {
t.Error("reqCtx.Err() = nil after Client.Close(); want non-nil")
}
}
// TestClientScopeHasNoDeadline is a regression guard for the original
// bug. The previous implementation wrapped context.Background() in
// WithTimeout at NewClient time, so a Client that lived past cfg.timeout
// had a context that was already past its deadline — and any
// context.WithTimeout child would be expired immediately.
func TestClientScopeHasNoDeadline(t *testing.T) {
client := NewClient(WithTimeout(50 * time.Millisecond))
defer client.Close()
if _, ok := client.ctx.Deadline(); ok {
t.Error("client-scope context has a deadline; lifetime cap would re-introduce the bug")
}
}
// TestRequestContextCancelledByClientClose above already proves that
// per-request contexts are derived from the client scope: cancelling the
// client cancels the per-request child. A direct parent-chain walk is not
// possible from outside the context package because the `parent` field
// is unexported.
+19
View File
@@ -0,0 +1,19 @@
//go:build linux || freebsd
// +build linux freebsd
package api
import (
"sync"
"codeberg.org/petrbalvin/goget/internal/protocol"
protocolreg "codeberg.org/petrbalvin/goget/internal/protocol/register"
)
var registerOnce sync.Once
func registerDefaultProtocols() {
registerOnce.Do(func() {
_, _ = protocolreg.RegisterAll(protocol.GlobalRegistry)
})
}