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
+211
View File
@@ -0,0 +1,211 @@
//go:build linux || freebsd
// +build linux freebsd
package file
import (
"context"
"io"
"os"
"path/filepath"
"time"
"codeberg.org/petrbalvin/goget/internal/core"
"codeberg.org/petrbalvin/goget/internal/protocol"
)
// Protocol implements file:// downloads.
type Protocol struct {
*protocol.BaseProtocol
}
// NewProtocol creates a new file protocol handler.
func NewProtocol() *Protocol {
return &Protocol{
BaseProtocol: protocol.NewBaseProtocol(protocol.ProtocolInfo{
Name: "FILE",
Scheme: "file",
DefaultPort: 0,
Features: []string{"resume", "recursive"},
}),
}
}
func (p *Protocol) Download(ctx context.Context, req *core.DownloadRequest) (*core.DownloadResult, error) {
startTime := time.Now()
filePath := req.URL.Path
if req.URL.Host != "" {
filePath = filepath.Join("/", req.URL.Host, filePath)
}
filePath = filepath.Clean(filePath)
info, err := os.Stat(filePath)
if err != nil {
return nil, core.NewFileError("failed to stat source path", err)
}
if info.IsDir() {
return p.downloadDirectory(ctx, req, filePath, startTime)
}
src, err := os.Open(filePath)
if err != nil {
return nil, core.NewFileError("failed to open source file", err)
}
defer src.Close()
totalSize := info.Size()
var writer io.Writer
var outputPath string
if req.Writer != nil {
writer = req.Writer
outputPath = req.Output
} else if req.Output == "" || req.Output == "-" {
writer = os.Stdout
outputPath = "-"
} else {
dst, err := os.Create(req.Output)
if err != nil {
return nil, core.NewFileError("failed to create output file", err)
}
defer dst.Close()
writer = dst
outputPath = req.Output
}
var written int64
if req.Resume && outputPath != "" && outputPath != "-" {
if fi, err := os.Stat(outputPath); err == nil {
written = fi.Size()
if written >= totalSize {
return &core.DownloadResult{
BytesDownloaded: totalSize,
TotalSize: totalSize,
Duration: time.Since(startTime),
Protocol: "FILE",
OutputPath: outputPath,
Resumed: true,
}, nil
}
if _, err := src.Seek(written, io.SeekStart); err != nil {
return nil, core.NewFileError("failed to seek for resume", err)
}
}
}
buf := make([]byte, 32*1024)
for {
select {
case <-ctx.Done():
return nil, ctx.Err()
default:
}
n, err := src.Read(buf)
if n > 0 {
if _, werr := writer.Write(buf[:n]); werr != nil {
return nil, core.NewFileError("failed to write data", werr)
}
written += int64(n)
if req.ProgressCallback != nil {
speed := float64(written) / time.Since(startTime).Seconds()
req.ProgressCallback(written, totalSize, speed)
}
}
if err == io.EOF {
break
}
if err != nil {
return nil, core.NewFileError("failed to read source file", err)
}
}
duration := time.Since(startTime)
speed := float64(written) / duration.Seconds()
return &core.DownloadResult{
BytesDownloaded: written,
TotalSize: totalSize,
Duration: duration,
Protocol: "FILE",
Speed: speed,
OutputPath: outputPath,
Resumed: written > 0 && req.Resume,
}, nil
}
// downloadDirectory recursively copies a local directory.
func (p *Protocol) downloadDirectory(ctx context.Context, req *core.DownloadRequest, dirPath string, startTime time.Time) (*core.DownloadResult, error) {
outputBase := req.Output
if outputBase == "" {
outputBase = filepath.Base(dirPath)
}
var totalBytes int64
var fileCount int
err := filepath.Walk(dirPath, func(path string, info os.FileInfo, err error) error {
select {
case <-ctx.Done():
return ctx.Err()
default:
}
if err != nil {
return err
}
if info.IsDir() {
return nil
}
// Calculate relative path
rel, err := filepath.Rel(dirPath, path)
if err != nil {
return err
}
outputPath := filepath.Join(outputBase, rel)
if err := os.MkdirAll(filepath.Dir(outputPath), 0755); err != nil {
return err
}
src, err := os.Open(path)
if err != nil {
return err
}
defer src.Close()
dst, err := os.Create(outputPath)
if err != nil {
return err
}
defer dst.Close()
n, err := io.Copy(dst, src)
if err != nil {
return err
}
totalBytes += n
fileCount++
return nil
})
if err != nil {
return nil, core.NewFileError("failed to copy directory recursively", err)
}
duration := time.Since(startTime)
speed := float64(0)
if duration.Seconds() > 0 {
speed = float64(totalBytes) / duration.Seconds()
}
return &core.DownloadResult{
BytesDownloaded: totalBytes,
TotalSize: totalBytes,
Duration: duration,
Protocol: "FILE",
Speed: speed,
OutputPath: outputBase,
ChunksCount: fileCount,
}, nil
}
+129
View File
@@ -0,0 +1,129 @@
//go:build linux || freebsd
// +build linux freebsd
package file
import (
"bytes"
"context"
"net/url"
"os"
"path/filepath"
"testing"
"codeberg.org/petrbalvin/goget/internal/core"
)
func TestDownloadFile(t *testing.T) {
tmp := t.TempDir()
src := filepath.Join(tmp, "src.txt")
dst := filepath.Join(tmp, "dst.txt")
os.WriteFile(src, []byte("hello world"), 0644)
proto := NewProtocol()
u := &url.URL{Scheme: "file", Path: src}
result, err := proto.Download(context.Background(), &core.DownloadRequest{
URL: u,
Output: dst,
})
if err != nil {
t.Fatal(err)
}
if result.BytesDownloaded != 11 {
t.Errorf("bytes = %d, want 11", result.BytesDownloaded)
}
data, _ := os.ReadFile(dst)
if string(data) != "hello world" {
t.Errorf("content = %q, want %q", data, "hello world")
}
}
func TestDownloadFileToWriter(t *testing.T) {
tmp := t.TempDir()
src := filepath.Join(tmp, "src.txt")
os.WriteFile(src, []byte("test"), 0644)
var buf bytes.Buffer
proto := NewProtocol()
u := &url.URL{Scheme: "file", Path: src}
_, err := proto.Download(context.Background(), &core.DownloadRequest{
URL: u,
Output: "-",
Writer: &buf,
})
if err != nil {
t.Fatal(err)
}
if buf.String() != "test" {
t.Errorf("content = %q, want %q", buf.String(), "test")
}
}
func TestDownloadFileNotFound(t *testing.T) {
proto := NewProtocol()
u := &url.URL{Scheme: "file", Path: "/nonexistent/path"}
_, err := proto.Download(context.Background(), &core.DownloadRequest{
URL: u,
Output: "/tmp/out",
})
if err == nil {
t.Error("expected error for nonexistent file")
}
}
func TestDownloadDirectory(t *testing.T) {
tmp := t.TempDir()
srcDir := filepath.Join(tmp, "srcdir")
dstDir := filepath.Join(tmp, "dstdir")
os.MkdirAll(filepath.Join(srcDir, "sub"), 0755)
os.WriteFile(filepath.Join(srcDir, "a.txt"), []byte("a"), 0644)
os.WriteFile(filepath.Join(srcDir, "sub/b.txt"), []byte("b"), 0644)
proto := NewProtocol()
u := &url.URL{Scheme: "file", Path: srcDir}
result, err := proto.Download(context.Background(), &core.DownloadRequest{
URL: u,
Output: dstDir,
})
if err != nil {
t.Fatal(err)
}
if result.ChunksCount != 2 {
t.Errorf("files = %d, want 2", result.ChunksCount)
}
if result.BytesDownloaded != 2 {
t.Errorf("bytes = %d, want 2", result.BytesDownloaded)
}
// Verify files exist
if _, err := os.Stat(filepath.Join(dstDir, "a.txt")); err != nil {
t.Error("a.txt not found")
}
if _, err := os.Stat(filepath.Join(dstDir, "sub", "b.txt")); err != nil {
t.Error("sub/b.txt not found")
}
}
func TestResumeFile(t *testing.T) {
tmp := t.TempDir()
src := filepath.Join(tmp, "src.txt")
dst := filepath.Join(tmp, "dst.txt")
os.WriteFile(src, []byte("hello world"), 0644)
os.WriteFile(dst, []byte("hello"), 0644) // partial: 5 bytes
proto := NewProtocol()
u := &url.URL{Scheme: "file", Path: src}
result, err := proto.Download(context.Background(), &core.DownloadRequest{
URL: u,
Output: dst,
Resume: true,
})
if err != nil {
t.Fatal(err)
}
if !result.Resumed {
t.Error("expected Resumed=true")
}
if result.BytesDownloaded != 11 {
t.Errorf("bytes = %d, want 11", result.BytesDownloaded)
}
}