324 lines
8.0 KiB
Go
324 lines
8.0 KiB
Go
//go:build linux || freebsd
|
||
// +build linux freebsd
|
||
|
||
package http
|
||
|
||
import (
|
||
"context"
|
||
"fmt"
|
||
"io"
|
||
"net/http"
|
||
"os"
|
||
"path/filepath"
|
||
"sync"
|
||
"time"
|
||
|
||
"codeberg.org/petrbalvin/goget/internal/auth"
|
||
"codeberg.org/petrbalvin/goget/internal/core"
|
||
"codeberg.org/petrbalvin/goget/internal/output"
|
||
)
|
||
|
||
func (c *Client) downloadParallel(ctx context.Context, req *core.DownloadRequest, totalSize int64, connections int, resumeChunks map[int]int64, username, password string) (*core.DownloadResult, error) {
|
||
startTime := time.Now()
|
||
|
||
chunks := createChunks(totalSize, connections, c.config.Parallel)
|
||
if c.config.Output.Verbose {
|
||
fmt.Fprintf(os.Stderr, "[parallel] Split into %d chunks\n", len(chunks))
|
||
}
|
||
|
||
tempDir, err := os.MkdirTemp("", "goget-chunks-*")
|
||
if err != nil {
|
||
return nil, core.NewFileError("failed to create temp dir", err)
|
||
}
|
||
defer os.RemoveAll(tempDir)
|
||
|
||
// completedChunks tracks per-chunk downloaded bytes for resume metadata.
|
||
completedChunks := make(map[int]int64)
|
||
var completedChunksMu sync.Mutex
|
||
|
||
for id, n := range resumeChunks {
|
||
completedChunks[id] = n
|
||
}
|
||
|
||
progressChan := make(chan int64, len(chunks)*2)
|
||
var wg sync.WaitGroup
|
||
var firstError error
|
||
var errorMu sync.Mutex
|
||
|
||
for i := range chunks {
|
||
chunk := &chunks[i]
|
||
wg.Add(1)
|
||
|
||
go func(chunk *core.ChunkInfo) {
|
||
defer wg.Done()
|
||
|
||
if resumeChunks != nil {
|
||
if done, ok := resumeChunks[chunk.ID]; ok && done >= (chunk.End-chunk.Start+1) {
|
||
progressChan <- done
|
||
chunk.Status = 2
|
||
return
|
||
}
|
||
}
|
||
|
||
chunkPath := filepath.Join(tempDir, fmt.Sprintf("chunk_%04d", chunk.ID))
|
||
err := c.downloadChunk(ctx, req, chunk, chunkPath, progressChan, username, password)
|
||
if err != nil {
|
||
errorMu.Lock()
|
||
if firstError == nil {
|
||
firstError = err
|
||
}
|
||
errorMu.Unlock()
|
||
chunk.Status = 3
|
||
chunk.Error = err
|
||
return
|
||
}
|
||
// Save per-chunk progress for resume on interruption.
|
||
completedChunksMu.Lock()
|
||
completedChunks[chunk.ID] = chunk.End - chunk.Start + 1
|
||
saveParallelResumeMetadata(req, totalSize, completedChunks)
|
||
completedChunksMu.Unlock()
|
||
chunk.Status = 2
|
||
}(chunk)
|
||
}
|
||
|
||
go func() {
|
||
wg.Wait()
|
||
close(progressChan)
|
||
}()
|
||
|
||
var totalDownloaded int64
|
||
var cancelled bool
|
||
aggregationLoop:
|
||
for {
|
||
select {
|
||
case progress, ok := <-progressChan:
|
||
if !ok {
|
||
break aggregationLoop
|
||
}
|
||
totalDownloaded += progress
|
||
if req.ProgressCallback != nil {
|
||
duration := time.Since(startTime)
|
||
speed := float64(totalDownloaded) / duration.Seconds()
|
||
req.ProgressCallback(totalDownloaded, totalSize, speed)
|
||
}
|
||
case <-ctx.Done():
|
||
cancelled = true
|
||
break aggregationLoop
|
||
}
|
||
}
|
||
|
||
if cancelled {
|
||
// Context was cancelled (SIGINT/SIGTERM). Return the error so
|
||
// the caller can show "cancelled" instead of "complete", and
|
||
// the progress bar renders the interrupted state. Partial
|
||
// chunks are NOT merged — they remain in temp files for a
|
||
// potential resume later.
|
||
return nil, ctx.Err()
|
||
}
|
||
|
||
if firstError != nil {
|
||
return nil, firstError
|
||
}
|
||
|
||
if req.Output != "" && req.Output != "-" {
|
||
if err := mergeChunks(req.Output, chunks, tempDir); err != nil {
|
||
return nil, core.NewFileError("failed to merge chunks", err)
|
||
}
|
||
}
|
||
|
||
if req.Output != "" {
|
||
output.DeleteResumeMetadata(req.Output)
|
||
}
|
||
|
||
duration := time.Since(startTime)
|
||
speed := float64(0)
|
||
if duration.Seconds() > 0 {
|
||
speed = float64(totalSize) / duration.Seconds()
|
||
}
|
||
|
||
return &core.DownloadResult{
|
||
BytesDownloaded: totalSize,
|
||
TotalSize: totalSize,
|
||
Duration: duration,
|
||
Speed: speed,
|
||
Protocol: "HTTP/HTTPS",
|
||
IPVersion: 6,
|
||
OutputPath: req.Output,
|
||
Parallel: true,
|
||
ChunksCount: len(chunks),
|
||
}, nil
|
||
}
|
||
|
||
// saveParallelResumeMetadata saves per-chunk progress to the resume metadata file.
|
||
func saveParallelResumeMetadata(req *core.DownloadRequest, totalSize int64, chunks map[int]int64) {
|
||
if req.Output == "" || req.Output == "-" {
|
||
return
|
||
}
|
||
// Build a snapshot of the current chunk progress.
|
||
snapshot := make(map[int]int64, len(chunks))
|
||
for id, n := range chunks {
|
||
snapshot[id] = n
|
||
}
|
||
meta := &output.ResumeMetadata{
|
||
URL: req.URL.String(),
|
||
Total: totalSize,
|
||
Downloaded: totalChunkBytes(snapshot),
|
||
LastWrite: time.Now(),
|
||
Version: "1.0",
|
||
Chunks: snapshot,
|
||
}
|
||
_ = meta.Save(req.Output)
|
||
}
|
||
|
||
// totalChunkBytes sums the byte count of all completed chunks.
|
||
func totalChunkBytes(chunks map[int]int64) int64 {
|
||
var total int64
|
||
for _, n := range chunks {
|
||
total += n
|
||
}
|
||
return total
|
||
}
|
||
|
||
func createChunks(totalSize int64, count int, cfg *core.ParallelConfig) []core.ChunkInfo {
|
||
if count <= 1 || totalSize <= cfg.MinSize {
|
||
return []core.ChunkInfo{
|
||
{ID: 0, Start: 0, End: totalSize - 1},
|
||
}
|
||
}
|
||
|
||
// Divide the file into `count` equal-sized chunks. The last chunk
|
||
// absorbs any remainder. MaxChunkSize is NOT used to further
|
||
// subdivide — the user explicitly asked for N parallel connections
|
||
// via --parallel, not N×M tiny range requests. Each chunk becomes
|
||
// one HTTP Range request handled by one of the N worker goroutines.
|
||
chunkSize := totalSize / int64(count)
|
||
if chunkSize < cfg.MinChunkSize {
|
||
chunkSize = cfg.MinChunkSize
|
||
count = int((totalSize + chunkSize - 1) / chunkSize)
|
||
}
|
||
|
||
chunks := make([]core.ChunkInfo, 0, count)
|
||
for i := 0; i < count; i++ {
|
||
start := int64(i) * chunkSize
|
||
end := start + chunkSize - 1
|
||
if i == count-1 || end >= totalSize {
|
||
end = totalSize - 1
|
||
}
|
||
chunks = append(chunks, core.ChunkInfo{
|
||
ID: i,
|
||
Start: start,
|
||
End: end,
|
||
})
|
||
}
|
||
|
||
return chunks
|
||
}
|
||
|
||
func (c *Client) downloadChunk(ctx context.Context, req *core.DownloadRequest, chunk *core.ChunkInfo, outputPath string, progressChan chan<- int64, username, password string) error {
|
||
chunk.Status = 1
|
||
|
||
httpReq, err := http.NewRequestWithContext(ctx, "GET", req.URL.String(), nil)
|
||
if err != nil {
|
||
return core.NewNetworkError("failed to create chunk request", err, req.URL.String())
|
||
}
|
||
|
||
httpReq.Header.Set("User-Agent", c.config.UserAgent)
|
||
httpReq.Header.Set("Range", fmt.Sprintf("bytes=%d-%d", chunk.Start, chunk.End))
|
||
|
||
if req.ResumeInfo != nil {
|
||
if req.ResumeInfo.ETag != "" {
|
||
httpReq.Header.Set("If-Range", req.ResumeInfo.ETag)
|
||
} else if req.ResumeInfo.LastModified != "" {
|
||
httpReq.Header.Set("If-Range", req.ResumeInfo.LastModified)
|
||
}
|
||
}
|
||
|
||
if username != "" {
|
||
auth.BasicAuth(httpReq, username, password)
|
||
}
|
||
|
||
for key, value := range req.Headers {
|
||
httpReq.Header.Set(key, value)
|
||
}
|
||
|
||
resp, err := c.httpClient.Do(httpReq)
|
||
if err != nil {
|
||
return core.NewNetworkError("failed to fetch chunk", err, req.URL.String())
|
||
}
|
||
defer resp.Body.Close()
|
||
c.updateHSTS(resp)
|
||
|
||
if resp.StatusCode != http.StatusPartialContent {
|
||
return core.NewProtocolError(
|
||
fmt.Sprintf("chunk HTTP error: %s", resp.Status),
|
||
nil,
|
||
req.URL.String(),
|
||
)
|
||
}
|
||
|
||
file, err := os.Create(outputPath)
|
||
if err != nil {
|
||
return core.NewFileError("failed to create chunk file", err)
|
||
}
|
||
defer file.Close()
|
||
|
||
reader := io.Reader(resp.Body)
|
||
if c.config.RateLimiter != nil {
|
||
reader = c.config.RateLimiter.WrapReader(reader)
|
||
}
|
||
|
||
buf := make([]byte, c.config.Transport.BufferSize)
|
||
var downloaded int64
|
||
|
||
for {
|
||
if ctx.Err() != nil {
|
||
return ctx.Err()
|
||
}
|
||
|
||
n, err := reader.Read(buf)
|
||
if n > 0 {
|
||
_, wErr := file.Write(buf[:n])
|
||
if wErr != nil {
|
||
return core.NewFileError("failed to write chunk", wErr)
|
||
}
|
||
downloaded += int64(n)
|
||
if progressChan != nil {
|
||
progressChan <- int64(n)
|
||
}
|
||
}
|
||
|
||
if err == io.EOF {
|
||
break
|
||
}
|
||
if err != nil {
|
||
return core.NewNetworkError("failed to read chunk", err, req.URL.String())
|
||
}
|
||
}
|
||
|
||
chunk.Downloaded = downloaded
|
||
return nil
|
||
}
|
||
|
||
func mergeChunks(outputPath string, chunks []core.ChunkInfo, tempDir string) error {
|
||
outFile, err := os.Create(outputPath)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
defer outFile.Close()
|
||
|
||
for _, chunk := range chunks {
|
||
chunkPath := filepath.Join(tempDir, fmt.Sprintf("chunk_%04d", chunk.ID))
|
||
chunkFile, err := os.Open(chunkPath)
|
||
if err != nil {
|
||
return fmt.Errorf("failed to open chunk %d: %w", chunk.ID, err)
|
||
}
|
||
_, err = io.Copy(outFile, chunkFile)
|
||
chunkFile.Close()
|
||
if err != nil {
|
||
return fmt.Errorf("failed to copy chunk %d: %w", chunk.ID, err)
|
||
}
|
||
}
|
||
|
||
return nil
|
||
}
|