feat: initial goget release — modern IPv6-first download utility
This commit is contained in:
+341
@@ -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)
|
||||
}
|
||||
@@ -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.
|
||||
@@ -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)
|
||||
})
|
||||
}
|
||||
Reference in New Issue
Block a user