Add resumable downloads, download paths, queue controls and history
This commit is contained in:
1 parent
434f6441da
commit
6027cfabb9
43 files changed
+2138
-291
No files matched your search
@@ -0,0 +1,141 @@
|
||||
package fetch
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"ddc/internal/guard"
|
||||
)
|
||||
|
||||
const userAgent = "ddc/1.0"
|
||||
|
||||
var (
|
||||
ErrResumeUnsupported = errors.New("connection was lost and the server does not support resuming")
|
||||
ErrSourceChanged = errors.New("file changed on the server while downloading")
|
||||
errConnectionFailed = errors.New("connection to the server failed")
|
||||
errServerUnavailable = errors.New("server is temporarily unavailable")
|
||||
)
|
||||
|
||||
type remoteFile struct {
|
||||
url string
|
||||
validator string
|
||||
totalBytes int64
|
||||
}
|
||||
|
||||
func (source *remoteFile) describe(response *http.Response) {
|
||||
source.totalBytes = max(response.ContentLength, 0)
|
||||
source.validator = validatorOf(response.Header)
|
||||
}
|
||||
|
||||
func validatorOf(header http.Header) string {
|
||||
if entityTag := header.Get("ETag"); entityTag != "" && !strings.HasPrefix(entityTag, "W/") {
|
||||
return entityTag
|
||||
}
|
||||
return header.Get("Last-Modified")
|
||||
}
|
||||
|
||||
type cancelingBody struct {
|
||||
io.ReadCloser
|
||||
cancel context.CancelFunc
|
||||
}
|
||||
|
||||
func (body *cancelingBody) Close() error {
|
||||
body.cancel()
|
||||
return body.ReadCloser.Close()
|
||||
}
|
||||
|
||||
func (downloader *Downloader) open(ctx context.Context, source *remoteFile, position, end int64) (*http.Response, error) {
|
||||
requestContext, cancelRequest := context.WithCancel(ctx)
|
||||
response, err := downloader.send(requestContext, source, position, end)
|
||||
if err != nil {
|
||||
cancelRequest()
|
||||
return nil, err
|
||||
}
|
||||
response.Body = &cancelingBody{ReadCloser: response.Body, cancel: cancelRequest}
|
||||
return response, nil
|
||||
}
|
||||
|
||||
func (downloader *Downloader) send(ctx context.Context, source *remoteFile, position, end int64) (*http.Response, error) {
|
||||
request, err := http.NewRequestWithContext(ctx, http.MethodGet, source.url, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
request.Header.Set("User-Agent", userAgent)
|
||||
request.Header.Set("Accept-Encoding", "identity")
|
||||
|
||||
ranged := position > 0 || (end > 0 && end < source.totalBytes)
|
||||
if ranged {
|
||||
request.Header.Set("Range", rangeHeader(position, end))
|
||||
if source.validator != "" {
|
||||
request.Header.Set("If-Range", source.validator)
|
||||
}
|
||||
}
|
||||
|
||||
response, err := downloader.client.Do(request)
|
||||
if err != nil {
|
||||
return nil, connectionError(err)
|
||||
}
|
||||
if err := checkResponse(response, source, position, ranged); err != nil {
|
||||
response.Body.Close()
|
||||
return nil, err
|
||||
}
|
||||
return response, nil
|
||||
}
|
||||
|
||||
func rangeHeader(position, end int64) string {
|
||||
if end > 0 {
|
||||
return fmt.Sprintf("bytes=%d-%d", position, end-1)
|
||||
}
|
||||
return fmt.Sprintf("bytes=%d-", position)
|
||||
}
|
||||
|
||||
func checkResponse(response *http.Response, source *remoteFile, position int64, ranged bool) error {
|
||||
status := response.StatusCode
|
||||
switch {
|
||||
case !ranged && status == http.StatusOK:
|
||||
if source.totalBytes > 0 && response.ContentLength != source.totalBytes {
|
||||
return ErrSourceChanged
|
||||
}
|
||||
return nil
|
||||
case ranged && status == http.StatusPartialContent:
|
||||
return checkContentRange(response.Header.Get("Content-Range"), source.totalBytes, position)
|
||||
case ranged && status == http.StatusOK:
|
||||
if source.validator != "" && validatorOf(response.Header) != source.validator {
|
||||
return ErrSourceChanged
|
||||
}
|
||||
return ErrResumeUnsupported
|
||||
case status == http.StatusTooManyRequests || status >= http.StatusInternalServerError:
|
||||
return fmt.Errorf("%w (%s)", errServerUnavailable, response.Status)
|
||||
default:
|
||||
return fmt.Errorf("server responded with %s", response.Status)
|
||||
}
|
||||
}
|
||||
|
||||
func checkContentRange(header string, totalBytes, offset int64) error {
|
||||
rangeText, found := strings.CutPrefix(header, "bytes ")
|
||||
if !found {
|
||||
return ErrResumeUnsupported
|
||||
}
|
||||
span, totalText, found := strings.Cut(rangeText, "/")
|
||||
startText, _, _ := strings.Cut(span, "-")
|
||||
start, err := strconv.ParseInt(startText, 10, 64)
|
||||
if !found || err != nil || start != offset {
|
||||
return ErrResumeUnsupported
|
||||
}
|
||||
if totalBytes > 0 && totalText != "*" && totalText != strconv.FormatInt(totalBytes, 10) {
|
||||
return ErrSourceChanged
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func connectionError(err error) error {
|
||||
if errors.Is(err, guard.ErrBlockedAddress) || errors.Is(err, guard.ErrBlockedPort) || errors.Is(err, guard.ErrTooManyRedirects) {
|
||||
return err
|
||||
}
|
||||
return fmt.Errorf("%w: %w", errConnectionFailed, err)
|
||||
}
|
||||
Reference in new issue
Block a user