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