feat: add resume support to Gemini protocol

This commit is contained in:
2026-06-29 20:06:08 +02:00
parent fc65ea8340
commit 9ef62520c7
6 changed files with 387 additions and 13 deletions
+121 -10
View File
@@ -15,11 +15,13 @@ import (
"net"
"net/url"
"os"
"path/filepath"
"strconv"
"strings"
"time"
"codeberg.org/petrbalvin/goget/internal/core"
"codeberg.org/petrbalvin/goget/internal/output"
"codeberg.org/petrbalvin/goget/internal/protocol"
)
@@ -39,7 +41,7 @@ func NewProtocol() *Protocol {
Name: "GEMINI",
Scheme: "gemini",
DefaultPort: 1965,
Features: []string{},
Features: []string{"resume"},
}),
}
}
@@ -96,6 +98,14 @@ func (p *Protocol) downloadWithRedirects(ctx context.Context, req *core.Download
conn.SetDeadline(time.Now().Add(30 * time.Second))
// Close the connection when the context is cancelled so any
// blocking Read returns immediately instead of waiting for the
// 30-second deadline.
go func() {
<-ctx.Done()
conn.Close()
}()
// Send request: URL + CRLF
requestURL := req.URL.String()
if _, err := fmt.Fprintf(conn, "%s\r\n", requestURL); err != nil {
@@ -178,43 +188,129 @@ func (p *Protocol) downloadWithRedirects(ctx context.Context, req *core.Download
}
// Category 2x: success — continue to read body
var totalRead int64
var totalRead int64 // total bytes received from server (including skipped)
var writer io.Writer
var file *os.File
resumed := false
var startOffset int64 // bytes to skip on resume
if req.Writer != nil {
writer = req.Writer
} else {
writer = io.Discard
// Set up output destination. When req.Output is set and no external
// Writer was provided, we open the file directly. This covers both
// fresh downloads (create) and resume (append). When req.Writer is
// set (caller manages the file), we use it as-is.
if req.Output != "" && req.Output != "-" {
if req.Resume {
canResume, resumeMeta, rerr := output.CanResume(req.Output, req.URL.String())
if rerr != nil {
if req.Verbose {
fmt.Fprintf(os.Stderr, "[gemini] Resume check failed: %v\n", rerr)
}
output.CleanupResumeFiles(req.Output)
} else if canResume && resumeMeta != nil {
file, rerr = os.OpenFile(req.Output, os.O_APPEND|os.O_WRONLY, 0644)
if rerr != nil {
return nil, core.NewFileError("failed to open file for resume", rerr)
}
defer file.Close()
writer = file
startOffset = resumeMeta.Downloaded
resumed = true
if req.Verbose {
fmt.Fprintf(os.Stderr, "[gemini] Resuming from byte %d\n", startOffset)
}
}
}
if writer == nil && req.Writer == nil {
// Fresh download — create the output file.
dir := filepath.Dir(req.Output)
if dir != "" && dir != "." {
if err := os.MkdirAll(dir, 0755); err != nil {
return nil, core.NewFileError("failed to create output directory", err)
}
}
file, err = os.Create(req.Output)
if err != nil {
return nil, core.NewFileError("failed to create output file", err)
}
defer file.Close()
writer = file
}
}
if writer == nil {
if req.Writer != nil {
writer = req.Writer
} else {
writer = io.Discard
}
}
// Gemini does not support range requests, so resume re-downloads
// the full content and skips the bytes already saved to disk.
var skipped int64
buf := make([]byte, 32*1024)
for {
select {
case <-ctx.Done():
saveGeminiResume(req, totalRead)
return nil, ctx.Err()
default:
}
n, err := reader.Read(buf)
if n > 0 {
if _, werr := writer.Write(buf[:n]); werr != nil {
return nil, core.NewFileError("failed to write gemini data", werr)
}
totalRead += int64(n)
chunk := buf[:n]
// On resume, discard bytes we already have on disk.
if skipped < startOffset {
remain := startOffset - skipped
if int64(n) <= remain {
skipped += int64(n)
chunk = nil
} else {
skipped += remain
chunk = chunk[int(remain):]
}
}
if len(chunk) > 0 {
if _, werr := writer.Write(chunk); werr != nil {
saveGeminiResume(req, totalRead)
return nil, core.NewFileError("failed to write gemini data", werr)
}
}
if req.ProgressCallback != nil {
speed := float64(totalRead) / time.Since(startTime).Seconds()
req.ProgressCallback(totalRead, -1, speed)
}
}
if err == io.EOF {
// The connection may have been closed by our context
// cancellation goroutine. If so, the EOF is not a genuine
// end-of-data but a side-effect of the cancel.
if ctx.Err() != nil {
saveGeminiResume(req, totalRead)
return nil, ctx.Err()
}
break
}
if err != nil {
saveGeminiResume(req, totalRead)
if ctx.Err() != nil {
return nil, ctx.Err()
}
return nil, core.NewNetworkError("failed to read gemini response body", err, core.SafeURL(req.URL))
}
}
// Download complete — remove resume metadata sidecar.
if req.Output != "" && req.Output != "-" {
output.DeleteResumeMetadata(req.Output)
}
duration := time.Since(startTime)
speed := float64(totalRead) / duration.Seconds()
speed := float64(0)
if duration.Seconds() > 0 {
speed = float64(totalRead) / duration.Seconds()
}
return &core.DownloadResult{
BytesDownloaded: totalRead,
@@ -224,5 +320,20 @@ func (p *Protocol) downloadWithRedirects(ctx context.Context, req *core.Download
IPVersion: 4,
Speed: speed,
OutputPath: req.Output,
Resumed: resumed,
}, nil
}
// saveGeminiResume persists partial Gemini download progress to the
// standard `.goget.meta` sidecar. No-op for stdout writes or zero-byte
// transfers. Gemini does not expose Content-Length in the response
// header, so Total is recorded as 0 (size unknown).
func saveGeminiResume(req *core.DownloadRequest, downloaded int64) {
if req == nil || req.Output == "" || req.Output == "-" || downloaded <= 0 {
return
}
meta := output.NewResumeMetadata(req.URL.String(), "", "", downloaded, 0)
if err := meta.Save(req.Output); err != nil && req.Verbose {
fmt.Fprintf(os.Stderr, "[gemini] Warning: failed to save resume metadata: %v\n", err)
}
}