mirror of
https://github.com/GopeedLab/gopeed.git
synced 2026-11-10 10:30:28 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
44e2272ca4 | ||
|
|
c51c5b284d | ||
|
|
4d65948934 | ||
|
|
c275bc2e46 | ||
|
|
9aae13453a | ||
|
|
d125d4ab75 | ||
|
|
4214765d39 | ||
|
|
47cafb7575 | ||
|
|
de65149b29 | ||
|
|
5462b14705 | ||
|
|
eee39e012f | ||
|
|
12860bb207 | ||
|
|
b86c2c18e7 |
No files matched your search
@@ -106,17 +106,32 @@ jobs:
|
||||
if ("${{ matrix.arch }}" -eq "amd64") {
|
||||
# for amd64
|
||||
$release = "build\windows\x64\runner\Release\"
|
||||
$mingw = "C:\Program Files\Git\mingw64\bin"
|
||||
cp $mingw\libstdc++-6.dll $release
|
||||
cp $mingw\libgcc_s_seh-1.dll $release
|
||||
# Use the same toolchain as cgo instead of Git's bundled DLLs.
|
||||
$cc = (Get-Command -Name (go env CC) -CommandType Application -ErrorAction Stop | Select-Object -First 1).Source
|
||||
foreach ($dll in @("libstdc++-6.dll", "libgcc_s_seh-1.dll", "libwinpthread-1.dll")) {
|
||||
$runtimePath = [string](& $cc "-print-file-name=$dll")
|
||||
if ($LASTEXITCODE -ne 0 -or [string]::IsNullOrWhiteSpace($runtimePath)) {
|
||||
throw "Failed to locate $dll using $cc"
|
||||
}
|
||||
$runtimePath = $runtimePath.Trim()
|
||||
# Some MinGW distributions keep runtime DLLs beside gcc.exe.
|
||||
if ($runtimePath -eq $dll) {
|
||||
$runtimePath = Join-Path (Split-Path -Parent $cc) $dll
|
||||
}
|
||||
if (!(Test-Path -LiteralPath $runtimePath -PathType Leaf)) {
|
||||
throw "MinGW runtime $dll was not found by $cc (reported path: $runtimePath)"
|
||||
}
|
||||
Write-Host "Copying MinGW runtime: $runtimePath"
|
||||
Copy-Item -LiteralPath $runtimePath -Destination $release
|
||||
}
|
||||
} else {
|
||||
# for ARM64
|
||||
$release = "build\windows\arm64\runner\Release\"
|
||||
$mingw = "$env:CLANGARM64_ROOT\aarch64-w64-mingw32\bin"
|
||||
cp $mingw\libc++.dll $release
|
||||
cp $mingw\libunwind.dll $release
|
||||
cp $mingw\libwinpthread-1.dll $release
|
||||
}
|
||||
cp $mingw\libwinpthread-1.dll $release
|
||||
cp $system\msvcp140.dll $release
|
||||
cp $system\vcruntime140.dll $release
|
||||
cp $system\vcruntime140_1.dll $release
|
||||
|
||||
@@ -69,6 +69,23 @@ jobs:
|
||||
with:
|
||||
files: ./coverage.txt
|
||||
token: ${{ secrets.CODECOV_UPLOAD_TOKEN }}
|
||||
test-flutter-category:
|
||||
# The settings editor and the backend have to read an extension list the
|
||||
# same way, so both sides run the shared rule table. The Go side is covered
|
||||
# by the test job above, this runs the ui side of it.
|
||||
name: Test shared category rules
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
- uses: subosito/flutter-action@v2
|
||||
with:
|
||||
flutter-version: ${{ env.FLUTTER_VERSION }}
|
||||
- name: Run the shared category rule table on the ui side
|
||||
working-directory: ui/flutter
|
||||
run: |
|
||||
flutter pub get
|
||||
flutter gen-l10n
|
||||
flutter test test/api/downloader_config_test.dart
|
||||
test-webview-integration:
|
||||
name: Test webview integration (${{ matrix.name }})
|
||||
strategy:
|
||||
|
||||
+2
-2
@@ -166,7 +166,7 @@ func runResolve(args []string) error {
|
||||
result, err := d.Resolve(&base.Request{
|
||||
URL: url,
|
||||
Labels: map[string]string(labels),
|
||||
}, nil)
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -250,7 +250,7 @@ func runDownload(args []string) error {
|
||||
result, err := d.Resolve(&base.Request{
|
||||
URL: url,
|
||||
Labels: map[string]string(labels),
|
||||
}, nil)
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
+7
-1
@@ -26,7 +26,7 @@ func Start(cfg *model.StartConfig) {
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
if downloadCfg.FirstLoad {
|
||||
if rest.Downloader.IsFirstLoad() {
|
||||
// Set default download config
|
||||
if cfg.DownloadConfig != nil {
|
||||
cfg.DownloadConfig.Merge(downloadCfg)
|
||||
@@ -51,6 +51,12 @@ func Start(cfg *model.StartConfig) {
|
||||
rest.Downloader.PutConfig(downloadCfg)
|
||||
}
|
||||
}
|
||||
|
||||
// The default download dir is resolved only after the core starts, so
|
||||
// seed the built-in categories now that the dir is known. Init is
|
||||
// idempotent and leaves an existing category list untouched.
|
||||
downloadCfg.Init()
|
||||
rest.Downloader.PutConfig(downloadCfg)
|
||||
}
|
||||
watchExit()
|
||||
|
||||
|
||||
@@ -66,6 +66,7 @@ require (
|
||||
go.uber.org/automaxprocs v1.6.0 // indirect
|
||||
go.uber.org/mock v0.5.1 // indirect
|
||||
go4.org v0.0.0-20230225012048-214862532bf5 // indirect
|
||||
golang.org/x/mod v0.33.0
|
||||
golang.org/x/oauth2 v0.35.0 // indirect
|
||||
golang.org/x/sync v0.20.0 // indirect
|
||||
)
|
||||
@@ -156,7 +157,6 @@ require (
|
||||
go.opentelemetry.io/otel/metric v1.39.0 // indirect
|
||||
go.opentelemetry.io/otel/trace v1.39.0 // indirect
|
||||
golang.org/x/crypto v0.48.0 // indirect
|
||||
golang.org/x/mod v0.33.0 // indirect
|
||||
golang.org/x/net v0.50.0
|
||||
golang.org/x/sys v0.44.0 // indirect
|
||||
golang.org/x/text v0.34.0
|
||||
|
||||
@@ -12,7 +12,7 @@ import (
|
||||
// Each download task will have a corresponding Fetcher instance for the management of the download task
|
||||
type Fetcher interface {
|
||||
Setup(ctl *controller.Controller)
|
||||
Resolve(req *base.Request, opts *base.Options) error
|
||||
Resolve(req *base.Request) error
|
||||
Start() error
|
||||
// Patch modifies task-specific data based on the protocol.
|
||||
// For HTTP: can modify Request info (URL, headers, etc.)
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
package fetcher
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"time"
|
||||
)
|
||||
|
||||
type TriggerSource string
|
||||
|
||||
const (
|
||||
TriggerScheduled TriggerSource = "scheduled"
|
||||
TriggerManual TriggerSource = "manual"
|
||||
)
|
||||
|
||||
var ErrJobSkipped = errors.New("protocol job skipped")
|
||||
var ErrConfigChanged = errors.New("protocol configuration changed during execution")
|
||||
|
||||
// ConfigState embeds the common backend-owned state section in a protocol config.
|
||||
// Public config PUTs always retain this section from the latest stored config.
|
||||
type ConfigState[T any] struct {
|
||||
State T `json:"state"`
|
||||
}
|
||||
|
||||
// ProtocolHost is scoped to one manager. UpdateState runs against the latest
|
||||
// config under Downloader's lock; callbacks must not perform network I/O.
|
||||
type ProtocolHost struct {
|
||||
Read func(any) error
|
||||
UpdateState func(func(config, state json.RawMessage) (any, error)) error
|
||||
Fetch func(context.Context, string, int64, func([]byte) error) ([]byte, error)
|
||||
}
|
||||
|
||||
func ReadConfig[T any](host ProtocolHost) (*T, error) {
|
||||
var cfg T
|
||||
if err := host.Read(&cfg); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &cfg, nil
|
||||
}
|
||||
|
||||
// UpdateState gives the callback a detached config snapshot and mutable state.
|
||||
// Only the state is committed; edits to the config snapshot are discarded.
|
||||
func UpdateState[C, S any](host ProtocolHost, update func(*C, *S) error) error {
|
||||
return host.UpdateState(func(rawConfig, rawState json.RawMessage) (any, error) {
|
||||
var cfg C
|
||||
var state S
|
||||
if err := json.Unmarshal(rawConfig, &cfg); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := json.Unmarshal(rawState, &state); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := update(&cfg, &state); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &state, nil
|
||||
})
|
||||
}
|
||||
|
||||
type CronJob struct {
|
||||
Name string
|
||||
Every time.Duration
|
||||
Timeout time.Duration
|
||||
Run func(context.Context, TriggerSource) error
|
||||
}
|
||||
type CronProvider interface{ CronJobs(ProtocolHost) []CronJob }
|
||||
|
||||
// ConfigNormalizer is optional. It owns protocol validation and derived fields.
|
||||
type ConfigNormalizer interface {
|
||||
NormalizeConfig(json.RawMessage) (any, error)
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
# GitHub mirror execution
|
||||
|
||||
This package owns GitHub URL rewriting and execution strategies. It depends only
|
||||
on the standard library and does not depend on Downloader, HTTP clients, Git, or
|
||||
storage. Downloader persists `Config` at its top-level `githubMirror` field;
|
||||
`pkg/base` preserves the existing configuration names through aliases.
|
||||
|
||||
## Race: resource reads
|
||||
|
||||
```go
|
||||
body, err := githubmirror.Race(ctx, cfg.GithubMirror, rawURL,
|
||||
githubmirror.Options[[]byte]{
|
||||
Kind: githubmirror.Resource,
|
||||
Timeout: 30 * time.Second,
|
||||
},
|
||||
func(ctx context.Context, candidate string) ([]byte, error) {
|
||||
return fetchAndValidate(ctx, client, candidate)
|
||||
},
|
||||
)
|
||||
```
|
||||
|
||||
The callback owns HTTP requests, size limits, and content validation. The first
|
||||
completed valid result wins. Other operations are canceled and drained. There is
|
||||
no HEAD-probe assumption or client-specific HTTP behavior inside the executor.
|
||||
|
||||
## Fallback: repository operations
|
||||
|
||||
```go
|
||||
repo, err := githubmirror.Fallback(ctx, cfg.GithubMirror, officialURL,
|
||||
githubmirror.Options[*git.Repository]{
|
||||
Kind: githubmirror.Repository,
|
||||
},
|
||||
func(ctx context.Context, candidate string) (*git.Repository, error) {
|
||||
// Use a clean temporary checkout for each attempt.
|
||||
return clone(ctx, candidate)
|
||||
},
|
||||
)
|
||||
```
|
||||
|
||||
The official URL is attempted first; mirrors follow their configured order.
|
||||
jsDelivr is excluded for repositories. Stop on the first success. Perform
|
||||
persistent installation or other business state changes after success, rather
|
||||
than inside a potentially repeated attempt.
|
||||
|
||||
## Results with resources
|
||||
|
||||
For temporary files, set `Options[*Download].Discard` to remove the file. Return
|
||||
the partial `*Download` alongside errors, so cleanup also covers failed reads or
|
||||
validation. The executor discards every failure and every race loser exactly
|
||||
once. The caller owns and must track/release the winning result.
|
||||
|
||||
- `Timeout == 0` uses 30 seconds per resource attempt and no overall deadline
|
||||
for repository attempts; a negative timeout is invalid. Extension Git HTTP
|
||||
transport limits connections/TLS handshakes to 10 seconds and resets a 60-second
|
||||
read deadline as data arrives. Slow continuous transfers can finish, while
|
||||
stalled sources fail and allow ordered fallback. Parent cancellation still applies.
|
||||
- Attempts must honor the supplied context; draining waits for them to return.
|
||||
- Attempt contexts are canceled after return, so callbacks must finish reading
|
||||
HTTP bodies before returning. Returning a still-streaming response is unsupported.
|
||||
- Discard callbacks must accept zero values, must not panic, and run serially.
|
||||
- Parent cancellation wins over a result selected while the parent is canceled;
|
||||
the selected result is then discarded as well.
|
||||
- Errors preserve their causes through wrapping/joining for `errors.Is`.
|
||||
- `github.com` and `raw.githubusercontent.com` expand into eligible mirrors.
|
||||
jsDelivr is only eligible for raw source resources. `api.github.com` is never
|
||||
mirrored and is rejected by the public mirror candidates API.
|
||||
- Client application update checks request the official GitHub API directly,
|
||||
falling back to the independent Gopeed website API on request or validation
|
||||
failure. Only package downloads obtain URLs from
|
||||
`POST /api/v1/mirrors/github/candidates`. Candidate generation never performs
|
||||
HTTP requests.
|
||||
- Disabled mirrors and other URLs execute only the original URL.
|
||||
- Config is a caller-owned immutable snapshot for the duration of a call.
|
||||
@@ -0,0 +1,90 @@
|
||||
// Package githubmirror rewrites GitHub URLs and executes mirrored operations.
|
||||
package githubmirror
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/url"
|
||||
"strings"
|
||||
)
|
||||
|
||||
type Mirror struct {
|
||||
Type string `json:"type"`
|
||||
URL string `json:"url"`
|
||||
IsBuiltIn bool `json:"isBuiltIn"`
|
||||
IsDeleted bool `json:"isDeleted"`
|
||||
}
|
||||
type Config struct {
|
||||
Enabled bool `json:"enabled"`
|
||||
Mirrors []Mirror `json:"mirrors"`
|
||||
}
|
||||
|
||||
func DefaultConfig() *Config {
|
||||
return &Config{Enabled: true, Mirrors: []Mirror{
|
||||
{Type: "jsdelivr", URL: "https://fastly.jsdelivr.net/gh", IsBuiltIn: true},
|
||||
{Type: "ghProxy", URL: "https://fastgit.cc", IsBuiltIn: true},
|
||||
{Type: "ghProxy", URL: "https://gh-proxy.com", IsBuiltIn: true},
|
||||
{Type: "ghProxy", URL: "https://ghfast.top", IsBuiltIn: true},
|
||||
}}
|
||||
}
|
||||
func (c *Config) Validate() error {
|
||||
if len(c.Mirrors) > 32 {
|
||||
return fmt.Errorf("too many GitHub mirrors")
|
||||
}
|
||||
for _, mirror := range c.Mirrors {
|
||||
u, err := url.Parse(mirror.URL)
|
||||
if err != nil || u == nil || (u.Scheme != "http" && u.Scheme != "https") || u.Host == "" || u.User != nil || u.RawQuery != "" || u.Fragment != "" {
|
||||
return fmt.Errorf("invalid GitHub mirror URL")
|
||||
}
|
||||
if mirror.Type != "jsdelivr" && mirror.Type != "ghProxy" {
|
||||
return fmt.Errorf("invalid GitHub mirror type: %s", mirror.Type)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Kind describes what a candidate endpoint must support.
|
||||
type Kind uint8
|
||||
|
||||
const (
|
||||
Resource Kind = iota
|
||||
Repository
|
||||
)
|
||||
|
||||
// Candidates preserves the official URL first. jsDelivr is only eligible for
|
||||
// raw source resources; Git operations use mirrors supporting Git Smart HTTP.
|
||||
// GitHub API URLs are never mirrored.
|
||||
func Candidates(c *Config, raw string, kind Kind) []string {
|
||||
result := []string{raw}
|
||||
u, err := url.Parse(raw)
|
||||
if err != nil || c == nil || !c.Enabled || u.User != nil || (u.Scheme != "https" && u.Scheme != "http") {
|
||||
return result
|
||||
}
|
||||
if u.Host != "github.com" && u.Host != "raw.githubusercontent.com" {
|
||||
return result
|
||||
}
|
||||
seen := map[string]bool{raw: true}
|
||||
for _, mirror := range c.Mirrors {
|
||||
if mirror.IsDeleted {
|
||||
continue
|
||||
}
|
||||
var candidate string
|
||||
switch mirror.Type {
|
||||
case "ghProxy":
|
||||
candidate = strings.TrimRight(mirror.URL, "/") + "/" + raw
|
||||
case "jsdelivr":
|
||||
if kind == Repository || u.Host != "raw.githubusercontent.com" {
|
||||
continue
|
||||
}
|
||||
parts := strings.SplitN(strings.TrimPrefix(u.EscapedPath(), "/"), "/", 4)
|
||||
if len(parts) != 4 {
|
||||
continue
|
||||
}
|
||||
candidate = strings.TrimRight(mirror.URL, "/") + "/" + parts[0] + "/" + parts[1] + "@" + parts[2] + "/" + parts[3]
|
||||
}
|
||||
if candidate != "" && !seen[candidate] {
|
||||
result = append(result, candidate)
|
||||
seen[candidate] = true
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
package githubmirror
|
||||
|
||||
import (
|
||||
"reflect"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestMirrorCandidates(t *testing.T) {
|
||||
cfg := &Config{Enabled: true, Mirrors: []Mirror{
|
||||
{Type: "jsdelivr", URL: "https://cdn.example/gh/"},
|
||||
{Type: "ghProxy", URL: "https://mirror.example/"},
|
||||
{Type: "ghProxy", URL: "https://deleted.example", IsDeleted: true},
|
||||
}}
|
||||
source := "https://raw.githubusercontent.com/owner/repo/main/a.txt"
|
||||
want := []string{source, "https://cdn.example/gh/owner/repo@main/a.txt", "https://mirror.example/" + source}
|
||||
if got := Candidates(cfg, source, Resource); !reflect.DeepEqual(got, want) {
|
||||
t.Fatalf("got %v", got)
|
||||
}
|
||||
repo := "https://github.com/owner/repo.git"
|
||||
if got := Candidates(cfg, repo, Repository); !reflect.DeepEqual(got, []string{repo, "https://mirror.example/" + repo}) {
|
||||
t.Fatalf("git candidates %v", got)
|
||||
}
|
||||
for _, raw := range []string{"https://github.com.evil.example/a", "https://example.com/a", "https://user:[email protected]/a"} {
|
||||
if got := Candidates(cfg, raw, Resource); !reflect.DeepEqual(got, []string{raw}) {
|
||||
t.Fatalf("unexpected mirror: %v", got)
|
||||
}
|
||||
}
|
||||
apiURL := "https://api.github.com/repos/GopeedLab/gopeed/releases?per_page=10"
|
||||
for _, kind := range []Kind{Resource, Repository} {
|
||||
if got := Candidates(cfg, apiURL, kind); !reflect.DeepEqual(got, []string{apiURL}) {
|
||||
t.Fatalf("GitHub API must never use mirrors: %v", got)
|
||||
}
|
||||
}
|
||||
cfg.Enabled = false
|
||||
if got := Candidates(cfg, source, Resource); !reflect.DeepEqual(got, []string{source}) {
|
||||
t.Fatalf("disabled mirror: %v", got)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,160 @@
|
||||
package githubmirror
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Options configures execution, not URL persistence. Discard releases results
|
||||
// from failed attempts and successful race losers, including partial results
|
||||
// returned together with an error. The caller owns the winning result.
|
||||
type Options[T any] struct {
|
||||
Kind Kind
|
||||
// Timeout limits each attempt. Zero uses 30 seconds for resources and no
|
||||
// overall deadline for repositories, whose transport owns idle timeouts.
|
||||
Timeout time.Duration
|
||||
Discard func(T)
|
||||
}
|
||||
|
||||
// Attempt must honor ctx and return a completed, validated result. Its context
|
||||
// is canceled after it returns, so live response streams must not be returned.
|
||||
// Return any partially acquired resources along with errors so Discard can
|
||||
// release them. Failed attempts must not mutate persistent business state.
|
||||
type Attempt[T any] func(context.Context, string) (T, error)
|
||||
|
||||
const defaultTimeout = 30 * time.Second
|
||||
|
||||
func prepare[T any](ctx context.Context, config *Config, raw string, options Options[T], attempt Attempt[T]) ([]string, time.Duration, error) {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
if raw == "" {
|
||||
return nil, 0, errors.New("empty GitHub source URL")
|
||||
}
|
||||
if options.Kind != Resource && options.Kind != Repository {
|
||||
return nil, 0, errors.New("invalid GitHub resource kind")
|
||||
}
|
||||
if options.Timeout < 0 {
|
||||
return nil, 0, errors.New("invalid GitHub attempt timeout")
|
||||
}
|
||||
if attempt == nil {
|
||||
return nil, 0, errors.New("GitHub mirror attempt is required")
|
||||
}
|
||||
if config != nil {
|
||||
if err := config.Validate(); err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
}
|
||||
timeout := options.Timeout
|
||||
if timeout == 0 && options.Kind == Resource {
|
||||
timeout = defaultTimeout
|
||||
}
|
||||
return Candidates(config, raw, options.Kind), timeout, nil
|
||||
}
|
||||
func invoke[T any](ctx context.Context, timeout time.Duration, candidate string, attempt Attempt[T]) (value T, err error) {
|
||||
var cancel context.CancelFunc
|
||||
if timeout > 0 {
|
||||
ctx, cancel = context.WithTimeout(ctx, timeout)
|
||||
} else {
|
||||
ctx, cancel = context.WithCancel(ctx)
|
||||
}
|
||||
defer cancel()
|
||||
defer func() {
|
||||
if recovered := recover(); recovered != nil {
|
||||
err = fmt.Errorf("GitHub mirror attempt panicked: %v", recovered)
|
||||
}
|
||||
}()
|
||||
value, err = attempt(ctx, candidate)
|
||||
if err == nil {
|
||||
err = ctx.Err()
|
||||
}
|
||||
return
|
||||
}
|
||||
func discard[T any](options Options[T], value T) {
|
||||
if options.Discard != nil {
|
||||
options.Discard(value)
|
||||
}
|
||||
}
|
||||
|
||||
// Race executes the official URL and eligible mirrors concurrently. The first
|
||||
// completed successful result wins; losing attempts are canceled and drained
|
||||
// before return so no temporary files or other resources outlive the call.
|
||||
// HTTP status/content validation is the attempt's responsibility.
|
||||
func Race[T any](ctx context.Context, config *Config, raw string, options Options[T], attempt Attempt[T]) (T, error) {
|
||||
var zero T
|
||||
candidates, timeout, err := prepare(ctx, config, raw, options, attempt)
|
||||
if err != nil {
|
||||
return zero, err
|
||||
}
|
||||
raceCtx, cancel := context.WithCancel(ctx)
|
||||
defer cancel()
|
||||
type outcome struct {
|
||||
index int
|
||||
value T
|
||||
err error
|
||||
}
|
||||
outcomes := make(chan outcome, len(candidates))
|
||||
for index, candidate := range candidates {
|
||||
go func(index int, candidate string) {
|
||||
value, err := invoke(raceCtx, timeout, candidate, attempt)
|
||||
outcomes <- outcome{index, value, err}
|
||||
}(index, candidate)
|
||||
}
|
||||
var winner T
|
||||
won := false
|
||||
failures := make([]error, len(candidates))
|
||||
for range candidates {
|
||||
outcome := <-outcomes
|
||||
if !won && outcome.err == nil && ctx.Err() == nil {
|
||||
winner = outcome.value
|
||||
won = true
|
||||
cancel()
|
||||
} else {
|
||||
discard(options, outcome.value)
|
||||
if outcome.err != nil {
|
||||
failures[outcome.index] = fmt.Errorf("GitHub source %d: %w", outcome.index+1, outcome.err)
|
||||
}
|
||||
if ctx.Err() != nil {
|
||||
cancel()
|
||||
}
|
||||
}
|
||||
}
|
||||
if err := ctx.Err(); err != nil {
|
||||
if won {
|
||||
discard(options, winner)
|
||||
}
|
||||
return zero, err
|
||||
}
|
||||
if won {
|
||||
return winner, nil
|
||||
}
|
||||
return zero, fmt.Errorf("all GitHub sources failed: %w", errors.Join(failures...))
|
||||
}
|
||||
|
||||
// Fallback executes the official URL first, then eligible mirrors in their
|
||||
// configured order. It stops immediately on success and cleans every failure.
|
||||
func Fallback[T any](ctx context.Context, config *Config, raw string, options Options[T], attempt Attempt[T]) (T, error) {
|
||||
var zero T
|
||||
candidates, timeout, err := prepare(ctx, config, raw, options, attempt)
|
||||
if err != nil {
|
||||
return zero, err
|
||||
}
|
||||
failures := make([]error, 0, len(candidates))
|
||||
for index, candidate := range candidates {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return zero, err
|
||||
}
|
||||
value, err := invoke(ctx, timeout, candidate, attempt)
|
||||
if err == nil && ctx.Err() == nil {
|
||||
return value, nil
|
||||
}
|
||||
discard(options, value)
|
||||
if parentErr := ctx.Err(); parentErr != nil {
|
||||
return zero, parentErr
|
||||
}
|
||||
failures = append(failures, fmt.Errorf("GitHub source %d: %w", index+1, err))
|
||||
}
|
||||
return zero, fmt.Errorf("all GitHub sources failed: %w", errors.Join(failures...))
|
||||
}
|
||||
@@ -0,0 +1,214 @@
|
||||
package githubmirror
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"reflect"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
const testSource = "https://github.com/owner/repo.git"
|
||||
|
||||
func testConfig() *Config {
|
||||
return &Config{Enabled: true, Mirrors: []Mirror{
|
||||
{Type: "ghProxy", URL: "https://first.example"},
|
||||
{Type: "ghProxy", URL: "https://second.example"},
|
||||
}}
|
||||
}
|
||||
func TestRaceWinsAndDrainsOwnedResults(t *testing.T) {
|
||||
config := testConfig()
|
||||
candidates := Candidates(config, testSource, Resource)
|
||||
var started sync.WaitGroup
|
||||
started.Add(len(candidates))
|
||||
discarded := []string{}
|
||||
winner, err := Race(context.Background(), config, testSource, Options[string]{
|
||||
Discard: func(value string) { discarded = append(discarded, value) },
|
||||
}, func(ctx context.Context, address string) (string, error) {
|
||||
started.Done()
|
||||
started.Wait()
|
||||
switch address {
|
||||
case candidates[0]:
|
||||
return address, errors.New("official offline")
|
||||
case candidates[1]:
|
||||
return address, nil
|
||||
default:
|
||||
<-ctx.Done()
|
||||
return address, ctx.Err()
|
||||
}
|
||||
})
|
||||
if err != nil || winner != candidates[1] {
|
||||
t.Fatalf("winner=%s err=%v", winner, err)
|
||||
}
|
||||
if len(discarded) != 2 || !contains(discarded, candidates[0]) || !contains(discarded, candidates[2]) {
|
||||
t.Fatalf("discarded %v", discarded)
|
||||
}
|
||||
}
|
||||
func contains(values []string, value string) bool {
|
||||
for _, item := range values {
|
||||
if item == value {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
func TestRaceCleansSimultaneousSuccessfulLosers(t *testing.T) {
|
||||
config := testConfig()
|
||||
var started sync.WaitGroup
|
||||
started.Add(3)
|
||||
cleaned := []string{}
|
||||
winner, err := Race(context.Background(), config, testSource, Options[string]{Discard: func(value string) { cleaned = append(cleaned, value) }}, func(_ context.Context, address string) (string, error) {
|
||||
started.Done()
|
||||
started.Wait()
|
||||
return address, nil
|
||||
})
|
||||
if err != nil || winner == "" || len(cleaned) != 2 || contains(cleaned, winner) {
|
||||
t.Fatalf("winner=%s discarded=%v err=%v", winner, cleaned, err)
|
||||
}
|
||||
}
|
||||
func TestRaceParentCancellationCleansWinnerToo(t *testing.T) {
|
||||
config := testConfig()
|
||||
config.Mirrors = config.Mirrors[:1]
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
cleaned := []string{}
|
||||
winner, err := Race(ctx, config, testSource, Options[string]{Discard: func(value string) { cleaned = append(cleaned, value) }}, func(ctx context.Context, address string) (string, error) {
|
||||
if address == testSource {
|
||||
return address, nil
|
||||
}
|
||||
<-ctx.Done()
|
||||
cancel()
|
||||
return address, ctx.Err()
|
||||
})
|
||||
if !errors.Is(err, context.Canceled) || winner != "" || len(cleaned) != 2 || !contains(cleaned, testSource) {
|
||||
t.Fatalf("winner=%s cleanup=%v err=%v", winner, cleaned, err)
|
||||
}
|
||||
}
|
||||
func TestRaceTimeoutAndErrorAggregation(t *testing.T) {
|
||||
expected := errors.New("offline")
|
||||
config := testConfig()
|
||||
config.Mirrors = config.Mirrors[:1]
|
||||
discarded := 0
|
||||
_, err := Race(context.Background(), config, testSource, Options[string]{Timeout: 10 * time.Millisecond, Discard: func(string) { discarded++ }}, func(ctx context.Context, address string) (string, error) {
|
||||
if address == testSource {
|
||||
return address, expected
|
||||
}
|
||||
<-ctx.Done()
|
||||
return address, ctx.Err()
|
||||
})
|
||||
if !errors.Is(err, expected) || !errors.Is(err, context.DeadlineExceeded) || discarded != 2 {
|
||||
t.Fatalf("discarded=%d err=%v", discarded, err)
|
||||
}
|
||||
}
|
||||
func TestFallbackPreservesOrderAndStopsOnSuccess(t *testing.T) {
|
||||
config := testConfig()
|
||||
candidates := Candidates(config, testSource, Repository)
|
||||
tried := []string{}
|
||||
cleaned := []string{}
|
||||
winner, err := Fallback(context.Background(), config, testSource, Options[string]{Kind: Repository, Discard: func(value string) { cleaned = append(cleaned, value) }}, func(_ context.Context, address string) (string, error) {
|
||||
tried = append(tried, address)
|
||||
if address == candidates[1] {
|
||||
return address, nil
|
||||
}
|
||||
return address, errors.New("offline")
|
||||
})
|
||||
if err != nil || winner != candidates[1] || !reflect.DeepEqual(tried, candidates[:2]) || !reflect.DeepEqual(cleaned, candidates[:1]) {
|
||||
t.Fatalf("winner=%s tried=%v discarded=%v err=%v", winner, tried, cleaned, err)
|
||||
}
|
||||
tried = nil
|
||||
_, err = Fallback(context.Background(), config, testSource, Options[string]{Kind: Repository}, func(_ context.Context, address string) (string, error) {
|
||||
tried = append(tried, address)
|
||||
return address, nil
|
||||
})
|
||||
if err != nil || !reflect.DeepEqual(tried, candidates[:1]) {
|
||||
t.Fatalf("tried=%v err=%v", tried, err)
|
||||
}
|
||||
}
|
||||
func TestFallbackTimeoutContinuesWithFreshContext(t *testing.T) {
|
||||
discarded := []string{}
|
||||
winner, err := Fallback(context.Background(), testConfig(), testSource, Options[string]{Kind: Repository, Timeout: 10 * time.Millisecond, Discard: func(value string) { discarded = append(discarded, value) }}, func(ctx context.Context, address string) (string, error) {
|
||||
if address == testSource {
|
||||
<-ctx.Done()
|
||||
return address, ctx.Err()
|
||||
}
|
||||
if err := ctx.Err(); err != nil {
|
||||
return address, err
|
||||
}
|
||||
return address, nil
|
||||
})
|
||||
if err != nil || !strings.HasPrefix(winner, "https://first.example/") || !reflect.DeepEqual(discarded, []string{testSource}) {
|
||||
t.Fatalf("winner=%s cleanup=%v err=%v", winner, discarded, err)
|
||||
}
|
||||
}
|
||||
func TestFallbackCancellationStopsBeforeNextMirror(t *testing.T) {
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
calls := 0
|
||||
cleaned := 0
|
||||
_, err := Fallback(ctx, testConfig(), testSource, Options[string]{Kind: Repository, Discard: func(string) { cleaned++ }}, func(ctx context.Context, address string) (string, error) {
|
||||
calls++
|
||||
cancel()
|
||||
return address, ctx.Err()
|
||||
})
|
||||
if !errors.Is(err, context.Canceled) || calls != 1 || cleaned != 1 {
|
||||
t.Fatalf("calls=%d discarded=%d err=%v", calls, cleaned, err)
|
||||
}
|
||||
}
|
||||
func TestExecutorsDoNotStartInvalidOrCanceledRequests(t *testing.T) {
|
||||
canceled, cancel := context.WithCancel(context.Background())
|
||||
cancel()
|
||||
for _, test := range []struct {
|
||||
ctx context.Context
|
||||
config *Config
|
||||
options Options[string]
|
||||
attempt Attempt[string]
|
||||
}{
|
||||
{ctx: canceled, options: Options[string]{}, attempt: func(context.Context, string) (string, error) { t.Fatal("called after cancellation"); return "", nil }},
|
||||
{ctx: context.Background(), options: Options[string]{Kind: 99}, attempt: func(context.Context, string) (string, error) { t.Fatal("invalid kind"); return "", nil }},
|
||||
{ctx: context.Background(), options: Options[string]{Timeout: -time.Second}, attempt: func(context.Context, string) (string, error) { t.Fatal("invalid timeout"); return "", nil }},
|
||||
{ctx: context.Background(), options: Options[string]{}, attempt: nil},
|
||||
{ctx: context.Background(), config: &Config{Mirrors: []Mirror{{Type: "invalid", URL: "https://mirror.example"}}}, attempt: func(context.Context, string) (string, error) { t.Fatal("invalid config"); return "", nil }},
|
||||
} {
|
||||
if _, err := Race(test.ctx, test.config, testSource, test.options, test.attempt); err == nil {
|
||||
t.Fatal("invalid race accepted")
|
||||
}
|
||||
if _, err := Fallback(test.ctx, test.config, testSource, test.options, test.attempt); err == nil {
|
||||
t.Fatal("invalid fallback accepted")
|
||||
}
|
||||
}
|
||||
}
|
||||
func TestAttemptPanicBecomesError(t *testing.T) {
|
||||
attempt := func(context.Context, string) (string, error) { panic("broken operation") }
|
||||
if _, err := Race(context.Background(), nil, testSource, Options[string]{}, attempt); err == nil || !strings.Contains(err.Error(), "broken operation") {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := Fallback(context.Background(), nil, testSource, Options[string]{}, attempt); err == nil || !strings.Contains(err.Error(), "broken operation") {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
func TestNonGithubResourcesAndDisabledMirrorsUseOnlyOriginal(t *testing.T) {
|
||||
for _, test := range []struct {
|
||||
config *Config
|
||||
raw string
|
||||
}{{testConfig(), "https://example.com/resource"}, {&Config{Enabled: false, Mirrors: testConfig().Mirrors}, testSource}} {
|
||||
calls := 0
|
||||
got, err := Race(context.Background(), test.config, test.raw, Options[string]{}, func(_ context.Context, address string) (string, error) { calls++; return address, nil })
|
||||
if err != nil || got != test.raw || calls != 1 {
|
||||
t.Fatalf("got=%s calls=%d err=%v", got, calls, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestRepositoryAttemptsHaveNoDefaultDeadline(t *testing.T) {
|
||||
value, err := Fallback(context.Background(), nil, testSource, Options[string]{Kind: Repository}, func(ctx context.Context, raw string) (string, error) {
|
||||
if _, ok := ctx.Deadline(); ok {
|
||||
t.Fatal("repository attempt has a total deadline")
|
||||
}
|
||||
return raw, nil
|
||||
})
|
||||
if err != nil || value != testSource {
|
||||
t.Fatalf("value=%s error=%v", value, err)
|
||||
}
|
||||
}
|
||||
@@ -1,12 +1,37 @@
|
||||
package bt
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"github.com/GopeedLab/gopeed/internal/fetcher"
|
||||
)
|
||||
|
||||
type trackerSubscriptionStatus struct {
|
||||
Success bool `json:"success"`
|
||||
TrackerCount int `json:"trackerCount"`
|
||||
LastFetchTime time.Time `json:"lastFetchTime"`
|
||||
}
|
||||
|
||||
type state struct {
|
||||
TrackerSubscriptionStatuses map[string]trackerSubscriptionStatus `json:"trackerSubscriptionStatuses"`
|
||||
TrackerSources map[string][]string `json:"trackerSources"`
|
||||
LastTrackerUpdateTime time.Time `json:"lastTrackerUpdateTime"`
|
||||
}
|
||||
|
||||
type config struct {
|
||||
ListenPort int `json:"listenPort"`
|
||||
Trackers []string `json:"trackers"`
|
||||
fetcher.ConfigState[state]
|
||||
TrackerSubscribeURLs []string `json:"trackerSubscribeUrls"`
|
||||
AutoUpdateTrackers bool `json:"autoUpdateTrackers"`
|
||||
CustomTrackers []string `json:"customTrackers"`
|
||||
ListenPort int `json:"listenPort"`
|
||||
// SeedKeep is always keep seeding after downloading is complete, unless manually stopped.
|
||||
SeedKeep bool `json:"seedKeep"`
|
||||
// SeedRatio is the ratio of uploaded data to downloaded data to seed.
|
||||
SeedRatio float64 `json:"seedRatio"`
|
||||
// SeedTime is the time in seconds to seed after downloading is complete.
|
||||
SeedTime int64 `json:"seedTime"`
|
||||
// DisableDHT and DisablePEX turn off DHT and PEX peer discovery. Like ListenPort,
|
||||
// they are read when the shared client is built.
|
||||
DisableDHT bool `json:"disableDht"`
|
||||
DisablePEX bool `json:"disablePex"`
|
||||
}
|
||||
@@ -0,0 +1,464 @@
|
||||
package bt
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/GopeedLab/gopeed/internal/controller"
|
||||
"github.com/GopeedLab/gopeed/internal/fetcher"
|
||||
"github.com/GopeedLab/gopeed/pkg/base"
|
||||
btprotocol "github.com/GopeedLab/gopeed/pkg/protocol/bt"
|
||||
"github.com/anacrolix/torrent"
|
||||
pp "github.com/anacrolix/torrent/peer_protocol"
|
||||
"golang.org/x/time/rate"
|
||||
)
|
||||
|
||||
type btDownloadHarness struct {
|
||||
*Fetcher
|
||||
pendingRequests atomic.Int64
|
||||
}
|
||||
|
||||
// These tests exercise the BT fetcher, native storage and real peer protocol.
|
||||
// All content is generated locally and served by a loopback seeder.
|
||||
func btDownloadFetcher(t *testing.T, f fetcher.Fetcher) *btDownloadHarness {
|
||||
t.Helper()
|
||||
if f == nil {
|
||||
f = new(FetcherManager).Build()
|
||||
}
|
||||
ctl := controller.NewController()
|
||||
ctl.GetConfig = func(v any) {
|
||||
encoded, err := json.Marshal(new(FetcherManager).DefaultConfig())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := json.Unmarshal(encoded, v); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
f.Setup(ctl)
|
||||
t.Cleanup(func() {
|
||||
if err := f.Close(); err != nil {
|
||||
t.Error(err)
|
||||
}
|
||||
if err := closeClient(); err != nil {
|
||||
t.Error(err)
|
||||
}
|
||||
})
|
||||
h := &btDownloadHarness{Fetcher: f.(*Fetcher)}
|
||||
// Keep the test client on loopback and observe real cancellation replies.
|
||||
// Waiting for them avoids the pinned library's immediate cancel/resume
|
||||
// request scheduling bug, without relying on arbitrary sleep durations.
|
||||
cfg = torrent.TestingConfig(t)
|
||||
cfg.DisableTrackers = false
|
||||
cfg.Seed = true
|
||||
cfg.DisableUTP = true
|
||||
cfg.DisableIPv6 = true
|
||||
cfg.DisableWebtorrent = true
|
||||
cfg.KeepAliveTimeout = time.Minute
|
||||
cfg.MaxAllocPeerRequestDataPerConn = 1 << 20
|
||||
cfg.Callbacks.SentRequest = []func(torrent.PeerRequestEvent){func(torrent.PeerRequestEvent) {
|
||||
h.pendingRequests.Add(1)
|
||||
}}
|
||||
cfg.Callbacks.ReadMessage = func(_ *torrent.PeerConn, msg *pp.Message) {
|
||||
if !msg.Keepalive && (msg.Type == pp.Piece || msg.Type == pp.Reject) {
|
||||
h.pendingRequests.Add(-1)
|
||||
}
|
||||
}
|
||||
var err error
|
||||
client, err = torrent.NewClient(cfg)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return h
|
||||
}
|
||||
|
||||
func btResolve(t *testing.T, f *btDownloadHarness, source string) {
|
||||
t.Helper()
|
||||
if err := f.Resolve(&base.Request{URL: source}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func btStart(t *testing.T, f *btDownloadHarness) {
|
||||
t.Helper()
|
||||
if err := f.Start(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func btAwaitDone(t *testing.T, f *btDownloadHarness) {
|
||||
t.Helper()
|
||||
done := make(chan error, 1)
|
||||
go func() { done <- f.Wait() }()
|
||||
timeout := time.NewTimer(20 * time.Second)
|
||||
defer timeout.Stop()
|
||||
select {
|
||||
case err := <-done:
|
||||
if err != nil {
|
||||
t.Fatalf("BT download failed: %v", err)
|
||||
}
|
||||
if !f.isDone() {
|
||||
t.Fatal("Wait returned before selected files were verified")
|
||||
}
|
||||
case <-timeout.C:
|
||||
var status strings.Builder
|
||||
client.WriteStatus(&status)
|
||||
t.Log(status.String())
|
||||
t.Fatalf("BT download did not finish: progress=%v stats=%+v pieces=%+v peers=%+v", f.Progress(), f.torrent.Stats(), f.torrent.PieceStateRuns(), f.Stats().Runtime)
|
||||
}
|
||||
}
|
||||
|
||||
func btAwaitPartial(t *testing.T, f *btDownloadHarness) {
|
||||
t.Helper()
|
||||
timeout := time.NewTimer(15 * time.Second)
|
||||
defer timeout.Stop()
|
||||
ticker := time.NewTicker(20 * time.Millisecond)
|
||||
defer ticker.Stop()
|
||||
for {
|
||||
select {
|
||||
case <-ticker.C:
|
||||
progress := f.Progress().TotalDownloaded()
|
||||
if progress >= 32*1024 && progress < f.Meta().Res.Size {
|
||||
return
|
||||
}
|
||||
case <-timeout.C:
|
||||
t.Fatal("BT transfer did not reach partial progress")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func btPause(t *testing.T, f *btDownloadHarness) {
|
||||
t.Helper()
|
||||
if err := f.Pause(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
timeout := time.NewTimer(5 * time.Second)
|
||||
defer timeout.Stop()
|
||||
ticker := time.NewTicker(10 * time.Millisecond)
|
||||
defer ticker.Stop()
|
||||
for {
|
||||
if f.pendingRequests.Load() == 0 {
|
||||
// The message callback runs under the client lock. Acquiring that
|
||||
// lock ensures the final cancellation reply has finished processing.
|
||||
client.Stats()
|
||||
return
|
||||
}
|
||||
select {
|
||||
case <-ticker.C:
|
||||
case <-timeout.C:
|
||||
t.Fatal("peer did not acknowledge cancelled requests")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestBTDownloadSources(t *testing.T) {
|
||||
for _, kind := range []string{"magnet", "torrent", "file URI", "data URI"} {
|
||||
t.Run(kind, func(t *testing.T) {
|
||||
fixture := newBTFixture(t, map[string][]byte{"": btPayload(96*1024, kind)}, 0)
|
||||
source := fixture.Magnet
|
||||
switch kind {
|
||||
case "torrent":
|
||||
source = fixture.TorrentFile
|
||||
case "file URI":
|
||||
source = fixture.FileURI()
|
||||
case "data URI":
|
||||
source = fixture.DataURI
|
||||
}
|
||||
f := btDownloadFetcher(t, nil)
|
||||
btResolve(t, f, source)
|
||||
if f.Meta().Opts != nil {
|
||||
t.Fatal("Resolve assigned download options")
|
||||
}
|
||||
f.storage.mu.RLock()
|
||||
bound := f.storage.owner != nil
|
||||
f.storage.mu.RUnlock()
|
||||
if bound {
|
||||
t.Fatal("Resolve bound download storage")
|
||||
}
|
||||
resolvedTorrent := f.torrent
|
||||
dir := filepath.Join(t.TempDir(), "chosen-after-resolve")
|
||||
f.Meta().Opts = &base.Options{Path: dir}
|
||||
if _, err := os.Stat(dir); !os.IsNotExist(err) {
|
||||
t.Fatalf("output directory exists before Start: %v", err)
|
||||
}
|
||||
btStart(t, f)
|
||||
if f.torrent != resolvedTorrent {
|
||||
t.Fatal("Start replaced the resolved torrent")
|
||||
}
|
||||
btAwaitDone(t, f)
|
||||
fixture.AssertFiles(t, dir)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestBTDirectDownloadWithExtraTrackers(t *testing.T) {
|
||||
for _, source := range []string{"request", "config", "subscription"} {
|
||||
t.Run(source, func(t *testing.T) {
|
||||
fixture := makeBTFixture(t, map[string][]byte{
|
||||
"a.bin": btPayload(20*1024, "direct-a"),
|
||||
"nested/b.bin": btPayload(48*1024, "direct-b"),
|
||||
}, 0, false, false)
|
||||
magnet, err := url.Parse(fixture.Magnet)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
query := magnet.Query()
|
||||
query.Del("tr")
|
||||
query.Del("x.pe")
|
||||
magnet.RawQuery = query.Encode()
|
||||
req := &base.Request{URL: magnet.String()}
|
||||
f := btDownloadFetcher(t, nil)
|
||||
if source == "request" {
|
||||
req.Extra = &btprotocol.ReqExtra{Trackers: []string{fixture.TrackerURL}}
|
||||
} else {
|
||||
backendConfig := new(FetcherManager).DefaultConfig().(*config)
|
||||
if source == "config" {
|
||||
backendConfig.CustomTrackers = []string{fixture.TrackerURL}
|
||||
} else {
|
||||
backendConfig.TrackerSubscribeURLs = []string{"https://subscription.example/list"}
|
||||
backendConfig.State.TrackerSources[backendConfig.TrackerSubscribeURLs[0]] = []string{fixture.TrackerURL}
|
||||
}
|
||||
f.ctl.GetConfig = func(dst any) {
|
||||
raw, err := json.Marshal(backendConfig)
|
||||
if err != nil {
|
||||
t.Error(err)
|
||||
return
|
||||
}
|
||||
if err := json.Unmarshal(raw, dst); err != nil {
|
||||
t.Error(err)
|
||||
}
|
||||
}
|
||||
}
|
||||
// CreateDirect supplies options before Downloader resolves the task.
|
||||
dir := filepath.Join(t.TempDir(), "direct-download")
|
||||
opts := &base.Options{Path: dir, SelectFiles: []int{}}
|
||||
f.Meta().Opts = opts
|
||||
resolved := make(chan error, 1)
|
||||
go func() { resolved <- f.Resolve(req) }()
|
||||
select {
|
||||
case err := <-resolved:
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
case <-time.After(5 * time.Second):
|
||||
f.torrentDropFunc()
|
||||
<-resolved
|
||||
t.Fatal("direct magnet download did not obtain metadata from its extra tracker")
|
||||
}
|
||||
if f.Meta().Opts != opts || len(opts.SelectFiles) != len(fixture.Files) {
|
||||
t.Fatal("resolution lost direct download options or did not select all files")
|
||||
}
|
||||
btStart(t, f)
|
||||
btAwaitDone(t, f)
|
||||
fixture.AssertFiles(t, dir)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestBTDownloadSelectedFiles(t *testing.T) {
|
||||
fixture := newBTFixture(t, map[string][]byte{
|
||||
"a.bin": btPayload(20*1024, "a"), "nested/b.bin": btPayload(48*1024, "b"),
|
||||
}, 0)
|
||||
f := btDownloadFetcher(t, nil)
|
||||
dir := t.TempDir()
|
||||
btResolve(t, f, fixture.Magnet)
|
||||
selected := -1
|
||||
for i, file := range f.Meta().Res.Files {
|
||||
if file.Name == "b.bin" {
|
||||
selected = i
|
||||
}
|
||||
}
|
||||
if selected < 0 {
|
||||
t.Fatal("selected file absent from metadata")
|
||||
}
|
||||
f.Meta().Opts = &base.Options{Path: dir}
|
||||
if err := f.Patch(nil, &base.Options{SelectFiles: []int{selected}}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
btStart(t, f)
|
||||
btAwaitDone(t, f)
|
||||
fixture.AssertFiles(t, dir, filepath.Join("bundle", "nested", "b.bin"))
|
||||
if _, err := os.Stat(filepath.Join(dir, "bundle", "a.bin")); !os.IsNotExist(err) {
|
||||
t.Fatalf("unselected output remains: %v", err)
|
||||
}
|
||||
if f.Meta().Res.Size != 48*1024 {
|
||||
t.Fatal("selected resource size is incorrect")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBTPauseAndResume(t *testing.T) {
|
||||
fixture := newBTFixture(t, map[string][]byte{"": btPayload(512*1024, "pause")}, 64*1024)
|
||||
f := btDownloadFetcher(t, nil)
|
||||
dir := t.TempDir()
|
||||
btResolve(t, f, fixture.Magnet)
|
||||
f.Meta().Opts = &base.Options{Path: dir}
|
||||
btStart(t, f)
|
||||
btAwaitPartial(t, f)
|
||||
btPause(t, f)
|
||||
resolvedTorrent := f.torrent
|
||||
fixture.Limiter.SetLimit(rate.Inf)
|
||||
// Once cancelled requests have settled, no more data should arrive.
|
||||
stats := f.torrent.Stats()
|
||||
readWhilePaused := stats.BytesReadData.Int64()
|
||||
time.Sleep(200 * time.Millisecond)
|
||||
stats = f.torrent.Stats()
|
||||
if stats.BytesReadData.Int64() != readWhilePaused || f.isDone() {
|
||||
t.Fatal("data download continued while paused")
|
||||
}
|
||||
btStart(t, f)
|
||||
if f.torrent != resolvedTorrent {
|
||||
t.Fatal("resume replaced the resolved torrent")
|
||||
}
|
||||
btAwaitDone(t, f)
|
||||
fixture.AssertFiles(t, dir)
|
||||
}
|
||||
|
||||
type btFetcherCheckpoint struct {
|
||||
Meta *fetcher.FetcherMeta `json:"meta"`
|
||||
Data json.RawMessage `json:"data"`
|
||||
}
|
||||
|
||||
func btStore(t *testing.T, f *btDownloadHarness) []byte {
|
||||
t.Helper()
|
||||
data, err := new(FetcherManager).Store(f.Fetcher)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
encodedData, err := json.Marshal(data)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
encoded, err := json.Marshal(btFetcherCheckpoint{Meta: f.Meta(), Data: encodedData})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return encoded
|
||||
}
|
||||
|
||||
func btRestore(t *testing.T, encoded []byte) *btDownloadHarness {
|
||||
t.Helper()
|
||||
var checkpoint btFetcherCheckpoint
|
||||
if err := json.Unmarshal(encoded, &checkpoint); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
data, restore := new(FetcherManager).Restore()
|
||||
if err := json.Unmarshal(checkpoint.Data, data); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return btDownloadFetcher(t, restore(checkpoint.Meta, data))
|
||||
}
|
||||
|
||||
func btCloseForRestart(t *testing.T, f *btDownloadHarness) {
|
||||
t.Helper()
|
||||
if err := f.Close(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := new(FetcherManager).Close(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBTColdStartResume(t *testing.T) {
|
||||
for _, kind := range []string{"v1", "hybrid"} {
|
||||
t.Run(kind, func(t *testing.T) {
|
||||
var fixture *btFixture
|
||||
if kind == "hybrid" {
|
||||
fixture = newBTHybridFixture(t, btPayload(512*1024, "restart-hybrid"))
|
||||
fixture.Limiter.SetLimit(64 * 1024)
|
||||
} else {
|
||||
fixture = newBTFixture(t, map[string][]byte{"": btPayload(512*1024, "restart")}, 64*1024)
|
||||
}
|
||||
f := btDownloadFetcher(t, nil)
|
||||
dir := t.TempDir()
|
||||
btResolve(t, f, fixture.Magnet)
|
||||
f.Meta().Opts = &base.Options{Path: dir}
|
||||
btStart(t, f)
|
||||
btAwaitPartial(t, f)
|
||||
btPause(t, f)
|
||||
checkpoint := btStore(t, f)
|
||||
hash := f.Meta().Res.Hash
|
||||
btCloseForRestart(t, f)
|
||||
seedStats := fixture.Torrent.Stats()
|
||||
sentBeforeRestart := seedStats.BytesWrittenData.Int64()
|
||||
restored := btRestore(t, checkpoint)
|
||||
if restored.Meta().Opts.Path != dir || restored.Meta().Res.Hash != hash {
|
||||
t.Fatal("download directory or hash was lost")
|
||||
}
|
||||
fixture.Limiter.SetLimit(rate.Inf)
|
||||
btStart(t, restored)
|
||||
btAwaitDone(t, restored)
|
||||
fixture.AssertFiles(t, dir)
|
||||
seedStats = fixture.Torrent.Stats()
|
||||
if sent := seedStats.BytesWrittenData.Int64() - sentBeforeRestart; sent >= 512*1024 {
|
||||
t.Fatalf("restart redownloaded the entire file: %d bytes", sent)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestBTDownloadHybrid(t *testing.T) {
|
||||
fixture := newBTHybridFixture(t, btPayload(80*1024, "hybrid-v2"))
|
||||
f := btDownloadFetcher(t, nil)
|
||||
dir := t.TempDir()
|
||||
btResolve(t, f, fixture.Magnet)
|
||||
f.Meta().Opts = &base.Options{Path: dir}
|
||||
btStart(t, f)
|
||||
btAwaitDone(t, f)
|
||||
fixture.AssertFiles(t, dir)
|
||||
}
|
||||
|
||||
func TestBTRepairCorruptedExistingFile(t *testing.T) {
|
||||
fixture := newBTFixture(t, map[string][]byte{"": btPayload(128*1024, "repair")}, 0)
|
||||
dir := t.TempDir()
|
||||
damaged := append([]byte(nil), fixture.Files["payload.bin"]...)
|
||||
damaged[20000] ^= 0xff
|
||||
if err := os.WriteFile(filepath.Join(dir, "payload.bin"), damaged, 0600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
f := btDownloadFetcher(t, nil)
|
||||
btResolve(t, f, fixture.Magnet)
|
||||
f.Meta().Opts = &base.Options{Path: dir}
|
||||
btStart(t, f)
|
||||
btAwaitDone(t, f)
|
||||
fixture.AssertFiles(t, dir)
|
||||
}
|
||||
|
||||
func TestBTPatchSelectionWhilePaused(t *testing.T) {
|
||||
fixture := newBTFixture(t, map[string][]byte{"a.bin": btPayload(256*1024, "patch-a"), "b.bin": btPayload(256*1024, "patch-b")}, 64*1024)
|
||||
f := btDownloadFetcher(t, nil)
|
||||
dir := t.TempDir()
|
||||
btResolve(t, f, fixture.Magnet)
|
||||
f.Meta().Opts = &base.Options{Path: dir, SelectFiles: []int{0}}
|
||||
btStart(t, f)
|
||||
btAwaitPartial(t, f)
|
||||
btPause(t, f)
|
||||
if err := f.Patch(nil, &base.Options{SelectFiles: []int{1}}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
fixture.Limiter.SetLimit(rate.Inf)
|
||||
btStart(t, f)
|
||||
btAwaitDone(t, f)
|
||||
fixture.AssertFiles(t, dir, filepath.Join("bundle", "b.bin"))
|
||||
for _, suffix := range []string{"", ".part"} {
|
||||
if _, err := os.Stat(filepath.Join(dir, "bundle", "a.bin") + suffix); !os.IsNotExist(err) {
|
||||
t.Fatalf("unselected output remains: %v", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestBTDownloadMultiFileWithEmptyFile(t *testing.T) {
|
||||
fixture := newBTFixture(t, map[string][]byte{"empty.txt": {}, "nested/data.bin": btPayload(48*1024, "empty-file")}, 0)
|
||||
f := btDownloadFetcher(t, nil)
|
||||
dir := t.TempDir()
|
||||
btResolve(t, f, fixture.Magnet)
|
||||
f.Meta().Opts = &base.Options{Path: dir}
|
||||
btStart(t, f)
|
||||
btAwaitDone(t, f)
|
||||
fixture.AssertFiles(t, dir)
|
||||
}
|
||||
+234
-110
@@ -3,11 +3,13 @@ package bt
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strings"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
@@ -33,6 +35,10 @@ var (
|
||||
)
|
||||
|
||||
type Fetcher struct {
|
||||
mu sync.Mutex
|
||||
closeOnce sync.Once
|
||||
storage *deferredFileStorage
|
||||
|
||||
ctl *controller.Controller
|
||||
config *config
|
||||
|
||||
@@ -77,6 +83,8 @@ func (f *Fetcher) initClient() (err error) {
|
||||
cfg.Bep20 = fmt.Sprintf("-GP%s-", parseBep20())
|
||||
cfg.ExtendedHandshakeClientVersion = fmt.Sprintf("Gopeed %s", base.Version)
|
||||
cfg.ListenPort = f.config.ListenPort
|
||||
cfg.NoDHT = f.config.DisableDHT
|
||||
cfg.DisablePEX = f.config.DisablePEX
|
||||
cfg.HTTPProxy = f.ctl.GetProxy(f.meta.Req.Proxy)
|
||||
dnsResolver := &DnsCacheResolver{RefreshTimeout: 5 * time.Minute}
|
||||
cfg.TrackerDialContext = dnsResolver.DialContext
|
||||
@@ -92,13 +100,17 @@ func (f *Fetcher) initClient() (err error) {
|
||||
return
|
||||
}
|
||||
|
||||
func (f *Fetcher) Resolve(req *base.Request, opts *base.Options) error {
|
||||
f.meta.Req = req
|
||||
f.meta.Opts = opts
|
||||
if f.meta.Opts == nil {
|
||||
f.meta.Opts = &base.Options{}
|
||||
func (f *Fetcher) Resolve(req *base.Request) error {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
if f.torrent != nil {
|
||||
f.safeDrop()
|
||||
f.torrent = nil
|
||||
f.storage = nil
|
||||
f.torrentReady.Store(false)
|
||||
}
|
||||
if err := f.addTorrent(req, false); err != nil {
|
||||
f.meta.Req = req
|
||||
if err := f.addTorrent(req); err != nil {
|
||||
return err
|
||||
}
|
||||
f.updateRes()
|
||||
@@ -106,12 +118,72 @@ func (f *Fetcher) Resolve(req *base.Request, opts *base.Options) error {
|
||||
}
|
||||
|
||||
func (f *Fetcher) Start() (err error) {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
if f.meta.Opts == nil || f.meta.Opts.Path == "" {
|
||||
return errors.New("download directory is required when starting a torrent")
|
||||
}
|
||||
if err := f.torrentDropCtx.Err(); err != nil {
|
||||
return err
|
||||
}
|
||||
if !f.torrentReady.Load() {
|
||||
if err = f.addTorrent(f.meta.Req, false); err != nil {
|
||||
if err = f.addTorrent(f.meta.Req); err != nil {
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
path := f.meta.Opts.Path
|
||||
if err := os.MkdirAll(path, 0755); err != nil {
|
||||
return err
|
||||
}
|
||||
hashes, err := f.storage.bind(storage.NewFileClientOpts{
|
||||
ClientBaseDir: path,
|
||||
FilePathMaker: func(opts storage.FilePathMakerOpts) string {
|
||||
_, name := torrentFileLayout(opts.Info, *opts.File)
|
||||
return name
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if hashes != nil {
|
||||
// File storage infers completion from final file names and sizes. Verify
|
||||
// before publishing that state, or peers can see an unverified file as
|
||||
// complete and disconnect before a damaged piece is discovered.
|
||||
// Late v2 hashes schedule their own initial checks through the library.
|
||||
changes := f.torrent.SubscribePieceStateChanges()
|
||||
defer changes.Close()
|
||||
for i, known := range hashes {
|
||||
if !known {
|
||||
continue
|
||||
}
|
||||
if err := f.torrentDropCtx.Err(); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := f.torrent.Piece(i).VerifyDataContext(f.torrentDropCtx); err != nil {
|
||||
return err
|
||||
}
|
||||
// VerifyDataContext can return while the result is still being
|
||||
// written to storage. Wait for it before refreshing completion.
|
||||
for {
|
||||
state := f.torrent.Piece(i).State()
|
||||
if !state.Hashing && !state.QueuedForHash && !state.Marking {
|
||||
break
|
||||
}
|
||||
select {
|
||||
case <-changes.Values:
|
||||
case <-f.torrentDropCtx.Done():
|
||||
return f.torrentDropCtx.Err()
|
||||
case <-f.torrent.Closed():
|
||||
return errors.New("torrent closed during verification")
|
||||
}
|
||||
}
|
||||
}
|
||||
for i := range hashes {
|
||||
f.torrent.Piece(i).UpdateCompletion()
|
||||
}
|
||||
}
|
||||
|
||||
files := f.torrent.Files()
|
||||
// If the user does not specify the file to download, all files will be downloaded by default
|
||||
if f.data.Progress == nil {
|
||||
@@ -132,21 +204,29 @@ func (f *Fetcher) Start() (err error) {
|
||||
}
|
||||
}
|
||||
f.torrent.AllowDataDownload()
|
||||
go f.doUpload(false)
|
||||
return
|
||||
}
|
||||
|
||||
func (f *Fetcher) Pause() (err error) {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
if f.torrent == nil {
|
||||
return nil
|
||||
}
|
||||
f.torrent.DisallowDataDownload()
|
||||
return
|
||||
}
|
||||
|
||||
func (f *Fetcher) Close() (err error) {
|
||||
f.safeDrop()
|
||||
f.torrentDropFunc()
|
||||
f.uploadDoneCh <- nil
|
||||
if len(client.Torrents()) == 0 {
|
||||
err = closeClient()
|
||||
}
|
||||
f.closeOnce.Do(func() {
|
||||
f.torrentDropFunc()
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
f.safeDrop()
|
||||
f.torrentReady.Store(false)
|
||||
close(f.uploadDoneCh)
|
||||
})
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -164,6 +244,8 @@ func (f *Fetcher) Meta() *fetcher.FetcherMeta {
|
||||
}
|
||||
|
||||
func (f *Fetcher) Stats() *fetcher.Stats {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
var stats torrent.TorrentStats
|
||||
var pieceMap *base.PieceMap
|
||||
pieceMapReady := false
|
||||
@@ -370,48 +452,65 @@ func normalizeBTTransport(network string) string {
|
||||
}
|
||||
|
||||
func (f *Fetcher) Progress() fetcher.Progress {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
if !f.torrentReady.Load() {
|
||||
return f.data.Progress
|
||||
return append(fetcher.Progress(nil), f.data.Progress...)
|
||||
}
|
||||
for i := range f.data.Progress {
|
||||
selectIndex := f.meta.Opts.SelectFiles[i]
|
||||
file := f.torrent.Files()[selectIndex]
|
||||
f.data.Progress[i] = file.BytesCompleted()
|
||||
}
|
||||
return f.data.Progress
|
||||
return append(fetcher.Progress(nil), f.data.Progress...)
|
||||
}
|
||||
|
||||
func (f *Fetcher) Wait() (err error) {
|
||||
func (f *Fetcher) Wait() error {
|
||||
f.mu.Lock()
|
||||
var closed <-chan struct{}
|
||||
if f.torrent != nil {
|
||||
closed = f.torrent.Closed()
|
||||
}
|
||||
f.mu.Unlock()
|
||||
ticker := time.NewTicker(time.Second)
|
||||
defer ticker.Stop()
|
||||
for {
|
||||
select {
|
||||
case <-f.torrentDropCtx.Done():
|
||||
return
|
||||
case <-time.After(time.Second):
|
||||
if f.torrentReady.Load() && len(f.meta.Opts.SelectFiles) > 0 {
|
||||
if f.isDone() {
|
||||
// remove unselected files
|
||||
for i, file := range f.torrent.Files() {
|
||||
selected := false
|
||||
for _, selectIndex := range f.meta.Opts.SelectFiles {
|
||||
if i == selectIndex {
|
||||
selected = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !selected {
|
||||
root, name := torrentFileLayout(f.torrent.Info(), file.FileInfo())
|
||||
if err := removeUnselectedFile(f.meta.Opts.Path, root, name); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
return nil
|
||||
case <-closed:
|
||||
return nil
|
||||
case <-ticker.C:
|
||||
done, err := f.finishDownload()
|
||||
if done || err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (f *Fetcher) finishDownload() (bool, error) {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
if !f.torrentReady.Load() || f.meta.Opts == nil || len(f.meta.Opts.SelectFiles) == 0 || !f.isDoneLocked() {
|
||||
return false, nil
|
||||
}
|
||||
selected := make(map[int]bool, len(f.meta.Opts.SelectFiles))
|
||||
for _, i := range f.meta.Opts.SelectFiles {
|
||||
selected[i] = true
|
||||
}
|
||||
for i, file := range f.torrent.Files() {
|
||||
if selected[i] {
|
||||
continue
|
||||
}
|
||||
root, name := torrentFileLayout(f.torrent.Info(), file.FileInfo())
|
||||
if err := removeUnselectedFile(f.meta.Opts.Path, root, name); err != nil {
|
||||
return false, err
|
||||
}
|
||||
}
|
||||
return true, nil
|
||||
}
|
||||
|
||||
// Match the file storage layout, including its NoName convention for rootless
|
||||
// torrents. File.Path() always prepends BestName, even when storage omits it.
|
||||
func torrentFileLayout(info *metainfo.Info, file metainfo.FileInfo) (root, name string) {
|
||||
@@ -469,13 +568,22 @@ func removeUnselectedFile(downloadDir, torrentRoot, torrentPath string) error {
|
||||
}
|
||||
|
||||
func (f *Fetcher) isDone() bool {
|
||||
if f.meta.Opts == nil {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
return f.isDoneLocked()
|
||||
}
|
||||
|
||||
func (f *Fetcher) isDoneLocked() bool {
|
||||
if f.meta.Opts == nil || f.torrent == nil {
|
||||
return false
|
||||
}
|
||||
for _, selectIndex := range f.meta.Opts.SelectFiles {
|
||||
file := f.torrent.Files()[selectIndex]
|
||||
if file.BytesCompleted() < file.Length() {
|
||||
return false
|
||||
for _, index := range f.meta.Opts.SelectFiles {
|
||||
file := f.torrent.Files()[index]
|
||||
for i := file.BeginPieceIndex(); i < file.EndPieceIndex(); i++ {
|
||||
state := f.torrent.Piece(i).State()
|
||||
if !state.Ok || !state.Complete || state.Hashing || state.QueuedForHash {
|
||||
return false
|
||||
}
|
||||
}
|
||||
}
|
||||
return true
|
||||
@@ -484,6 +592,11 @@ func (f *Fetcher) isDone() bool {
|
||||
// Patch modifies the BT task settings.
|
||||
// Invalid file indices are silently ignored.
|
||||
func (f *Fetcher) Patch(req *base.Request, opts *base.Options) error {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
if f.meta.Opts == nil {
|
||||
f.meta.Opts = &base.Options{}
|
||||
}
|
||||
if opts == nil {
|
||||
return nil
|
||||
}
|
||||
@@ -540,9 +653,9 @@ func (f *Fetcher) updateRes() {
|
||||
Files: make([]*base.FileInfo, len(f.torrent.Files())),
|
||||
Hash: f.torrent.InfoHash().String(),
|
||||
}
|
||||
// Directory torrent
|
||||
if f.torrent.Info().Length == 0 {
|
||||
res.Name = f.torrent.Name()
|
||||
// Use the same root classification as storage (v2 single files have Length == 0).
|
||||
if files := f.torrent.Files(); len(files) > 0 {
|
||||
res.Name, _ = torrentFileLayout(f.torrent.Info(), files[0].FileInfo())
|
||||
}
|
||||
for i, file := range f.torrent.Files() {
|
||||
res.Files[i] = &base.FileInfo{
|
||||
@@ -559,61 +672,50 @@ func (f *Fetcher) updateRes() {
|
||||
}
|
||||
|
||||
func (f *Fetcher) Upload() (err error) {
|
||||
return f.addTorrent(f.meta.Req, true)
|
||||
if err = f.Start(); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (f *Fetcher) doUpload(fromUpload bool) {
|
||||
if !f.torrentUpload.CompareAndSwap(false, true) {
|
||||
return
|
||||
}
|
||||
|
||||
// Check and update seed data
|
||||
lastData := &fetcherData{
|
||||
SeedBytes: f.data.SeedBytes,
|
||||
SeedTime: f.data.SeedTime,
|
||||
}
|
||||
var doneTime int64 = 0
|
||||
f.mu.Lock()
|
||||
initialBytes, initialTime := f.data.SeedBytes, f.data.SeedTime
|
||||
torrentClosed := f.torrent.Closed()
|
||||
f.mu.Unlock()
|
||||
var doneTime int64
|
||||
ticker := time.NewTicker(time.Second)
|
||||
defer ticker.Stop()
|
||||
for {
|
||||
select {
|
||||
case <-f.torrentDropCtx.Done():
|
||||
return
|
||||
case <-time.After(time.Second):
|
||||
case <-torrentClosed:
|
||||
return
|
||||
case <-ticker.C:
|
||||
f.mu.Lock()
|
||||
if !f.torrentReady.Load() {
|
||||
f.mu.Unlock()
|
||||
continue
|
||||
}
|
||||
|
||||
stats := f.torrentStats()
|
||||
f.data.SeedBytes = lastData.SeedBytes + stats.BytesWrittenData.Int64()
|
||||
|
||||
// Check is download complete, if not don't check and stop seeding
|
||||
if !fromUpload && !f.isDone() {
|
||||
f.data.SeedBytes = initialBytes + stats.BytesWrittenData.Int64()
|
||||
if !fromUpload && !f.isDoneLocked() {
|
||||
f.mu.Unlock()
|
||||
continue
|
||||
}
|
||||
if doneTime == 0 {
|
||||
doneTime = time.Now().Unix()
|
||||
}
|
||||
f.data.SeedTime = lastData.SeedTime + time.Now().Unix() - doneTime
|
||||
|
||||
// If the seed forever is true, keep seeding
|
||||
if f.config.SeedKeep {
|
||||
continue
|
||||
}
|
||||
|
||||
// If the seed ratio is reached, stop seeding
|
||||
if f.config.SeedRatio > 0 {
|
||||
seedRadio := f.seedRadio()
|
||||
if seedRadio >= f.config.SeedRatio {
|
||||
f.Close()
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
// If the seed time is reached, stop seeding
|
||||
if f.config.SeedTime > 0 {
|
||||
if f.data.SeedTime >= f.config.SeedTime {
|
||||
f.Close()
|
||||
break
|
||||
}
|
||||
f.data.SeedTime = initialTime + time.Now().Unix() - doneTime
|
||||
stop := !f.config.SeedKeep && ((f.config.SeedRatio > 0 && f.seedRadio() >= f.config.SeedRatio) || (f.config.SeedTime > 0 && f.data.SeedTime >= f.config.SeedTime))
|
||||
f.mu.Unlock()
|
||||
if stop {
|
||||
f.Close()
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -631,6 +733,8 @@ func (f *Fetcher) torrentStats() torrent.TorrentStats {
|
||||
}
|
||||
|
||||
func (f *Fetcher) UploadedBytes() int64 {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
return f.data.SeedBytes
|
||||
}
|
||||
|
||||
@@ -639,7 +743,8 @@ func (f *Fetcher) WaitUpload() (err error) {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (f *Fetcher) addTorrent(req *base.Request, fromUpload bool) (err error) {
|
||||
func (f *Fetcher) addTorrent(req *base.Request) (err error) {
|
||||
f.ctl.GetConfig(&f.config)
|
||||
if err = base.ParseReqExtra[bt.ReqExtra](req); err != nil {
|
||||
return
|
||||
}
|
||||
@@ -658,11 +763,16 @@ func (f *Fetcher) addTorrent(req *base.Request, fromUpload bool) (err error) {
|
||||
var reader io.Reader
|
||||
if schema == "FILE" {
|
||||
fileUrl, _ := url.Parse(req.URL)
|
||||
filePath := fileUrl.Path[1:]
|
||||
filePath := fileUrl.Path
|
||||
if runtime.GOOS == "windows" && len(filePath) > 2 && filePath[0] == '/' && filePath[2] == ':' {
|
||||
filePath = filePath[1:]
|
||||
}
|
||||
filePath = filepath.FromSlash(filePath)
|
||||
reader, err = os.Open(filePath)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
defer reader.(io.Closer).Close()
|
||||
} else if schema == "DATA" {
|
||||
_, data := util.ParseDataUri(req.URL)
|
||||
reader = bytes.NewBuffer(data)
|
||||
@@ -695,21 +805,6 @@ func (f *Fetcher) addTorrent(req *base.Request, fromUpload bool) (err error) {
|
||||
return
|
||||
}
|
||||
}
|
||||
spec.Storage = storage.NewFileOpts(storage.NewFileClientOpts{
|
||||
ClientBaseDir: cfg.DataDir,
|
||||
FilePathMaker: func(opts storage.FilePathMakerOpts) string {
|
||||
_, name := torrentFileLayout(opts.Info, *opts.File)
|
||||
return name
|
||||
},
|
||||
TorrentDirMaker: func(baseDir string, info *metainfo.Info, infoHash metainfo.Hash) string {
|
||||
return f.meta.Opts.Path
|
||||
},
|
||||
})
|
||||
f.torrent, _, err = client.AddTorrentSpec(spec)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
// Do not add external tracker to a private torrent.
|
||||
if !privateTorrent {
|
||||
// use map to deduplicate
|
||||
@@ -722,23 +817,40 @@ func (f *Fetcher) addTorrent(req *base.Request, fromUpload bool) (err error) {
|
||||
}
|
||||
}
|
||||
}
|
||||
if len(f.config.Trackers) > 0 {
|
||||
for _, tracker := range f.config.Trackers {
|
||||
trackers[tracker] = true
|
||||
}
|
||||
for _, tracker := range f.config.effectiveTrackers() {
|
||||
trackers[tracker] = true
|
||||
}
|
||||
if len(trackers) > 0 {
|
||||
announceList := make([][]string, 0)
|
||||
for tracker := range trackers {
|
||||
announceList = append(announceList, []string{tracker})
|
||||
spec.Trackers = append(spec.Trackers, []string{tracker})
|
||||
}
|
||||
f.torrent.AddTrackers(announceList)
|
||||
}
|
||||
}
|
||||
<-f.torrent.GotInfo()
|
||||
s := &deferredFileStorage{}
|
||||
spec.Storage = s
|
||||
var added bool
|
||||
f.torrent, added, err = client.AddTorrentSpec(spec)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !added {
|
||||
f.torrent = nil
|
||||
return errors.New("torrent is already active")
|
||||
}
|
||||
f.storage = s
|
||||
f.torrent.DisallowDataDownload()
|
||||
// Extra trackers must be available while magnet metadata is still missing;
|
||||
// they may be the only way to discover a peer that can provide it.
|
||||
select {
|
||||
case <-f.torrent.GotInfo():
|
||||
case <-f.torrentDropCtx.Done():
|
||||
f.safeDrop()
|
||||
return f.torrentDropCtx.Err()
|
||||
case <-f.torrent.Closed():
|
||||
return errors.New("torrent closed during resolution")
|
||||
}
|
||||
f.torrentReady.Store(true)
|
||||
|
||||
go f.doUpload(fromUpload)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -834,17 +946,29 @@ func (fm *FetcherManager) AutoRename() bool {
|
||||
|
||||
func (fm *FetcherManager) DefaultConfig() any {
|
||||
return &config{
|
||||
TrackerSubscribeURLs: append([]string{}, defaultTrackerSubscribeURLs...),
|
||||
AutoUpdateTrackers: true,
|
||||
CustomTrackers: []string{},
|
||||
ConfigState: fetcher.ConfigState[state]{State: state{
|
||||
TrackerSources: map[string][]string{},
|
||||
TrackerSubscriptionStatuses: map[string]trackerSubscriptionStatus{},
|
||||
}},
|
||||
ListenPort: 0,
|
||||
Trackers: []string{},
|
||||
SeedKeep: false,
|
||||
SeedRatio: 1.0,
|
||||
SeedTime: 120 * 60,
|
||||
DisableDHT: false,
|
||||
DisablePEX: false,
|
||||
}
|
||||
}
|
||||
|
||||
func (fm *FetcherManager) Store(f fetcher.Fetcher) (data any, err error) {
|
||||
_f := f.(*Fetcher)
|
||||
return _f.data, nil
|
||||
_f.mu.Lock()
|
||||
defer _f.mu.Unlock()
|
||||
snapshot := *_f.data
|
||||
snapshot.Progress = append(fetcher.Progress(nil), snapshot.Progress...)
|
||||
return &snapshot, nil
|
||||
}
|
||||
|
||||
func (fm *FetcherManager) Restore() (v any, f func(meta *fetcher.FetcherMeta, v any) fetcher.Fetcher) {
|
||||
|
||||
@@ -18,11 +18,11 @@ import (
|
||||
)
|
||||
|
||||
func TestFetcher_Resolve_Torrent(t *testing.T) {
|
||||
doResolve(t, buildFetcher())
|
||||
doResolve(t, buildFetcher(t))
|
||||
}
|
||||
|
||||
func TestFetcher_Resolve_DataUri_Torrent(t *testing.T) {
|
||||
fetcher := buildFetcher()
|
||||
fetcher := buildFetcher(t)
|
||||
buf, err := os.ReadFile("./testdata/ubuntu-22.04-live-server-amd64.iso.torrent")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
@@ -31,7 +31,7 @@ func TestFetcher_Resolve_DataUri_Torrent(t *testing.T) {
|
||||
dataUri := "data:application/x-bittorrent;base64," + base64.StdEncoding.EncodeToString(buf)
|
||||
err = fetcher.Resolve(&base.Request{
|
||||
URL: dataUri,
|
||||
}, nil)
|
||||
})
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
@@ -53,7 +53,7 @@ func TestFetcher_Resolve_DataUri_Torrent(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestFetcher_Config(t *testing.T) {
|
||||
doResolve(t, buildConfigFetcher(nil))
|
||||
doResolve(t, buildConfigFetcher(t, nil))
|
||||
}
|
||||
|
||||
func TestFetcher_ResolveWithProxy(t *testing.T) {
|
||||
@@ -61,7 +61,7 @@ func TestFetcher_ResolveWithProxy(t *testing.T) {
|
||||
proxyListener := test.StartSocks5Server(usr, pwd)
|
||||
defer proxyListener.Close()
|
||||
|
||||
doResolve(t, buildConfigFetcher(&base.DownloaderProxyConfig{
|
||||
doResolve(t, buildConfigFetcher(t, &base.DownloaderProxyConfig{
|
||||
Enable: true,
|
||||
System: false,
|
||||
Scheme: "socks5",
|
||||
@@ -81,7 +81,7 @@ func doResolve(t *testing.T, fetcher fetcher.Fetcher) {
|
||||
"udp://tracker.bitsearch.to:1337/announce",
|
||||
},
|
||||
},
|
||||
}, nil)
|
||||
})
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
@@ -111,7 +111,7 @@ func doResolve(t *testing.T, fetcher fetcher.Fetcher) {
|
||||
"udp://tracker.bitsearch.to:1337/announce",
|
||||
},
|
||||
},
|
||||
}, nil)
|
||||
})
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
@@ -145,7 +145,7 @@ func doResolve(t *testing.T, fetcher fetcher.Fetcher) {
|
||||
t.Run("Resolve Unclean Torrent", func(t *testing.T) {
|
||||
err := fetcher.Resolve(&base.Request{
|
||||
URL: "./testdata/test.unclean.torrent",
|
||||
}, nil)
|
||||
})
|
||||
if err != nil {
|
||||
t.Errorf("Resolve Unclean Torrent Resolve() got = %v, want nil", err)
|
||||
}
|
||||
@@ -156,7 +156,7 @@ func doResolve(t *testing.T, fetcher fetcher.Fetcher) {
|
||||
uri := "file:///" + file
|
||||
err := fetcher.Resolve(&base.Request{
|
||||
URL: uri,
|
||||
}, nil)
|
||||
})
|
||||
if err != nil {
|
||||
t.Errorf("Resolve file scheme Torrent Resolve() got = %v, want nil", err)
|
||||
}
|
||||
@@ -211,7 +211,7 @@ func TestFetcherManager_ParseName(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func buildFetcher() fetcher.Fetcher {
|
||||
func buildFetcher(t testing.TB) fetcher.Fetcher {
|
||||
fb := new(FetcherManager)
|
||||
fetcher := fb.Build()
|
||||
newController := controller.NewController()
|
||||
@@ -219,14 +219,15 @@ func buildFetcher() fetcher.Fetcher {
|
||||
json.Unmarshal([]byte(test.ToJson(fb.DefaultConfig())), v)
|
||||
}
|
||||
fetcher.Setup(newController)
|
||||
t.Cleanup(func() { fetcher.Close(); closeClient() })
|
||||
return fetcher
|
||||
}
|
||||
|
||||
func buildConfigFetcher(proxyConfig *base.DownloaderProxyConfig) fetcher.Fetcher {
|
||||
func buildConfigFetcher(t testing.TB, proxyConfig *base.DownloaderProxyConfig) fetcher.Fetcher {
|
||||
fetcher := new(FetcherManager).Build()
|
||||
newController := controller.NewController()
|
||||
mockCfg := config{
|
||||
Trackers: []string{
|
||||
CustomTrackers: []string{
|
||||
"udp://tracker.birkenwald.de:6969/announce",
|
||||
"udp://tracker.bitsearch.to:1337/announce",
|
||||
}}
|
||||
@@ -237,29 +238,30 @@ func buildConfigFetcher(proxyConfig *base.DownloaderProxyConfig) fetcher.Fetcher
|
||||
return proxyConfig.ToHandler()
|
||||
}
|
||||
fetcher.Setup(newController)
|
||||
t.Cleanup(func() { fetcher.Close(); closeClient() })
|
||||
return fetcher
|
||||
}
|
||||
|
||||
// TestFetcher_Patch tests the Patch functionality for BT fetcher.
|
||||
// It tests modifying selected files after Resolve (without downloading).
|
||||
func TestFetcher_Patch(t *testing.T) {
|
||||
f := buildFetcher()
|
||||
f := buildFetcher(t)
|
||||
|
||||
// Resolve a multi-file torrent
|
||||
err := f.Resolve(&base.Request{
|
||||
URL: "./testdata/test.torrent",
|
||||
}, nil)
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Resolve failed: %v", err)
|
||||
}
|
||||
|
||||
// Verify initial state: 3 files, all selected by default
|
||||
// Resolution describes files without choosing download options.
|
||||
meta := f.Meta()
|
||||
if len(meta.Res.Files) != 3 {
|
||||
t.Fatalf("Expected 3 files, got %d", len(meta.Res.Files))
|
||||
}
|
||||
if len(meta.Opts.SelectFiles) != 3 {
|
||||
t.Fatalf("Expected 3 selected files, got %d", len(meta.Opts.SelectFiles))
|
||||
if meta.Opts != nil {
|
||||
t.Fatal("Resolve should not assign download options")
|
||||
}
|
||||
|
||||
// Total size of all files: 107484864
|
||||
@@ -366,3 +368,52 @@ func TestFetcher_Patch(t *testing.T) {
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestFetcher_PeerDiscoveryConfig(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
stored any
|
||||
wantDisableDHT bool
|
||||
wantDisablePEX bool
|
||||
}{
|
||||
{"default config", new(FetcherManager).DefaultConfig(), false, false},
|
||||
{"stored config without the fields", map[string]any{"listenPort": 0, "seedRatio": 1.0}, false, false},
|
||||
{"both disabled", map[string]any{"disableDht": true, "disablePex": true}, true, true},
|
||||
{"only DHT disabled", map[string]any{"disableDht": true}, true, false},
|
||||
{"only PEX disabled", map[string]any{"disablePex": true}, false, true},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
f := buildFetcherWithConfig(tt.stored).(*Fetcher)
|
||||
if f.config.DisableDHT != tt.wantDisableDHT || f.config.DisablePEX != tt.wantDisablePEX {
|
||||
t.Errorf("config got DisableDHT = %v, DisablePEX = %v, want %v, %v", f.config.DisableDHT, f.config.DisablePEX, tt.wantDisableDHT, tt.wantDisablePEX)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestFetcher_PeerDiscoveryDisabledOnClient(t *testing.T) {
|
||||
if err := closeClient(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
f := buildFetcherWithConfig(map[string]any{"disableDht": true, "disablePex": true})
|
||||
if err := f.Resolve(&base.Request{URL: "./testdata/test.torrent"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !cfg.NoDHT || !cfg.DisablePEX {
|
||||
t.Errorf("client config got NoDHT = %v, DisablePEX = %v, want true", cfg.NoDHT, cfg.DisablePEX)
|
||||
}
|
||||
if n := len(client.DhtServers()); n != 0 {
|
||||
t.Errorf("client got %d DHT servers, want 0", n)
|
||||
}
|
||||
}
|
||||
|
||||
func buildFetcherWithConfig(stored any) fetcher.Fetcher {
|
||||
fetcher := new(FetcherManager).Build()
|
||||
newController := controller.NewController()
|
||||
newController.GetConfig = func(v any) {
|
||||
json.Unmarshal([]byte(test.ToJson(stored)), v)
|
||||
}
|
||||
fetcher.Setup(newController)
|
||||
return fetcher
|
||||
}
|
||||
@@ -0,0 +1,212 @@
|
||||
package bt
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/base64"
|
||||
"encoding/binary"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/anacrolix/torrent"
|
||||
"github.com/anacrolix/torrent/bencode"
|
||||
"github.com/anacrolix/torrent/merkle"
|
||||
"github.com/anacrolix/torrent/metainfo"
|
||||
"golang.org/x/time/rate"
|
||||
)
|
||||
|
||||
// btFixture serves generated, trackerless content over a real loopback TCP peer.
|
||||
// Its HTTP tracker exercises .torrent downloads; magnet links also carry the
|
||||
// explicit x.pe address.
|
||||
type btFixture struct {
|
||||
MetaInfo *metainfo.MetaInfo
|
||||
Magnet string
|
||||
TorrentFile string
|
||||
DataURI string
|
||||
TrackerURL string
|
||||
Files map[string][]byte // Paths relative to the selected download directory.
|
||||
Seeder *torrent.Client
|
||||
Torrent *torrent.Torrent
|
||||
Limiter *rate.Limiter
|
||||
}
|
||||
|
||||
func newBTFixture(t testing.TB, files map[string][]byte, bytesPerSecond int) *btFixture {
|
||||
return makeBTFixture(t, files, bytesPerSecond, false, true)
|
||||
}
|
||||
|
||||
func newBTHybridFixture(t testing.TB, payload []byte) *btFixture {
|
||||
return makeBTFixture(t, map[string][]byte{"": payload}, 0, true, true)
|
||||
}
|
||||
|
||||
func makeBTFixture(t testing.TB, files map[string][]byte, bytesPerSecond int, v2, private bool) *btFixture {
|
||||
t.Helper()
|
||||
source := t.TempDir()
|
||||
name := "payload.bin"
|
||||
if _, single := files[""]; !single {
|
||||
name = "bundle"
|
||||
}
|
||||
root := filepath.Join(source, name)
|
||||
expected := make(map[string][]byte, len(files))
|
||||
for path, data := range files {
|
||||
target := filepath.Join(root, filepath.FromSlash(path))
|
||||
if err := os.MkdirAll(filepath.Dir(target), 0700); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(target, data, 0600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
expected[filepath.Join(name, filepath.FromSlash(path))] = append([]byte(nil), data...)
|
||||
}
|
||||
info := metainfo.Info{PieceLength: 16 * 1024}
|
||||
if err := info.BuildFromFilePath(root); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var layers map[string]string
|
||||
if v2 {
|
||||
data := files[""]
|
||||
h := merkle.NewHash()
|
||||
h.Write(data)
|
||||
root := h.Sum(nil)
|
||||
var pieceHashes []byte
|
||||
for off := 0; off < len(data); off += int(info.PieceLength) {
|
||||
end := min(off+int(info.PieceLength), len(data))
|
||||
ph := merkle.NewHash()
|
||||
ph.Write(data[off:end])
|
||||
pieceHashes = append(pieceHashes, ph.SumMinLength(nil, int(info.PieceLength))...)
|
||||
}
|
||||
info = metainfo.Info{Name: name, PieceLength: info.PieceLength, Length: int64(len(data)), Pieces: info.Pieces, MetaVersion: 2, FileTree: metainfo.FileTree{File: metainfo.FileTreeFile{Length: int64(len(data)), PiecesRoot: string(root)}}}
|
||||
layers = map[string]string{string(root): string(pieceHashes)}
|
||||
}
|
||||
// Private torrents avoid public DHT discovery once metadata is available.
|
||||
info.Private = &private
|
||||
raw, err := bencode.Marshal(&info)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
mi := &metainfo.MetaInfo{InfoBytes: raw, PieceLayers: layers}
|
||||
cfg := torrent.TestingConfig(t)
|
||||
cfg.DataDir = source
|
||||
cfg.Seed = true
|
||||
cfg.DisableUTP = true
|
||||
cfg.DisableIPv6 = true
|
||||
cfg.DisableWebtorrent = true
|
||||
cfg.KeepAliveTimeout = time.Minute
|
||||
cfg.MaxAllocPeerRequestDataPerConn = 1 << 20
|
||||
limiter := rate.NewLimiter(rate.Inf, 16*1024)
|
||||
if bytesPerSecond > 0 {
|
||||
limiter.SetLimit(rate.Limit(bytesPerSecond))
|
||||
}
|
||||
cfg.UploadRateLimiter = limiter
|
||||
seed, err := torrent.NewClient(cfg)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { seed.Close() })
|
||||
seeded, _, err := seed.AddTorrentSpec(torrent.TorrentSpecFromMetaInfo(mi))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
defer cancel()
|
||||
if err := seeded.VerifyDataContext(ctx); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
select {
|
||||
case <-seeded.Complete().On():
|
||||
case <-ctx.Done():
|
||||
t.Fatal("local seeder did not finish verification")
|
||||
}
|
||||
addr := seed.ListenAddrs()[0].String()
|
||||
host, portString, err := net.SplitHostPort(addr)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
resolved, err := net.ResolveTCPAddr("tcp4", net.JoinHostPort(host, portString))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
compact := make([]byte, 6)
|
||||
copy(compact, resolved.IP.To4())
|
||||
binary.BigEndian.PutUint16(compact[4:], uint16(resolved.Port))
|
||||
tracker := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
response, err := bencode.Marshal(map[string]any{"interval": 3600, "peers": string(compact)})
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), 500)
|
||||
return
|
||||
}
|
||||
w.Write(response)
|
||||
}))
|
||||
t.Cleanup(tracker.Close)
|
||||
// The pinned library has a hybrid tracker-dispatch assertion bug.
|
||||
// Hybrid fixtures use their explicit loopback peer rather than a tracker.
|
||||
if !v2 {
|
||||
mi.Announce = tracker.URL
|
||||
}
|
||||
var encoded bytes.Buffer
|
||||
if err := mi.Write(&encoded); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
torrentFile := filepath.Join(t.TempDir(), "fixture.torrent")
|
||||
if err := os.WriteFile(torrentFile, encoded.Bytes(), 0600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
m, err := mi.MagnetV2()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
magnet := m.String()
|
||||
u, err := url.Parse(magnet)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
q := u.Query()
|
||||
q.Add("x.pe", addr)
|
||||
u.RawQuery = q.Encode()
|
||||
return &btFixture{MetaInfo: mi, Magnet: u.String(), TorrentFile: torrentFile, DataURI: "data:application/x-bittorrent;base64," + base64.StdEncoding.EncodeToString(encoded.Bytes()), TrackerURL: tracker.URL, Files: expected, Seeder: seed, Torrent: seeded, Limiter: limiter}
|
||||
}
|
||||
|
||||
func btPayload(size int, label string) []byte {
|
||||
data := make([]byte, size)
|
||||
for i := range data {
|
||||
data[i] = byte((i*31 + i/251) % 251)
|
||||
}
|
||||
copy(data, label)
|
||||
return data
|
||||
}
|
||||
|
||||
func (f *btFixture) AssertFiles(t testing.TB, dir string, paths ...string) {
|
||||
t.Helper()
|
||||
if len(paths) == 0 {
|
||||
for path := range f.Files {
|
||||
paths = append(paths, path)
|
||||
}
|
||||
}
|
||||
for _, path := range paths {
|
||||
got, err := os.ReadFile(filepath.Join(dir, path))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
want, ok := f.Files[path]
|
||||
if !ok {
|
||||
t.Fatalf("unknown fixture file %q", path)
|
||||
}
|
||||
if !bytes.Equal(got, want) {
|
||||
t.Fatalf("BT file %s differs: received %d bytes, want %d", path, len(got), len(want))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (f *btFixture) FileURI() string {
|
||||
path := filepath.ToSlash(f.TorrentFile)
|
||||
if !strings.HasPrefix(path, "/") {
|
||||
path = "/" + path
|
||||
}
|
||||
return (&url.URL{Scheme: "file", Path: path}).String()
|
||||
}
|
||||
@@ -17,25 +17,85 @@ import (
|
||||
"github.com/anacrolix/torrent/storage"
|
||||
)
|
||||
|
||||
func TestStartUsesSelectionSubmittedAfterResolve(t *testing.T) {
|
||||
config := torrent.TestingConfig(t)
|
||||
config.DataDir = t.TempDir()
|
||||
config.DisableTCP = true
|
||||
config.DisableUTP = true
|
||||
c, err := torrent.NewClient(config)
|
||||
func TestStartDoesNotPublishUnverifiedCompletion(t *testing.T) {
|
||||
cfg := torrent.TestingConfig(t)
|
||||
cfg.DisableTCP = true
|
||||
cfg.DisableUTP = true
|
||||
c, err := torrent.NewClient(cfg)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer c.Close()
|
||||
tor, err := c.AddTorrentFromFile("testdata/test.torrent")
|
||||
payload := btPayload(32*1024, "unverified")
|
||||
info := metainfo.Info{Name: "payload.bin", Length: int64(len(payload)), PieceLength: 16 * 1024}
|
||||
for offset := 0; offset < len(payload); offset += int(info.PieceLength) {
|
||||
hash := sha1.Sum(payload[offset : offset+int(info.PieceLength)])
|
||||
info.Pieces = append(info.Pieces, hash[:]...)
|
||||
}
|
||||
raw, err := bencode.Marshal(info)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
f := &Fetcher{torrent: tor, meta: &fetcher.FetcherMeta{Opts: &base.Options{}}, data: &fetcherData{}}
|
||||
store := &deferredFileStorage{}
|
||||
spec := torrent.TorrentSpecFromMetaInfo(&metainfo.MetaInfo{InfoBytes: raw})
|
||||
spec.Storage = store
|
||||
tor, _, err := c.AddTorrentSpec(spec)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
tor.DisallowDataDownload()
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
f := &Fetcher{torrent: tor, storage: store, meta: &fetcher.FetcherMeta{}, data: &fetcherData{}, torrentDropCtx: ctx, torrentDropFunc: cancel, config: &config{SeedKeep: true}}
|
||||
f.torrentReady.Store(true)
|
||||
f.updateRes()
|
||||
// Resolve initializes all files; creation replaces the selection before Start.
|
||||
f.meta.Opts = &base.Options{SelectFiles: []int{2}}
|
||||
dir := t.TempDir()
|
||||
payload[20000] ^= 0xff
|
||||
if err := os.WriteFile(filepath.Join(dir, info.Name), payload, 0600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
f.meta.Opts = &base.Options{Path: dir}
|
||||
// Retain the original event channel to detect even a transient completion.
|
||||
// With no network transports, the damaged piece cannot have been repaired.
|
||||
completed := tor.Complete().On()
|
||||
if err := f.Start(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
select {
|
||||
case <-completed:
|
||||
t.Fatal("Start published completion before verifying the existing file")
|
||||
default:
|
||||
}
|
||||
}
|
||||
|
||||
func TestStartUsesSelectionSubmittedAfterResolve(t *testing.T) {
|
||||
cfg := torrent.TestingConfig(t)
|
||||
cfg.DataDir = t.TempDir()
|
||||
cfg.DisableTCP = true
|
||||
cfg.DisableUTP = true
|
||||
c, err := torrent.NewClient(cfg)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer c.Close()
|
||||
store := &deferredFileStorage{}
|
||||
mi, err := metainfo.LoadFromFile("testdata/test.torrent")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
spec := torrent.TorrentSpecFromMetaInfo(mi)
|
||||
spec.Storage = store
|
||||
tor, _, err := c.AddTorrentSpec(spec)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
tor.DisallowDataDownload()
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
f := &Fetcher{torrent: tor, storage: store, meta: &fetcher.FetcherMeta{}, data: &fetcherData{}, torrentDropCtx: ctx, torrentDropFunc: cancel, config: &config{SeedKeep: true}}
|
||||
f.torrentReady.Store(true)
|
||||
f.updateRes()
|
||||
f.meta.Opts = &base.Options{Path: t.TempDir(), SelectFiles: []int{2}}
|
||||
if err := f.Start(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,116 @@
|
||||
package bt
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"os"
|
||||
"sync"
|
||||
|
||||
g "github.com/anacrolix/generics"
|
||||
"github.com/anacrolix/torrent/metainfo"
|
||||
"github.com/anacrolix/torrent/storage"
|
||||
)
|
||||
|
||||
// deferredFileStorage lets metadata resolve without opening the user's files.
|
||||
// It binds once. After binding, pieces are supplied directly by file storage.
|
||||
type deferredFileStorage struct {
|
||||
mu sync.RWMutex
|
||||
ctx context.Context
|
||||
info *metainfo.Info
|
||||
hash metainfo.Hash
|
||||
knownHashes []bool
|
||||
owner storage.ClientImplCloser
|
||||
torrent storage.TorrentImpl
|
||||
closed bool
|
||||
}
|
||||
|
||||
func (s *deferredFileStorage) OpenTorrent(ctx context.Context, info *metainfo.Info, hash metainfo.Hash) (storage.TorrentImpl, error) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
if s.closed || s.info != nil {
|
||||
return storage.TorrentImpl{}, errors.New("torrent storage already opened or closed")
|
||||
}
|
||||
s.ctx, s.info, s.hash = ctx, info, hash
|
||||
s.knownHashes = make([]bool, info.NumPieces())
|
||||
return storage.TorrentImpl{
|
||||
PieceWithHash: func(p metainfo.Piece, hash g.Option[[]byte]) storage.PieceImpl {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
if s.closed {
|
||||
return metadataPiece{err: os.ErrClosed}
|
||||
}
|
||||
if s.owner == nil {
|
||||
s.knownHashes[p.Index()] = hash.Ok
|
||||
// A missing v2 hash must remain unknown until its arrival changes
|
||||
// completion and updates the library's piece request priorities.
|
||||
return metadataPiece{known: hash.Ok}
|
||||
}
|
||||
if s.torrent.PieceWithHash != nil {
|
||||
return s.torrent.PieceWithHash(p, hash)
|
||||
}
|
||||
return s.torrent.Piece(p)
|
||||
},
|
||||
Close: s.Close,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (s *deferredFileStorage) bind(opts storage.NewFileClientOpts) ([]bool, error) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
if s.closed {
|
||||
return nil, os.ErrClosed
|
||||
}
|
||||
if s.info == nil {
|
||||
return nil, errors.New("torrent metadata is not ready")
|
||||
}
|
||||
if s.owner != nil {
|
||||
return nil, nil
|
||||
}
|
||||
if opts.FilePathMaker == nil {
|
||||
opts.FilePathMaker = func(o storage.FilePathMakerOpts) string { _, name := torrentFileLayout(o.Info, *o.File); return name }
|
||||
}
|
||||
owner := storage.NewFileOpts(opts)
|
||||
t, err := owner.OpenTorrent(s.ctx, s.info, s.hash)
|
||||
if err != nil {
|
||||
owner.Close()
|
||||
return nil, err
|
||||
}
|
||||
s.owner, s.torrent = owner, t
|
||||
return append([]bool(nil), s.knownHashes...), nil
|
||||
}
|
||||
|
||||
func (s *deferredFileStorage) Close() error {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
if s.closed {
|
||||
return nil
|
||||
}
|
||||
s.closed = true
|
||||
if s.owner == nil {
|
||||
return nil
|
||||
}
|
||||
var err error
|
||||
if s.torrent.Close != nil {
|
||||
err = s.torrent.Close()
|
||||
}
|
||||
return errors.Join(err, s.owner.Close())
|
||||
}
|
||||
|
||||
type metadataPiece struct {
|
||||
known bool
|
||||
err error
|
||||
}
|
||||
|
||||
func (p metadataPiece) Completion() storage.Completion {
|
||||
return storage.Completion{Ok: p.known, Err: p.err}
|
||||
}
|
||||
func (p metadataPiece) ReadAt([]byte, int64) (int, error) {
|
||||
return 0, errors.New("download storage is not initialized")
|
||||
}
|
||||
func (p metadataPiece) WriteAt([]byte, int64) (int, error) {
|
||||
return 0, errors.New("download storage is not initialized")
|
||||
}
|
||||
func (p metadataPiece) MarkComplete() error { return errors.New("download storage is not initialized") }
|
||||
func (p metadataPiece) MarkNotComplete() error {
|
||||
return errors.New("download storage is not initialized")
|
||||
}
|
||||
@@ -0,0 +1,234 @@
|
||||
package bt
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/url"
|
||||
"slices"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/GopeedLab/gopeed/internal/fetcher"
|
||||
)
|
||||
|
||||
var defaultTrackerSubscribeURLs = []string{
|
||||
"https://raw.githubusercontent.com/ngosang/trackerslist/master/trackers_all.txt",
|
||||
"https://raw.githubusercontent.com/ngosang/trackerslist/master/trackers_all_http.txt",
|
||||
"https://raw.githubusercontent.com/ngosang/trackerslist/master/trackers_all_https.txt",
|
||||
"https://raw.githubusercontent.com/ngosang/trackerslist/master/trackers_all_ip.txt",
|
||||
"https://raw.githubusercontent.com/ngosang/trackerslist/master/trackers_all_udp.txt",
|
||||
"https://raw.githubusercontent.com/ngosang/trackerslist/master/trackers_all_ws.txt",
|
||||
"https://raw.githubusercontent.com/ngosang/trackerslist/master/trackers_best.txt",
|
||||
"https://raw.githubusercontent.com/ngosang/trackerslist/master/trackers_best_ip.txt",
|
||||
"https://raw.githubusercontent.com/XIU2/TrackersListCollection/master/all.txt",
|
||||
"https://raw.githubusercontent.com/XIU2/TrackersListCollection/master/best.txt",
|
||||
"https://raw.githubusercontent.com/XIU2/TrackersListCollection/master/http.txt",
|
||||
}
|
||||
|
||||
func validTracker(raw string) bool {
|
||||
u, err := url.Parse(raw)
|
||||
if err != nil || u.Hostname() == "" || u.User != nil {
|
||||
return false
|
||||
}
|
||||
switch u.Scheme {
|
||||
case "udp", "http", "https", "ws", "wss":
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
func uniqueTrackers(groups ...[]string) []string {
|
||||
result := []string{}
|
||||
seen := map[string]bool{}
|
||||
for _, group := range groups {
|
||||
for _, raw := range group {
|
||||
value := strings.TrimSpace(raw)
|
||||
if value != "" && !seen[value] {
|
||||
seen[value] = true
|
||||
result = append(result, value)
|
||||
}
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// effectiveTrackers derives the client list without persisting duplicate lists.
|
||||
func (c *config) effectiveTrackers() []string {
|
||||
groups := [][]string{c.CustomTrackers}
|
||||
for _, source := range c.TrackerSubscribeURLs {
|
||||
groups = append(groups, c.State.TrackerSources[source])
|
||||
}
|
||||
return uniqueTrackers(groups...)
|
||||
}
|
||||
func (fm *FetcherManager) NormalizeConfig(raw json.RawMessage) (any, error) {
|
||||
c := fm.DefaultConfig().(*config)
|
||||
if err := json.Unmarshal(raw, c); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if c.ListenPort < 0 || c.ListenPort > 65535 || c.SeedRatio < 0 || c.SeedTime < 0 {
|
||||
return nil, errors.New("invalid BT configuration")
|
||||
}
|
||||
if len(c.TrackerSubscribeURLs) > 64 {
|
||||
return nil, errors.New("too many tracker subscriptions")
|
||||
}
|
||||
c.TrackerSubscribeURLs = uniqueTrackers(c.TrackerSubscribeURLs)
|
||||
for _, source := range c.TrackerSubscribeURLs {
|
||||
u, err := url.Parse(source)
|
||||
if err != nil || u == nil || (u.Scheme != "https" && u.Scheme != "http") || u.Hostname() == "" || u.User != nil {
|
||||
return nil, errors.New("invalid tracker subscription URL")
|
||||
}
|
||||
}
|
||||
c.CustomTrackers = uniqueTrackers(c.CustomTrackers)
|
||||
for _, tracker := range c.CustomTrackers {
|
||||
if !validTracker(tracker) {
|
||||
return nil, fmt.Errorf("invalid tracker URL: %s", tracker)
|
||||
}
|
||||
}
|
||||
if c.State.TrackerSources == nil {
|
||||
c.State.TrackerSources = map[string][]string{}
|
||||
}
|
||||
for source := range c.State.TrackerSources {
|
||||
if !slices.Contains(c.TrackerSubscribeURLs, source) {
|
||||
delete(c.State.TrackerSources, source)
|
||||
}
|
||||
}
|
||||
if c.State.TrackerSubscriptionStatuses == nil {
|
||||
c.State.TrackerSubscriptionStatuses = map[string]trackerSubscriptionStatus{}
|
||||
}
|
||||
for source := range c.State.TrackerSubscriptionStatuses {
|
||||
if !slices.Contains(c.TrackerSubscribeURLs, source) {
|
||||
delete(c.State.TrackerSubscriptionStatuses, source)
|
||||
}
|
||||
}
|
||||
return c, nil
|
||||
}
|
||||
func (fm *FetcherManager) CronJobs(host fetcher.ProtocolHost) []fetcher.CronJob {
|
||||
return []fetcher.CronJob{{
|
||||
Name: "update-trackers",
|
||||
Every: 24 * time.Hour,
|
||||
Timeout: 90 * time.Second,
|
||||
Run: func(ctx context.Context, source fetcher.TriggerSource) error {
|
||||
return fm.updateTrackers(ctx, host, source)
|
||||
},
|
||||
}}
|
||||
}
|
||||
func parseTrackers(data []byte) ([]string, error) {
|
||||
scanner := bufio.NewScanner(bytes.NewReader(data))
|
||||
scanner.Buffer(make([]byte, 4096), 64*1024)
|
||||
result := []string{}
|
||||
for scanner.Scan() {
|
||||
line := strings.TrimSpace(strings.TrimPrefix(scanner.Text(), "\ufeff"))
|
||||
if validTracker(line) {
|
||||
result = append(result, line)
|
||||
}
|
||||
if len(result) > 20000 {
|
||||
return nil, errors.New("too many trackers")
|
||||
}
|
||||
}
|
||||
if err := scanner.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(result) == 0 {
|
||||
return nil, errors.New("subscription contains no valid trackers")
|
||||
}
|
||||
return uniqueTrackers(result), nil
|
||||
}
|
||||
func (fm *FetcherManager) updateTrackers(ctx context.Context, host fetcher.ProtocolHost, source fetcher.TriggerSource) error {
|
||||
snapshot, err := fetcher.ReadConfig[config](host)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if (source == fetcher.TriggerScheduled && !snapshot.AutoUpdateTrackers) || len(snapshot.TrackerSubscribeURLs) == 0 {
|
||||
return fetcher.ErrJobSkipped
|
||||
}
|
||||
fetched := map[string][]string{}
|
||||
statuses := map[string]trackerSubscriptionStatus{}
|
||||
var failures []error
|
||||
var mu sync.Mutex
|
||||
var wg sync.WaitGroup
|
||||
slots := make(chan struct{}, 4)
|
||||
for _, address := range snapshot.TrackerSubscribeURLs {
|
||||
wg.Add(1)
|
||||
go func(address string) {
|
||||
defer wg.Done()
|
||||
select {
|
||||
case slots <- struct{}{}:
|
||||
defer func() { <-slots }()
|
||||
case <-ctx.Done():
|
||||
return
|
||||
}
|
||||
data, err := host.Fetch(ctx, address, 2<<20, func(body []byte) error { _, err := parseTrackers(body); return err })
|
||||
var trackers []string
|
||||
if err == nil {
|
||||
trackers, err = parseTrackers(data)
|
||||
}
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
statuses[address] = trackerSubscriptionStatus{Success: err == nil, TrackerCount: len(trackers), LastFetchTime: time.Now().UTC()}
|
||||
if err != nil {
|
||||
failures = append(failures, fmt.Errorf("subscription %s: %w", address, err))
|
||||
} else {
|
||||
fetched[address] = trackers
|
||||
}
|
||||
}(address)
|
||||
}
|
||||
wg.Wait()
|
||||
if err := ctx.Err(); err != nil {
|
||||
failures = append(failures, err)
|
||||
}
|
||||
if len(statuses) == 0 {
|
||||
return errors.Join(failures...)
|
||||
}
|
||||
err = fetcher.UpdateState[config, state](host, func(current *config, state *state) error {
|
||||
if !slices.Equal(current.TrackerSubscribeURLs, snapshot.TrackerSubscribeURLs) {
|
||||
return fetcher.ErrConfigChanged
|
||||
}
|
||||
if state.TrackerSources == nil {
|
||||
state.TrackerSources = map[string][]string{}
|
||||
}
|
||||
if state.TrackerSubscriptionStatuses == nil {
|
||||
state.TrackerSubscriptionStatuses = map[string]trackerSubscriptionStatus{}
|
||||
}
|
||||
for address, status := range statuses {
|
||||
state.TrackerSubscriptionStatuses[address] = status
|
||||
}
|
||||
for address, trackers := range fetched {
|
||||
state.TrackerSources[address] = trackers
|
||||
}
|
||||
if len(failures) == 0 {
|
||||
state.LastTrackerUpdateTime = time.Now().UTC()
|
||||
}
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
current, err := fetcher.ReadConfig[config](host)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
fm.applyTrackers(current.effectiveTrackers())
|
||||
return errors.Join(failures...)
|
||||
}
|
||||
func (fm *FetcherManager) applyTrackers(trackers []string) {
|
||||
lock.Lock()
|
||||
defer lock.Unlock()
|
||||
if client == nil {
|
||||
return
|
||||
}
|
||||
announce := make([][]string, 0, len(trackers))
|
||||
for _, tracker := range trackers {
|
||||
announce = append(announce, []string{tracker})
|
||||
}
|
||||
for _, t := range client.Torrents() {
|
||||
info := t.Info()
|
||||
if info == nil || (info.Private != nil && *info.Private) {
|
||||
continue
|
||||
}
|
||||
t.AddTrackers(announce)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,217 @@
|
||||
package bt
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"reflect"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/GopeedLab/gopeed/internal/fetcher"
|
||||
)
|
||||
|
||||
func trackerTestHost(t *testing.T, c *config, fetch func(context.Context, string, int64, func([]byte) error) ([]byte, error)) (fetcher.ProtocolHost, *sync.Mutex) {
|
||||
t.Helper()
|
||||
mu := &sync.Mutex{}
|
||||
return fetcher.ProtocolHost{
|
||||
Read: func(dst any) error {
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
raw, err := json.Marshal(c)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return json.Unmarshal(raw, dst)
|
||||
},
|
||||
UpdateState: func(change func(config, state json.RawMessage) (any, error)) error {
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
raw, _ := json.Marshal(c)
|
||||
currentState, _ := json.Marshal(c.State)
|
||||
next, err := change(raw, currentState)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
raw, err = json.Marshal(next)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return json.Unmarshal(raw, &c.State)
|
||||
},
|
||||
Fetch: fetch,
|
||||
}, mu
|
||||
}
|
||||
func TestTrackerRefreshPartialSuccessUsesLatestConfig(t *testing.T) {
|
||||
fm := &FetcherManager{}
|
||||
cfg := fm.DefaultConfig().(*config)
|
||||
cfg.TrackerSubscribeURLs = []string{"https://a.example/list", "https://b.example/list"}
|
||||
cfg.CustomTrackers = []string{"udp://custom.example:80/announce"}
|
||||
cfg.State.TrackerSources = map[string][]string{cfg.TrackerSubscribeURLs[0]: {"udp://old.example:80/announce"}, cfg.TrackerSubscribeURLs[1]: {"udp://cached.example:80/announce"}}
|
||||
host, mu := trackerTestHost(t, cfg, nil)
|
||||
host.Fetch = func(ctx context.Context, address string, _ int64, validate func([]byte) error) ([]byte, error) {
|
||||
if address == "https://b.example/list" {
|
||||
return nil, errors.New("offline")
|
||||
}
|
||||
// Simulate a frontend update while the network request is in flight.
|
||||
mu.Lock()
|
||||
cfg.CustomTrackers = []string{"udp://new-custom.example:80/announce"}
|
||||
mu.Unlock()
|
||||
data := []byte("udp://fresh.example:80/announce\n# ignored\nudp://fresh.example:80/announce\n")
|
||||
return data, validate(data)
|
||||
}
|
||||
err := fm.updateTrackers(context.Background(), host, fetcher.TriggerManual)
|
||||
if err == nil {
|
||||
t.Fatal("partial failure must be reported")
|
||||
}
|
||||
want := []string{"udp://new-custom.example:80/announce", "udp://fresh.example:80/announce", "udp://cached.example:80/announce"}
|
||||
if !reflect.DeepEqual(cfg.effectiveTrackers(), want) {
|
||||
t.Fatalf("got %v", cfg.effectiveTrackers())
|
||||
}
|
||||
success := cfg.State.TrackerSubscriptionStatuses["https://a.example/list"]
|
||||
failure := cfg.State.TrackerSubscriptionStatuses["https://b.example/list"]
|
||||
if !success.Success || success.TrackerCount != 1 || success.LastFetchTime.IsZero() || failure.Success || failure.TrackerCount != 0 || failure.LastFetchTime.IsZero() {
|
||||
t.Fatalf("unexpected subscription statuses: %+v", cfg.State.TrackerSubscriptionStatuses)
|
||||
}
|
||||
if !cfg.State.LastTrackerUpdateTime.IsZero() {
|
||||
t.Fatal("partial refresh marked fully successful")
|
||||
}
|
||||
}
|
||||
func TestTrackerRefreshDiscardsChangedSubscriptions(t *testing.T) {
|
||||
fm := &FetcherManager{}
|
||||
cfg := fm.DefaultConfig().(*config)
|
||||
cfg.TrackerSubscribeURLs = []string{"https://a.example/list"}
|
||||
host, mu := trackerTestHost(t, cfg, nil)
|
||||
host.Fetch = func(context.Context, string, int64, func([]byte) error) ([]byte, error) {
|
||||
mu.Lock()
|
||||
cfg.TrackerSubscribeURLs = []string{}
|
||||
mu.Unlock()
|
||||
return []byte("udp://fresh.example:80/announce"), nil
|
||||
}
|
||||
if err := fm.updateTrackers(context.Background(), host, fetcher.TriggerManual); !errors.Is(err, fetcher.ErrConfigChanged) {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(cfg.State.TrackerSources) != 0 {
|
||||
t.Fatal("outdated source was committed")
|
||||
}
|
||||
}
|
||||
func TestTrackerRefreshManualWorksWhenAutomaticDisabled(t *testing.T) {
|
||||
fm := &FetcherManager{}
|
||||
cfg := fm.DefaultConfig().(*config)
|
||||
cfg.AutoUpdateTrackers = false
|
||||
cfg.TrackerSubscribeURLs = []string{"https://a.example/list"}
|
||||
calls := 0
|
||||
host, _ := trackerTestHost(t, cfg, func(context.Context, string, int64, func([]byte) error) ([]byte, error) {
|
||||
calls++
|
||||
return []byte("udp://fresh.example:80/announce"), nil
|
||||
})
|
||||
if err := fm.updateTrackers(context.Background(), host, fetcher.TriggerScheduled); !errors.Is(err, fetcher.ErrJobSkipped) {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if calls != 0 {
|
||||
t.Fatal("scheduled job fetched while disabled")
|
||||
}
|
||||
if err := fm.updateTrackers(context.Background(), host, fetcher.TriggerManual); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if calls != 1 || cfg.State.LastTrackerUpdateTime.IsZero() {
|
||||
t.Fatal("manual refresh did not succeed")
|
||||
}
|
||||
}
|
||||
func TestTrackerConfigPreservesExplicitSettings(t *testing.T) {
|
||||
value, err := (&FetcherManager{}).NormalizeConfig(json.RawMessage(`{
|
||||
"listenPort":6881, "trackerSubscribeUrls":[], "autoUpdateTrackers":false,
|
||||
"customTrackers":["udp://custom.example:80/announce"]
|
||||
}`))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
cfg := value.(*config)
|
||||
if cfg.AutoUpdateTrackers || len(cfg.TrackerSubscribeURLs) != 0 || cfg.ListenPort != 6881 {
|
||||
t.Fatalf("%+v", cfg)
|
||||
}
|
||||
if !reflect.DeepEqual(cfg.effectiveTrackers(), cfg.CustomTrackers) {
|
||||
t.Fatalf("derived trackers: %v", cfg.effectiveTrackers())
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseTrackerList(t *testing.T) {
|
||||
got, err := parseTrackers([]byte("\ufeffudp://tracker.example:80/announce\r\n\n<html>error</html>\nhttps://tracker.example/announce\n"))
|
||||
if err != nil || len(got) != 2 {
|
||||
t.Fatalf("%v %v", got, err)
|
||||
}
|
||||
if _, err := parseTrackers([]byte("<html>mirror error</html>")); err == nil {
|
||||
t.Fatal("invalid mirror body accepted")
|
||||
}
|
||||
}
|
||||
func TestTrackerDefaultsRegisterDailyJob(t *testing.T) {
|
||||
jobs := (&FetcherManager{}).CronJobs(fetcher.ProtocolHost{})
|
||||
if len(jobs) != 1 || jobs[0].Name != "update-trackers" || jobs[0].Every != 24*time.Hour {
|
||||
t.Fatal(jobs)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTrackerRefreshAllFailuresPersistStatusesAndKeepCache(t *testing.T) {
|
||||
fm := &FetcherManager{}
|
||||
cfg := fm.DefaultConfig().(*config)
|
||||
address := "https://custom.example/trackers.txt"
|
||||
cfg.TrackerSubscribeURLs = []string{address}
|
||||
cfg.State.TrackerSources[address] = []string{"udp://cached.example:80/announce"}
|
||||
cfg.State.TrackerSubscriptionStatuses[address] = trackerSubscriptionStatus{Success: true, TrackerCount: 1, LastFetchTime: time.Now().Add(-time.Hour)}
|
||||
host, _ := trackerTestHost(t, cfg, func(context.Context, string, int64, func([]byte) error) ([]byte, error) {
|
||||
return nil, errors.New("offline")
|
||||
})
|
||||
before := time.Now()
|
||||
if err := fm.updateTrackers(context.Background(), host, fetcher.TriggerManual); err == nil {
|
||||
t.Fatal("failure was not reported")
|
||||
}
|
||||
status, ok := cfg.State.TrackerSubscriptionStatuses[address]
|
||||
if !ok || status.Success || status.TrackerCount != 0 || status.LastFetchTime.Before(before) {
|
||||
t.Fatalf("failed fetch status not stored: %+v", status)
|
||||
}
|
||||
if !reflect.DeepEqual(cfg.State.TrackerSources[address], []string{"udp://cached.example:80/announce"}) {
|
||||
t.Fatal("last good cache lost")
|
||||
}
|
||||
if !reflect.DeepEqual(cfg.effectiveTrackers(), cfg.State.TrackerSources[address]) {
|
||||
t.Fatal("cached trackers not retained")
|
||||
}
|
||||
}
|
||||
|
||||
func TestTrackerConfigPrunesRemovedSubscriptions(t *testing.T) {
|
||||
fm := &FetcherManager{}
|
||||
cfg := fm.DefaultConfig().(*config)
|
||||
address := "https://custom.example/trackers.txt"
|
||||
cfg.TrackerSubscribeURLs = []string{}
|
||||
cfg.State.TrackerSources[address] = []string{"udp://cached.example:80/announce"}
|
||||
cfg.State.TrackerSubscriptionStatuses[address] = trackerSubscriptionStatus{Success: true, TrackerCount: 1, LastFetchTime: time.Now()}
|
||||
raw, _ := json.Marshal(cfg)
|
||||
value, err := fm.NormalizeConfig(raw)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
next := value.(*config)
|
||||
if len(next.State.TrackerSources) != 0 || len(next.State.TrackerSubscriptionStatuses) != 0 || len(next.effectiveTrackers()) != 0 {
|
||||
t.Fatal("removed subscription still owns cached results")
|
||||
}
|
||||
}
|
||||
|
||||
func TestTrackerRefreshTimeoutPersistsCompletedAttemptStatus(t *testing.T) {
|
||||
fm := &FetcherManager{}
|
||||
cfg := fm.DefaultConfig().(*config)
|
||||
address := "https://custom.example/trackers.txt"
|
||||
cfg.TrackerSubscribeURLs = []string{address}
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
host, _ := trackerTestHost(t, cfg, func(context.Context, string, int64, func([]byte) error) ([]byte, error) {
|
||||
cancel()
|
||||
return nil, context.DeadlineExceeded
|
||||
})
|
||||
if err := fm.updateTrackers(ctx, host, fetcher.TriggerManual); !errors.Is(err, context.DeadlineExceeded) {
|
||||
t.Fatal(err)
|
||||
}
|
||||
status, ok := cfg.State.TrackerSubscriptionStatuses[address]
|
||||
if !ok || status.Success || status.TrackerCount != 0 || status.LastFetchTime.IsZero() {
|
||||
t.Fatalf("timeout status lost: %+v", status)
|
||||
}
|
||||
}
|
||||
@@ -67,17 +67,14 @@ func (f *Fetcher) Setup(ctl *controller.Controller) {
|
||||
f.ctl.GetConfig(&f.config)
|
||||
}
|
||||
|
||||
func (f *Fetcher) Resolve(req *base.Request, opts *base.Options) error {
|
||||
func (f *Fetcher) Resolve(req *base.Request) error {
|
||||
link, err := parseLink(req.URL)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
f.meta.Req = req
|
||||
f.meta.Opts = opts
|
||||
if f.meta.Opts == nil {
|
||||
f.meta.Opts = &base.Options{}
|
||||
}
|
||||
|
||||
f.meta.Res = buildResource(link)
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -13,7 +13,8 @@ func TestFetcher_Resolve(t *testing.T) {
|
||||
f := (&FetcherManager{}).Build()
|
||||
f.Setup(controller.NewController())
|
||||
|
||||
err := f.Resolve(&base.Request{URL: testLink}, &base.Options{Path: t.TempDir()})
|
||||
f.Meta().Opts = &base.Options{Path: t.TempDir()}
|
||||
err := f.Resolve(&base.Request{URL: testLink})
|
||||
if err != nil {
|
||||
t.Fatalf("Resolve() error = %v", err)
|
||||
}
|
||||
|
||||
@@ -172,11 +172,11 @@ func (f *Fetcher) Meta() *fetcher.FetcherMeta {
|
||||
return f.meta
|
||||
}
|
||||
|
||||
func (f *Fetcher) Resolve(req *base.Request, opts *base.Options) (err error) {
|
||||
func (f *Fetcher) Resolve(req *base.Request) (err error) {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
f.meta.Req = req
|
||||
f.meta.Opts = opts
|
||||
|
||||
f.extra = &ReqExtra{}
|
||||
if err = base.ParseReqExtra[ReqExtra](req); err != nil {
|
||||
return
|
||||
@@ -194,10 +194,6 @@ func (f *Fetcher) Resolve(req *base.Request, opts *base.Options) (err error) {
|
||||
}
|
||||
state.StagingID = randomStagingID()
|
||||
f.state = state
|
||||
// Pin the staging folder now so the very first persisted state already
|
||||
// carries it: a crash right after create still resumes into the same
|
||||
// folder.
|
||||
f.resolveStagingDirLocked()
|
||||
size := planTotalSize(state.Segments)
|
||||
f.meta.Res = &base.Resource{
|
||||
Range: false,
|
||||
|
||||
@@ -80,7 +80,8 @@ func mustResolve(t *testing.T, f *Fetcher, playlistURL, referer string) {
|
||||
if referer != "" {
|
||||
req.Extra = &ReqExtra{Header: map[string]string{"Referer": referer}}
|
||||
}
|
||||
if err := f.Resolve(req, &base.Options{Path: t.TempDir()}); err != nil {
|
||||
f.Meta().Opts = &base.Options{Path: t.TempDir()}
|
||||
if err := f.Resolve(req); err != nil {
|
||||
t.Fatalf("resolve: %v", err)
|
||||
}
|
||||
}
|
||||
@@ -207,7 +208,8 @@ func TestFetcherE2E_RefererEnforced(t *testing.T) {
|
||||
// Without Referer the resolve itself must fail (403 on the playlist).
|
||||
blocked := newTestFetcher(t, testConfig())
|
||||
req := &base.Request{URL: server.URL + "/video.m3u8"}
|
||||
if err := blocked.Resolve(req, &base.Options{Path: t.TempDir()}); err == nil {
|
||||
blocked.Meta().Opts = &base.Options{Path: t.TempDir()}
|
||||
if err := blocked.Resolve(req); err == nil {
|
||||
t.Fatal("resolve without Referer should fail")
|
||||
}
|
||||
|
||||
@@ -217,7 +219,8 @@ func TestFetcherE2E_RefererEnforced(t *testing.T) {
|
||||
URL: server.URL + "/video.m3u8",
|
||||
Extra: &ReqExtra{Header: map[string]string{"Referer": page}},
|
||||
}
|
||||
if err := allowed.Resolve(allowedReq, &base.Options{Path: t.TempDir()}); err != nil {
|
||||
allowed.Meta().Opts = &base.Options{Path: t.TempDir()}
|
||||
if err := allowed.Resolve(allowedReq); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
mustRun(t, allowed)
|
||||
@@ -560,7 +563,8 @@ func TestFetcherE2E_StoreRestoreAuthenticated(t *testing.T) {
|
||||
URL: server.URL + "/video.m3u8",
|
||||
Extra: &ReqExtra{Header: map[string]string{"Referer": page}},
|
||||
}
|
||||
if err := f.Resolve(req, &base.Options{Path: t.TempDir()}); err != nil {
|
||||
f.Meta().Opts = &base.Options{Path: t.TempDir()}
|
||||
if err := f.Resolve(req); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := f.Start(); err != nil {
|
||||
@@ -630,7 +634,8 @@ func TestFetcherE2E_PatchPartialUpdate(t *testing.T) {
|
||||
Extra: &ReqExtra{Header: map[string]string{"Referer": page}},
|
||||
}
|
||||
opts := &base.Options{Path: t.TempDir()}
|
||||
if err := f.Resolve(req, opts); err != nil {
|
||||
f.Meta().Opts = opts
|
||||
if err := f.Resolve(req); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
@@ -713,7 +718,8 @@ func TestFetcherE2E_PatchWhileRunningRejected(t *testing.T) {
|
||||
defer server.Close()
|
||||
|
||||
f := newTestFetcher(t, testConfig())
|
||||
if err := f.Resolve(&base.Request{URL: server.URL + "/video.m3u8"}, &base.Options{Path: t.TempDir()}); err != nil {
|
||||
f.Meta().Opts = &base.Options{Path: t.TempDir()}
|
||||
if err := f.Resolve(&base.Request{URL: server.URL + "/video.m3u8"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := f.Start(); err != nil {
|
||||
@@ -767,7 +773,8 @@ func TestFetcherE2E_RetryAfterFailure(t *testing.T) {
|
||||
cfg.MaxRetries = 0 // fail the run on the first bad segment
|
||||
cfg.PrefetchContentLength = false
|
||||
f := newTestFetcher(t, cfg)
|
||||
if err := f.Resolve(&base.Request{URL: server.URL + "/video.m3u8"}, &base.Options{Path: t.TempDir()}); err != nil {
|
||||
f.Meta().Opts = &base.Options{Path: t.TempDir()}
|
||||
if err := f.Resolve(&base.Request{URL: server.URL + "/video.m3u8"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := f.Start(); err != nil {
|
||||
@@ -828,7 +835,8 @@ func TestFetcherE2E_StateMissingSelfHeal(t *testing.T) {
|
||||
defer server.Close()
|
||||
|
||||
f := newTestFetcher(t, testConfig())
|
||||
if err := f.Resolve(&base.Request{URL: server.URL + "/video.m3u8"}, &base.Options{Path: t.TempDir()}); err != nil {
|
||||
f.Meta().Opts = &base.Options{Path: t.TempDir()}
|
||||
if err := f.Resolve(&base.Request{URL: server.URL + "/video.m3u8"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// Simulate the crash window where the engine persisted the task with its
|
||||
|
||||
@@ -189,7 +189,8 @@ func TestHLSRealMedia_TS(t *testing.T) {
|
||||
defer server.Close()
|
||||
|
||||
f := newTestFetcher(t, testConfig())
|
||||
if err := f.Resolve(&base.Request{URL: server.URL + "/video.m3u8"}, &base.Options{Path: t.TempDir()}); err != nil {
|
||||
f.Meta().Opts = &base.Options{Path: t.TempDir()}
|
||||
if err := f.Resolve(&base.Request{URL: server.URL + "/video.m3u8"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := f.Start(); err != nil {
|
||||
@@ -225,7 +226,8 @@ func TestHLSRealMedia_FMP4(t *testing.T) {
|
||||
defer server.Close()
|
||||
|
||||
f := newTestFetcher(t, testConfig())
|
||||
if err := f.Resolve(&base.Request{URL: server.URL + "/video.m3u8"}, &base.Options{Path: t.TempDir()}); err != nil {
|
||||
f.Meta().Opts = &base.Options{Path: t.TempDir()}
|
||||
if err := f.Resolve(&base.Request{URL: server.URL + "/video.m3u8"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := f.Start(); err != nil {
|
||||
@@ -274,7 +276,8 @@ func TestHLSRealMedia_AES128(t *testing.T) {
|
||||
defer server.Close()
|
||||
|
||||
f := newTestFetcher(t, testConfig())
|
||||
if err := f.Resolve(&base.Request{URL: server.URL + "/video.m3u8"}, &base.Options{Path: t.TempDir()}); err != nil {
|
||||
f.Meta().Opts = &base.Options{Path: t.TempDir()}
|
||||
if err := f.Resolve(&base.Request{URL: server.URL + "/video.m3u8"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if f.meta.Res.Size != 0 {
|
||||
|
||||
@@ -50,15 +50,20 @@ func TestFetcherE2E_SameURLTwoTasks(t *testing.T) {
|
||||
// user duplicating a task. The engine would rename the second output
|
||||
// (AutoRename); mirror that here.
|
||||
f1 := newTestFetcher(t, cfg)
|
||||
if err := f1.Resolve(&base.Request{URL: server.URL + "/video.m3u8"}, &base.Options{Path: dir}); err != nil {
|
||||
f1.Meta().Opts = &base.Options{Path: dir}
|
||||
if err := f1.Resolve(&base.Request{URL: server.URL + "/video.m3u8"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
f2 := newTestFetcher(t, cfg)
|
||||
if err := f2.Resolve(&base.Request{URL: server.URL + "/video.m3u8"}, &base.Options{Path: dir, Name: "video-2.ts"}); err != nil {
|
||||
f2.Meta().Opts = &base.Options{Path: dir, Name: "video-2.ts"}
|
||||
if err := f2.Resolve(&base.Request{URL: server.URL + "/video.m3u8"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if f1.state.StagingDir == "" || f1.state.StagingDir == f2.state.StagingDir {
|
||||
t.Fatalf("staging dirs must be distinct per task: %q vs %q", f1.state.StagingDir, f2.state.StagingDir)
|
||||
if f1.state.StagingID == "" || f1.state.StagingID == f2.state.StagingID {
|
||||
t.Fatal("staging identities must be distinct per task")
|
||||
}
|
||||
if f1.state.StagingDir != "" || f2.state.StagingDir != "" {
|
||||
t.Fatal("Resolve pinned an output directory")
|
||||
}
|
||||
|
||||
// Start task 1, let it stage some segments, then pause it.
|
||||
@@ -135,7 +140,8 @@ func TestFetcherE2E_ForeignJournalDiscarded(t *testing.T) {
|
||||
cfg := testConfig()
|
||||
cfg.PrefetchContentLength = false
|
||||
f := newTestFetcher(t, cfg)
|
||||
if err := f.Resolve(&base.Request{URL: server.URL + "/video.m3u8"}, &base.Options{Path: t.TempDir()}); err != nil {
|
||||
f.Meta().Opts = &base.Options{Path: t.TempDir()}
|
||||
if err := f.Resolve(&base.Request{URL: server.URL + "/video.m3u8"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// Plant a journal that does not belong to this plan (wrong fingerprint).
|
||||
@@ -176,7 +182,8 @@ func TestFetcherE2E_MergeFailurePreservesStaging(t *testing.T) {
|
||||
defer server.Close()
|
||||
|
||||
f := newTestFetcher(t, testConfig())
|
||||
if err := f.Resolve(&base.Request{URL: server.URL + "/video.m3u8"}, &base.Options{Path: t.TempDir()}); err != nil {
|
||||
f.Meta().Opts = &base.Options{Path: t.TempDir()}
|
||||
if err := f.Resolve(&base.Request{URL: server.URL + "/video.m3u8"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
run := &hlsRun{
|
||||
@@ -250,7 +257,8 @@ func TestFetcherE2E_ReMergeOverExistingOutput(t *testing.T) {
|
||||
defer server.Close()
|
||||
|
||||
f := newTestFetcher(t, testConfig())
|
||||
if err := f.Resolve(&base.Request{URL: server.URL + "/video.m3u8"}, &base.Options{Path: t.TempDir()}); err != nil {
|
||||
f.Meta().Opts = &base.Options{Path: t.TempDir()}
|
||||
if err := f.Resolve(&base.Request{URL: server.URL + "/video.m3u8"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
tempDir := f.resolveStagingDirLocked()
|
||||
|
||||
+103
-127
@@ -39,6 +39,11 @@ const (
|
||||
stealMinChunkSize = 512 * 1024 // Min steal size: 512KB (avoid tiny chunks)
|
||||
)
|
||||
|
||||
// connReadBufferSize is the read buffer each connection allocates once and
|
||||
// reuses for its lifetime. 64 KiB keeps per-read overhead low on fast links;
|
||||
// at 256 connections it is 16 MiB per task.
|
||||
const connReadBufferSize = 64 * 1024
|
||||
|
||||
var (
|
||||
errRangeRequestIgnored = errors.New("server ignored HTTP range request")
|
||||
errInvalidRangeResponse = errors.New("invalid HTTP range response")
|
||||
@@ -311,13 +316,17 @@ type Fetcher struct {
|
||||
resolveFallback atomic.Bool // Resolve prefetch is still available until the first Range request takes over
|
||||
|
||||
// Target file
|
||||
file *os.File
|
||||
fileMu sync.Mutex
|
||||
redirectURL string
|
||||
redirectLock sync.Mutex
|
||||
ifRange string
|
||||
rangeReprobeEligible bool
|
||||
rangeValidatorPinned bool
|
||||
file *os.File
|
||||
fileMu sync.Mutex
|
||||
redirectURL string
|
||||
redirectLock sync.Mutex
|
||||
ifRange string
|
||||
rangeReprobeEligible bool
|
||||
rangeValidatorPinned bool
|
||||
// sizeKnown distinguishes a display estimate from an HTTP-confirmed length,
|
||||
// including zero. Nil preserves the interpretation of legacy snapshots.
|
||||
sizeKnown *bool
|
||||
// An interrupted chunked sequential response must restart from byte zero.
|
||||
sequentialSizeUnknown bool
|
||||
|
||||
// Lifecycle control
|
||||
@@ -364,6 +373,40 @@ func (f *Fetcher) setState(s fetcherState) {
|
||||
f.state.Store(int32(s))
|
||||
}
|
||||
|
||||
// Size state is read under connMu or before download workers are started.
|
||||
func (f *Fetcher) hasKnownSize() bool {
|
||||
if f.sizeKnown != nil {
|
||||
return *f.sizeKnown
|
||||
}
|
||||
return f.meta != nil && f.meta.Res != nil && f.meta.Res.Size > 0 && !f.sequentialSizeUnknown
|
||||
}
|
||||
|
||||
func (f *Fetcher) knownSize() int64 {
|
||||
if f.hasKnownSize() {
|
||||
return f.meta.Res.Size
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
func (f *Fetcher) sizeHint() int64 {
|
||||
if f.meta.Opts != nil && f.meta.Opts.Size > 0 {
|
||||
return f.meta.Opts.Size
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
// setResourceSizeLocked publishes the value and its authority in one snapshot.
|
||||
func (f *Fetcher) setResourceSizeLocked(size int64, known bool) {
|
||||
if !known {
|
||||
size = f.sizeHint()
|
||||
}
|
||||
f.sizeKnown = &known
|
||||
f.meta.Res.Size = size
|
||||
if len(f.meta.Res.Files) > 0 && f.meta.Res.Files[0] != nil {
|
||||
f.meta.Res.Files[0].Size = size
|
||||
}
|
||||
}
|
||||
|
||||
// updateMaxConnTime updates maxConnTime if the new duration is larger
|
||||
func (f *Fetcher) updateMaxConnTime(d time.Duration) {
|
||||
newVal := int64(d)
|
||||
@@ -372,19 +415,11 @@ func (f *Fetcher) updateMaxConnTime(d time.Duration) {
|
||||
}
|
||||
}
|
||||
|
||||
func (f *Fetcher) Resolve(req *base.Request, opts *base.Options) error {
|
||||
func (f *Fetcher) Resolve(req *base.Request) error {
|
||||
if err := base.ParseReqExtra[fhttp.ReqExtra](req); err != nil {
|
||||
return err
|
||||
}
|
||||
f.meta.Req = req
|
||||
f.meta.Opts = opts
|
||||
if f.meta.Opts == nil {
|
||||
f.meta.Opts = &base.Options{}
|
||||
}
|
||||
|
||||
if err := f.initOptions(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
f.setState(stateResolving)
|
||||
|
||||
@@ -450,6 +485,13 @@ func (f *Fetcher) Resolve(req *base.Request, opts *base.Options) error {
|
||||
}
|
||||
}
|
||||
}
|
||||
// A hint is only a display value, even when the origin advertises Range.
|
||||
known := resp.StatusCode == base.HttpCodePartialContent || resp.ContentLength >= 0
|
||||
f.sizeKnown = &known
|
||||
f.sequentialSizeUnknown = !res.Range && !known
|
||||
if !known {
|
||||
res.Size = f.sizeHint()
|
||||
}
|
||||
|
||||
// Parse last modified time
|
||||
var lastModifiedTime *time.Time
|
||||
@@ -693,6 +735,9 @@ func (f *Fetcher) prepareFullRedownload() {
|
||||
|
||||
// Options may be replaced between Resolve and Start when creating a resolved task.
|
||||
func (f *Fetcher) initOptions() error {
|
||||
if f.meta.Opts == nil {
|
||||
f.meta.Opts = &base.Options{}
|
||||
}
|
||||
opts := f.meta.Opts
|
||||
// Parse options
|
||||
if err := base.ParseOptExtra[fhttp.OptsExtra](opts); err != nil {
|
||||
@@ -803,7 +848,7 @@ func (f *Fetcher) doStart() error {
|
||||
_, err = os.Stat(name)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
file, err = f.ctl.Touch(name, f.meta.Res.Size)
|
||||
file, err = f.ctl.Touch(name, f.knownSize())
|
||||
} else {
|
||||
return err
|
||||
}
|
||||
@@ -821,7 +866,7 @@ func (f *Fetcher) doStart() error {
|
||||
// For non-range resources, the response will be used directly
|
||||
var prefetchedBytes int64
|
||||
if f.meta.Res.Range {
|
||||
resolveComplete := f.meta.Res.Size > 0 && f.prefetchDone.Load() && f.prefetchErr == nil && f.prefetchSize.Load() >= f.meta.Res.Size
|
||||
resolveComplete := f.knownSize() > 0 && f.prefetchDone.Load() && f.prefetchErr == nil && f.prefetchSize.Load() >= f.knownSize()
|
||||
if f.resolveFallback.Load() && !resolveComplete {
|
||||
// Keep Resolve downloading until the first Range response has been
|
||||
// validated. The Range request starts at a stable snapshot; a valid
|
||||
@@ -993,7 +1038,7 @@ func (f *Fetcher) expandConnections() {
|
||||
return
|
||||
}
|
||||
|
||||
totalSize := f.meta.Res.Size
|
||||
totalSize := f.knownSize()
|
||||
|
||||
f.connMu.Lock()
|
||||
|
||||
@@ -1141,7 +1186,7 @@ func (f *Fetcher) runConnection(conn *connection) {
|
||||
|
||||
// Use fast-fail client for quick retry during download phase
|
||||
client := f.buildFastFailClient()
|
||||
buf := make([]byte, 8192)
|
||||
buf := make([]byte, connReadBufferSize)
|
||||
|
||||
retries := 0
|
||||
f.connMu.Lock()
|
||||
@@ -1284,7 +1329,7 @@ func (f *Fetcher) downloadChunkOnce(conn *connection, client *http.Client, buf [
|
||||
rangeMode := f.meta.Res.Range
|
||||
ifRange := ""
|
||||
if resumeProbe {
|
||||
end := f.meta.Res.Size - 1
|
||||
end := f.knownSize() - 1
|
||||
rangeEnd = &end
|
||||
ifRange = f.ifRange
|
||||
} else if rangeMode && (f.rangeValidatorPinned || f.resolveFallback.Load()) {
|
||||
@@ -1364,7 +1409,7 @@ func (f *Fetcher) downloadChunkOnce(conn *connection, client *http.Client, buf [
|
||||
}
|
||||
if rangeRequested && resp.StatusCode == base.HttpCodePartialContent {
|
||||
var responseTotal int64
|
||||
expectedResponseLength, responseTotal, err = validateRangeResponse(resp, rangeStart, rangeEnd, f.meta.Res.Size)
|
||||
expectedResponseLength, responseTotal, err = validateRangeResponse(resp, rangeStart, rangeEnd, f.knownSize())
|
||||
if err != nil {
|
||||
resp.Body.Close()
|
||||
return err
|
||||
@@ -1389,7 +1434,7 @@ func (f *Fetcher) downloadChunkOnce(conn *connection, client *http.Client, buf [
|
||||
f.rangeValidatorPinned = true
|
||||
f.sequentialSizeUnknown = false
|
||||
conn.Chunk.Begin = 0
|
||||
conn.Chunk.setEnd(f.meta.Res.Size - 1)
|
||||
conn.Chunk.setEnd(f.knownSize() - 1)
|
||||
f.connMu.Unlock()
|
||||
}
|
||||
}
|
||||
@@ -1414,7 +1459,7 @@ func (f *Fetcher) downloadChunkOnce(conn *connection, client *http.Client, buf [
|
||||
resp.Body.Close()
|
||||
return err
|
||||
}
|
||||
} else if f.meta.Res.Size > 0 {
|
||||
} else if f.hasKnownSize() && f.meta.Res.Size > 0 {
|
||||
expectedResponseLength = f.meta.Res.Size
|
||||
if resp.ContentLength >= 0 && resp.ContentLength != expectedResponseLength {
|
||||
resp.Body.Close()
|
||||
@@ -1550,7 +1595,7 @@ func (f *Fetcher) commitResolveTakeover(conn *connection, requestedStart int64)
|
||||
}
|
||||
|
||||
prefetched := f.stopPrefetchAndCopyData()
|
||||
if prefetched < requestedStart || prefetched > f.meta.Res.Size {
|
||||
if prefetched < requestedStart || prefetched > f.knownSize() {
|
||||
return 0, fmt.Errorf("resolve prefetch advanced to invalid offset %d for range start %d", prefetched, requestedStart)
|
||||
}
|
||||
f.resolveDataPos.Store(prefetched)
|
||||
@@ -1581,7 +1626,7 @@ func (f *Fetcher) completeFromResolveFallback(conn *connection) bool {
|
||||
}
|
||||
time.Sleep(10 * time.Millisecond)
|
||||
}
|
||||
knownSize := f.meta.Res.Size > 0
|
||||
knownSize := f.hasKnownSize()
|
||||
if f.prefetchErr != nil || (knownSize && f.prefetchSize.Load() != f.meta.Res.Size) {
|
||||
f.cleanupPrefetchFile()
|
||||
return false
|
||||
@@ -1595,10 +1640,7 @@ func (f *Fetcher) completeFromResolveFallback(conn *connection) bool {
|
||||
|
||||
f.connMu.Lock()
|
||||
if !knownSize {
|
||||
f.meta.Res.Size = prefetched
|
||||
if len(f.meta.Res.Files) > 0 && f.meta.Res.Files[0] != nil {
|
||||
f.meta.Res.Files[0].Size = prefetched
|
||||
}
|
||||
f.setResourceSizeLocked(prefetched, true)
|
||||
}
|
||||
f.meta.Res.Range = false
|
||||
f.rangeReprobeEligible = false
|
||||
@@ -1680,10 +1722,7 @@ func (f *Fetcher) resolveOpenEndedRange(conn *connection, totalSize int64) error
|
||||
f.connMu.Unlock()
|
||||
return err
|
||||
}
|
||||
f.meta.Res.Size = totalSize
|
||||
if len(f.meta.Res.Files) > 0 && f.meta.Res.Files[0] != nil {
|
||||
f.meta.Res.Files[0].Size = totalSize
|
||||
}
|
||||
f.setResourceSizeLocked(totalSize, true)
|
||||
conn.Chunk.setEnd(totalSize - 1)
|
||||
f.connMu.Unlock()
|
||||
return nil
|
||||
@@ -1755,10 +1794,7 @@ func (f *Fetcher) resizeSequentialResource(size int64) error {
|
||||
return err
|
||||
}
|
||||
|
||||
f.meta.Res.Size = size
|
||||
if len(f.meta.Res.Files) > 0 {
|
||||
f.meta.Res.Files[0].Size = size
|
||||
}
|
||||
f.setResourceSizeLocked(size, true)
|
||||
f.sequentialSizeUnknown = false
|
||||
f.connMu.Unlock()
|
||||
return nil
|
||||
@@ -1783,10 +1819,7 @@ func (f *Fetcher) beginUnknownSizeSequentialResource() error {
|
||||
return err
|
||||
}
|
||||
|
||||
f.meta.Res.Size = 0
|
||||
if len(f.meta.Res.Files) > 0 {
|
||||
f.meta.Res.Files[0].Size = 0
|
||||
}
|
||||
f.setResourceSizeLocked(0, false)
|
||||
f.sequentialSizeUnknown = true
|
||||
f.ifRange = ""
|
||||
f.rangeValidatorPinned = false
|
||||
@@ -1803,7 +1836,7 @@ func (f *Fetcher) runConnectionWithResolveResp(conn *connection) {
|
||||
conn.State = connConnecting
|
||||
f.connMu.Unlock()
|
||||
|
||||
buf := make([]byte, 8192)
|
||||
buf := make([]byte, connReadBufferSize)
|
||||
|
||||
// Get the resolve response
|
||||
f.resolveRespLock.Lock()
|
||||
@@ -1870,11 +1903,9 @@ func (f *Fetcher) runConnectionWithResolveResp(conn *connection) {
|
||||
if err != nil {
|
||||
if err == io.EOF {
|
||||
f.connMu.Lock()
|
||||
if f.meta.Res.Size == 0 {
|
||||
f.meta.Res.Size = conn.Downloaded
|
||||
if len(f.meta.Res.Files) > 0 && f.meta.Res.Files[0] != nil {
|
||||
f.meta.Res.Files[0].Size = conn.Downloaded
|
||||
}
|
||||
if !f.hasKnownSize() {
|
||||
f.setResourceSizeLocked(conn.Downloaded, true)
|
||||
f.sequentialSizeUnknown = false
|
||||
}
|
||||
conn.Completed = true
|
||||
conn.State = connCompleted
|
||||
@@ -1897,7 +1928,7 @@ func (f *Fetcher) runConnectionWithResolveResp(conn *connection) {
|
||||
func (f *Fetcher) runConnectionFallback(conn *connection) {
|
||||
// Use fast-fail client for quick retry during download phase
|
||||
client := f.buildFastFailClient()
|
||||
buf := make([]byte, 8192)
|
||||
buf := make([]byte, connReadBufferSize)
|
||||
|
||||
retries := 0
|
||||
countedRetries := 0
|
||||
@@ -1913,78 +1944,15 @@ func (f *Fetcher) runConnectionFallback(conn *connection) {
|
||||
}
|
||||
|
||||
f.connMu.Lock()
|
||||
if retries > 0 {
|
||||
f.resetConnectionForRestart(conn)
|
||||
}
|
||||
conn.State = connConnecting
|
||||
f.connMu.Unlock()
|
||||
|
||||
err := func() error {
|
||||
httpReq, err := f.buildRequest(conn.ctx, f.meta.Req)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Record connection start time for adaptive timeout tracking
|
||||
connStartTime := time.Now()
|
||||
|
||||
resp, err := client.Do(httpReq)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != base.HttpCodeOK && resp.StatusCode != base.HttpCodePartialContent {
|
||||
return NewRequestError(resp.StatusCode)
|
||||
}
|
||||
|
||||
// Record successful connection time for adaptive timeout
|
||||
f.updateMaxConnTime(time.Since(connStartTime))
|
||||
|
||||
f.connMu.Lock()
|
||||
conn.State = connDownloading
|
||||
conn.failed = false
|
||||
f.connMu.Unlock()
|
||||
|
||||
f.primaryReadyOnce.Do(func() {
|
||||
close(f.primaryReadyCh)
|
||||
})
|
||||
if f.slowStart != nil {
|
||||
f.slowStart.onConnectSuccess()
|
||||
}
|
||||
|
||||
reader := f.responseReader(resp)
|
||||
for {
|
||||
if conn.ctx.Err() != nil {
|
||||
return conn.ctx.Err()
|
||||
}
|
||||
|
||||
n, err := reader.Read(buf)
|
||||
if n > 0 {
|
||||
f.connMu.Lock()
|
||||
writeOffset := conn.Chunk.Downloaded
|
||||
f.connMu.Unlock()
|
||||
|
||||
f.fileMu.Lock()
|
||||
if f.file != nil {
|
||||
_, writeErr := f.file.WriteAt(buf[:n], writeOffset)
|
||||
if writeErr != nil {
|
||||
f.fileMu.Unlock()
|
||||
return writeErr
|
||||
}
|
||||
}
|
||||
f.fileMu.Unlock()
|
||||
|
||||
f.connMu.Lock()
|
||||
conn.Chunk.Downloaded += int64(n)
|
||||
conn.Downloaded += int64(n)
|
||||
f.connMu.Unlock()
|
||||
}
|
||||
if err != nil {
|
||||
if err == io.EOF {
|
||||
return nil
|
||||
}
|
||||
return err
|
||||
}
|
||||
}
|
||||
}()
|
||||
// Restored tasks without a reusable Resolve response need the same
|
||||
// length handling and restart semantics as other sequential requests.
|
||||
err := f.downloadChunkOnce(conn, client, buf)
|
||||
|
||||
if err == nil {
|
||||
f.connMu.Lock()
|
||||
@@ -2133,14 +2101,14 @@ func (f *Fetcher) resetConnectionForRestart(conn *connection) {
|
||||
// hasSequentialPrefixLocked reports whether a single sequential connection has
|
||||
// a useful contiguous prefix. The caller must hold connMu.
|
||||
func (f *Fetcher) hasSequentialPrefixLocked(conn *connection) bool {
|
||||
if f.meta == nil || f.meta.Res == nil || f.meta.Res.Range || !f.rangeReprobeEligible || f.meta.Res.Size <= 0 {
|
||||
if f.meta == nil || f.meta.Res == nil || f.meta.Res.Range || !f.rangeReprobeEligible || f.knownSize() <= 0 {
|
||||
return false
|
||||
}
|
||||
if conn == nil || conn.ID != 0 || len(f.connections) != 1 || conn.Chunk == nil {
|
||||
return false
|
||||
}
|
||||
downloaded := conn.Chunk.Downloaded
|
||||
return downloaded > 0 && downloaded < f.meta.Res.Size
|
||||
return downloaded > 0 && downloaded < f.knownSize()
|
||||
}
|
||||
|
||||
// canProbeSequentialResumeLocked reports whether the prefix can be resumed
|
||||
@@ -2226,7 +2194,7 @@ func (f *Fetcher) onDownloadComplete() {
|
||||
needsMoreData := false
|
||||
if f.meta.Res.Range {
|
||||
needsMoreData = conn.Chunk != nil && (conn.Chunk.openEnded() || conn.Chunk.remain() > 0)
|
||||
} else if f.meta.Res.Size > 0 {
|
||||
} else if f.hasKnownSize() && f.meta.Res.Size > 0 {
|
||||
needsMoreData = conn.Downloaded < f.meta.Res.Size
|
||||
} else {
|
||||
needsMoreData = !conn.Completed && conn.State != connCompleted
|
||||
@@ -2236,7 +2204,7 @@ func (f *Fetcher) onDownloadComplete() {
|
||||
// This connection has remaining work and isn't done
|
||||
// Check if it failed with 403 (server limit) - these can be ignored if other connections completed the work
|
||||
if conn.State == connFailed && conn.failed {
|
||||
if re := extractRequestError(conn.lastErr); re != nil && re.Code == 403 {
|
||||
if re := extractRequestError(conn.lastErr); re != nil && re.Code == 403 && f.hasKnownSize() {
|
||||
// 403 is server connection limit, check if other connections will complete this chunk
|
||||
continue
|
||||
}
|
||||
@@ -2247,7 +2215,7 @@ func (f *Fetcher) onDownloadComplete() {
|
||||
}
|
||||
|
||||
// If total downloaded matches file size, consider it a success regardless of connection failures
|
||||
downloadComplete := f.meta.Res.Size > 0 && totalDownloaded >= f.meta.Res.Size
|
||||
downloadComplete := f.hasKnownSize() && f.meta.Res.Size > 0 && totalDownloaded >= f.meta.Res.Size
|
||||
|
||||
// Integrity errors must win over byte-count completion. A malformed response
|
||||
// may be detected only after the expected bytes have already been written.
|
||||
@@ -2264,7 +2232,7 @@ func (f *Fetcher) onDownloadComplete() {
|
||||
for _, conn := range f.connections {
|
||||
if conn.State == connFailed && conn.failed {
|
||||
// Skip 403 errors (server connection limit) - these are expected when exceeding server's limit
|
||||
if re := extractRequestError(conn.lastErr); re != nil && re.Code == 403 {
|
||||
if re := extractRequestError(conn.lastErr); re != nil && re.Code == 403 && f.hasKnownSize() {
|
||||
continue
|
||||
}
|
||||
if re := extractRequestError(conn.lastErr); re != nil {
|
||||
@@ -2364,7 +2332,7 @@ func (f *Fetcher) checkCompletion() bool {
|
||||
totalDownloaded += conn.Downloaded
|
||||
}
|
||||
|
||||
if f.meta.Res.Size > 0 && totalDownloaded >= f.meta.Res.Size {
|
||||
if f.hasKnownSize() && f.meta.Res.Size > 0 && totalDownloaded >= f.meta.Res.Size {
|
||||
// Don't start a new goroutine - let the caller handle completion
|
||||
return true
|
||||
}
|
||||
@@ -2391,6 +2359,14 @@ func (f *Fetcher) checkCompletion() bool {
|
||||
|
||||
// Patch modifies the HTTP request information.
|
||||
func (f *Fetcher) Patch(req *base.Request, opts *base.Options) error {
|
||||
if opts != nil && opts.Size > 0 {
|
||||
f.connMu.Lock()
|
||||
f.meta.Opts.Size = opts.Size
|
||||
if f.meta.Res != nil && !f.hasKnownSize() {
|
||||
f.setResourceSizeLocked(0, false)
|
||||
}
|
||||
f.connMu.Unlock()
|
||||
}
|
||||
// Patch request info
|
||||
if req != nil {
|
||||
if req.URL != "" {
|
||||
@@ -2488,8 +2464,8 @@ func (f *Fetcher) Pause() error {
|
||||
conn := f.connections[0]
|
||||
if conn != nil && conn.ID == 0 && conn.Chunk != nil && !conn.Completed {
|
||||
conn.Chunk.Begin = prefetched
|
||||
if f.meta.Res.Size > 0 {
|
||||
conn.Chunk.setEnd(f.meta.Res.Size - 1)
|
||||
if f.knownSize() > 0 {
|
||||
conn.Chunk.setEnd(f.knownSize() - 1)
|
||||
} else {
|
||||
conn.Chunk.End = nil
|
||||
}
|
||||
|
||||
@@ -95,9 +95,10 @@ func TestFetcher_ChecksumMismatch_Retry(t *testing.T) {
|
||||
}
|
||||
|
||||
// 1. Initial attempt -> will download corruptContent, failing checksum verification
|
||||
if err := f.Resolve(&base.Request{URL: server.URL + "/retry_test.txt"}, opts); err != nil {
|
||||
if err := f.Resolve(&base.Request{URL: server.URL + "/retry_test.txt"}); err != nil {
|
||||
t.Fatalf("Resolve error: %v", err)
|
||||
}
|
||||
f.Meta().Opts = opts
|
||||
if err := f.Start(); err != nil {
|
||||
t.Fatalf("Start error: %v", err)
|
||||
}
|
||||
@@ -215,9 +216,10 @@ func TestFetcher_ChecksumMismatch_Retry_PartialPrefetch(t *testing.T) {
|
||||
}
|
||||
|
||||
// 1. Resolve phase
|
||||
if err := f.Resolve(&base.Request{URL: server.URL + "/prefetch_retry.txt"}, opts); err != nil {
|
||||
if err := f.Resolve(&base.Request{URL: server.URL + "/prefetch_retry.txt"}); err != nil {
|
||||
t.Fatalf("Resolve error: %v", err)
|
||||
}
|
||||
f.Meta().Opts = opts
|
||||
|
||||
// Wait until prefetch has received exactly 7 bytes
|
||||
for i := 0; i < 200; i++ {
|
||||
@@ -338,9 +340,10 @@ func TestFetcher_ChecksumMismatch_Retry_NonRange(t *testing.T) {
|
||||
}
|
||||
|
||||
// 1. Resolve and start initial attempt
|
||||
if err := f.Resolve(&base.Request{URL: server.URL + "/nonrange_retry.txt"}, opts); err != nil {
|
||||
if err := f.Resolve(&base.Request{URL: server.URL + "/nonrange_retry.txt"}); err != nil {
|
||||
t.Fatalf("Resolve error: %v", err)
|
||||
}
|
||||
f.Meta().Opts = opts
|
||||
if err := f.Start(); err != nil {
|
||||
t.Fatalf("Start error: %v", err)
|
||||
}
|
||||
|
||||
@@ -52,9 +52,11 @@ func TestFetcher_ChecksumVerification(t *testing.T) {
|
||||
},
|
||||
}
|
||||
|
||||
if err := f.Resolve(&base.Request{URL: server.URL + "/test.txt"}, opts); err != nil {
|
||||
if err := f.Resolve(&base.Request{URL: server.URL + "/test.txt"}); err != nil {
|
||||
t.Fatalf("Resolve error: %v", err)
|
||||
}
|
||||
|
||||
f.Meta().Opts = opts
|
||||
if err := f.Start(); err != nil {
|
||||
t.Fatalf("Start error: %v", err)
|
||||
}
|
||||
@@ -79,9 +81,11 @@ func TestFetcher_ChecksumVerification(t *testing.T) {
|
||||
},
|
||||
}
|
||||
|
||||
if err := f.Resolve(&base.Request{URL: server.URL + "/test.txt"}, opts); err != nil {
|
||||
if err := f.Resolve(&base.Request{URL: server.URL + "/test.txt"}); err != nil {
|
||||
t.Fatalf("Resolve error: %v", err)
|
||||
}
|
||||
|
||||
f.Meta().Opts = opts
|
||||
if err := f.Start(); err != nil {
|
||||
t.Fatalf("Start error: %v", err)
|
||||
}
|
||||
@@ -106,9 +110,11 @@ func TestFetcher_ChecksumVerification(t *testing.T) {
|
||||
},
|
||||
}
|
||||
|
||||
if err := f.Resolve(&base.Request{URL: server.URL + "/test.txt"}, opts); err != nil {
|
||||
if err := f.Resolve(&base.Request{URL: server.URL + "/test.txt"}); err != nil {
|
||||
t.Fatalf("Resolve error: %v", err)
|
||||
}
|
||||
|
||||
f.Meta().Opts = opts
|
||||
if err := f.Start(); err != nil {
|
||||
t.Fatalf("Start error: %v", err)
|
||||
}
|
||||
@@ -137,9 +143,13 @@ func TestFetcher_ChecksumVerification(t *testing.T) {
|
||||
},
|
||||
}
|
||||
|
||||
err := f.Resolve(&base.Request{URL: server.URL + "/test.txt"}, opts)
|
||||
if err := f.Resolve(&base.Request{URL: server.URL + "/test.txt"}); err != nil {
|
||||
t.Fatalf("Resolve error: %v", err)
|
||||
}
|
||||
f.Meta().Opts = opts
|
||||
err := f.Start()
|
||||
if err == nil {
|
||||
t.Fatal("Resolve expected error for unsupported algorithm, got nil")
|
||||
t.Fatal("Start expected error for unsupported algorithm, got nil")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "unsupported checksum algorithm") {
|
||||
t.Fatalf("expected 'unsupported checksum algorithm' in error, got %v", err)
|
||||
@@ -165,9 +175,11 @@ func TestFetcher_ChecksumVerification(t *testing.T) {
|
||||
},
|
||||
}
|
||||
|
||||
if err := f.Resolve(&base.Request{URL: server.URL + "/test.txt"}, opts); err != nil {
|
||||
if err := f.Resolve(&base.Request{URL: server.URL + "/test.txt"}); err != nil {
|
||||
t.Fatalf("Resolve error: %v", err)
|
||||
}
|
||||
|
||||
f.Meta().Opts = opts
|
||||
if err := f.Start(); err != nil {
|
||||
t.Fatalf("Start error: %v", err)
|
||||
}
|
||||
@@ -192,9 +204,11 @@ func TestFetcher_ChecksumVerification(t *testing.T) {
|
||||
},
|
||||
}
|
||||
|
||||
if err := f.Resolve(&base.Request{URL: server.URL + "/test.txt"}, opts); err != nil {
|
||||
if err := f.Resolve(&base.Request{URL: server.URL + "/test.txt"}); err != nil {
|
||||
t.Fatalf("Resolve error: %v", err)
|
||||
}
|
||||
|
||||
f.Meta().Opts = opts
|
||||
if err := f.Start(); err != nil {
|
||||
t.Fatalf("Start error: %v", err)
|
||||
}
|
||||
@@ -235,9 +249,11 @@ func TestFetcher_ChecksumVerification(t *testing.T) {
|
||||
},
|
||||
}
|
||||
|
||||
if err := f.Resolve(&base.Request{URL: largeServer.URL + "/large.bin"}, opts); err != nil {
|
||||
if err := f.Resolve(&base.Request{URL: largeServer.URL + "/large.bin"}); err != nil {
|
||||
t.Fatalf("Resolve error: %v", err)
|
||||
}
|
||||
|
||||
f.Meta().Opts = opts
|
||||
if err := f.Start(); err != nil {
|
||||
t.Fatalf("Start error: %v", err)
|
||||
}
|
||||
@@ -258,9 +274,11 @@ func TestFetcher_ChecksumVerification(t *testing.T) {
|
||||
},
|
||||
}
|
||||
|
||||
if err := f.Resolve(&base.Request{URL: server.URL + "/test.txt"}, opts); err != nil {
|
||||
if err := f.Resolve(&base.Request{URL: server.URL + "/test.txt"}); err != nil {
|
||||
t.Fatalf("Resolve error: %v", err)
|
||||
}
|
||||
|
||||
f.Meta().Opts = opts
|
||||
if err := f.Start(); err != nil {
|
||||
t.Fatalf("Start error: %v", err)
|
||||
}
|
||||
@@ -269,7 +287,7 @@ func TestFetcher_ChecksumVerification(t *testing.T) {
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("Invalid Checksum In Resolve Makes Zero Requests", func(t *testing.T) {
|
||||
t.Run("Invalid Checksum In Start Makes No Additional Requests", func(t *testing.T) {
|
||||
var reqReceived atomic.Int32
|
||||
customServer := httptest.NewServer(gohttp.HandlerFunc(func(w gohttp.ResponseWriter, r *gohttp.Request) {
|
||||
reqReceived.Add(1)
|
||||
@@ -293,12 +311,17 @@ func TestFetcher_ChecksumVerification(t *testing.T) {
|
||||
},
|
||||
}
|
||||
|
||||
err := f.Resolve(&base.Request{URL: customServer.URL + "/test.txt"}, opts)
|
||||
if err == nil {
|
||||
t.Fatal("Resolve expected error for invalid checksum hash, got nil")
|
||||
if err := f.Resolve(&base.Request{URL: customServer.URL + "/test.txt"}); err != nil {
|
||||
t.Fatalf("Resolve error: %v", err)
|
||||
}
|
||||
if reqReceived.Load() != 0 {
|
||||
t.Fatalf("expected 0 requests to server on invalid checksum in Resolve, got %d", reqReceived.Load())
|
||||
requestsAfterResolve := reqReceived.Load()
|
||||
f.Meta().Opts = opts
|
||||
err := f.Start()
|
||||
if err == nil {
|
||||
t.Fatal("Start expected error for invalid checksum hash, got nil")
|
||||
}
|
||||
if got := reqReceived.Load(); got != requestsAfterResolve {
|
||||
t.Fatalf("invalid checksum in Start issued additional requests: got %d, had %d after Resolve", got, requestsAfterResolve)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -19,10 +19,11 @@ type fetcherData struct {
|
||||
IfRange string // Strong ETag or Last-Modified validator for safe resume
|
||||
RangeReprobeEligible bool // Origin advertised Range before falling back to sequential mode
|
||||
RangeValidatorPinned bool // Recovered Range mode must keep the same If-Range validator
|
||||
SequentialSizeUnknown bool // Interrupted chunked restart must retry from byte zero
|
||||
SequentialSizeUnknown bool // Interrupted chunked sequential streams restart from byte zero
|
||||
SizeKnown *bool // Nil for legacy snapshots; false means Size is only a display estimate
|
||||
Range *bool // Authoritative Range mode; nil for records saved by older versions
|
||||
ResourceSize *int64 // Authoritative resource size from the same snapshot as Connections
|
||||
FileSize *int64 // Authoritative single-file size; nil for older records
|
||||
ResourceSize *int64 // Display size from the same snapshot; authority is recorded by SizeKnown
|
||||
FileSize *int64 // Single-file size from the same snapshot; nil for older records
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
@@ -93,6 +94,7 @@ func (fm *FetcherManager) Store(f fetcher.Fetcher) (data any, err error) {
|
||||
rangeReprobeEligible := _f.rangeReprobeEligible
|
||||
rangeValidatorPinned := _f.rangeValidatorPinned
|
||||
sequentialSizeUnknown := _f.sequentialSizeUnknown
|
||||
sizeKnown := _f.hasKnownSize()
|
||||
var rangeMode *bool
|
||||
var resourceSize *int64
|
||||
var fileSize *int64
|
||||
@@ -115,6 +117,7 @@ func (fm *FetcherManager) Store(f fetcher.Fetcher) (data any, err error) {
|
||||
RangeReprobeEligible: rangeReprobeEligible,
|
||||
RangeValidatorPinned: rangeValidatorPinned,
|
||||
SequentialSizeUnknown: sequentialSizeUnknown,
|
||||
SizeKnown: &sizeKnown,
|
||||
Range: rangeMode,
|
||||
ResourceSize: resourceSize,
|
||||
FileSize: fileSize,
|
||||
@@ -168,6 +171,10 @@ func (fm *FetcherManager) Restore() (v any, f func(meta *fetcher.FetcherMeta, v
|
||||
fetcher.rangeReprobeEligible = fd.RangeReprobeEligible
|
||||
fetcher.rangeValidatorPinned = fd.RangeValidatorPinned
|
||||
fetcher.sequentialSizeUnknown = fd.SequentialSizeUnknown
|
||||
if fd.SizeKnown != nil {
|
||||
known := *fd.SizeKnown
|
||||
fetcher.sizeKnown = &known
|
||||
}
|
||||
if fd.Range != nil && fetcher.meta.Res != nil {
|
||||
// Recovery-critical resource fields live in the same persisted snapshot
|
||||
// as Connections, making them authoritative over task metadata that may
|
||||
|
||||
@@ -233,10 +233,11 @@ func TestFetcher_ResolveFilenameFromContentType(t *testing.T) {
|
||||
defer fetcher.Pause()
|
||||
|
||||
const opaqueName = "OIP-C.u5nEXTXND3PJF0gNTgw5YgHaFP"
|
||||
err := fetcher.Resolve(&base.Request{URL: server.URL + "/" + opaqueName}, &base.Options{
|
||||
fetcher.Meta().Opts = &base.Options{
|
||||
Name: test.DownloadName,
|
||||
Path: test.Dir,
|
||||
})
|
||||
}
|
||||
err := fetcher.Resolve(&base.Request{URL: server.URL + "/" + opaqueName})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -251,6 +252,10 @@ func TestFetcher_ResolveWithHostHeader(t *testing.T) {
|
||||
defer listener.Close()
|
||||
|
||||
fetcher := buildFetcher()
|
||||
fetcher.Meta().Opts = &base.Options{
|
||||
Name: test.DownloadName,
|
||||
Path: test.Dir,
|
||||
}
|
||||
err := fetcher.Resolve(&base.Request{
|
||||
URL: "http://" + listener.Addr().String() + "/",
|
||||
Extra: &http.ReqExtra{
|
||||
@@ -258,9 +263,6 @@ func TestFetcher_ResolveWithHostHeader(t *testing.T) {
|
||||
"Host": "test",
|
||||
},
|
||||
},
|
||||
}, &base.Options{
|
||||
Name: test.DownloadName,
|
||||
Path: test.Dir,
|
||||
})
|
||||
// The server should return 400 for invalid Host header
|
||||
if err == nil || !strings.Contains(err.Error(), "400") {
|
||||
@@ -274,6 +276,10 @@ func TestFetcher_ResolveWithInvalidHeader(t *testing.T) {
|
||||
|
||||
fetcher := buildFetcher()
|
||||
defer fetcher.Pause() // Close the resolve response to allow server shutdown
|
||||
fetcher.Meta().Opts = &base.Options{
|
||||
Name: test.DownloadName,
|
||||
Path: test.Dir,
|
||||
}
|
||||
err := fetcher.Resolve(&base.Request{
|
||||
URL: "http://" + listener.Addr().String() + "/",
|
||||
Extra: &http.ReqExtra{
|
||||
@@ -281,9 +287,6 @@ func TestFetcher_ResolveWithInvalidHeader(t *testing.T) {
|
||||
"Referer": "\rtest",
|
||||
},
|
||||
},
|
||||
}, &base.Options{
|
||||
Name: test.DownloadName,
|
||||
Path: test.Dir,
|
||||
})
|
||||
// Invalid header with \r should be sanitized by Go's http client, allowing the request to succeed
|
||||
if err != nil {
|
||||
@@ -303,12 +306,13 @@ func TestFetcher_ResolveAutomaticallyFallsBackToBrowserFingerprint(t *testing.T)
|
||||
|
||||
fetcher := buildFetcher()
|
||||
defer fetcher.Pause()
|
||||
fetcher.Meta().Opts = &base.Options{
|
||||
Name: test.DownloadName,
|
||||
Path: test.Dir,
|
||||
}
|
||||
err := fetcher.Resolve(&base.Request{
|
||||
URL: server.URL + "/file.bin",
|
||||
SkipVerifyCert: true,
|
||||
}, &base.Options{
|
||||
Name: test.DownloadName,
|
||||
Path: test.Dir,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Resolve() error = %v", err)
|
||||
@@ -369,11 +373,12 @@ func testResolve(startTestServer func() net.Listener, path string, t *testing.T,
|
||||
defer listener.Close()
|
||||
fetcher := buildFetcher()
|
||||
defer fetcher.Pause() // Close the resolve response to allow server shutdown
|
||||
err := fetcher.Resolve(&base.Request{
|
||||
URL: "http://" + listener.Addr().String() + "/" + path,
|
||||
}, &base.Options{
|
||||
fetcher.Meta().Opts = &base.Options{
|
||||
Name: test.DownloadName,
|
||||
Path: test.Dir,
|
||||
}
|
||||
err := fetcher.Resolve(&base.Request{
|
||||
URL: "http://" + listener.Addr().String() + "/" + path,
|
||||
})
|
||||
want, err := wantFn(err)
|
||||
if err != nil {
|
||||
@@ -425,13 +430,14 @@ func TestFetcher_PauseBeforeStartDiscardsPrefetchProgress(t *testing.T) {
|
||||
defer server.Close()
|
||||
|
||||
fetcher := buildFetcher()
|
||||
if err := fetcher.Resolve(&base.Request{URL: server.URL + "/" + test.BuildName}, &base.Options{
|
||||
fetcher.Meta().Opts = &base.Options{
|
||||
Path: t.TempDir(),
|
||||
Name: test.DownloadName,
|
||||
Extra: &http.OptsExtra{
|
||||
Connections: 2,
|
||||
},
|
||||
}); err != nil {
|
||||
}
|
||||
if err := fetcher.Resolve(&base.Request{URL: server.URL + "/" + test.BuildName}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
select {
|
||||
@@ -772,13 +778,14 @@ func TestFetcher_SequentialRetryReprobesRange(t *testing.T) {
|
||||
defer server.Close()
|
||||
|
||||
f := buildFetcher()
|
||||
if err := f.Resolve(&base.Request{URL: server.URL + "/dynamic-range.data"}, &base.Options{
|
||||
f.Meta().Opts = &base.Options{
|
||||
Path: t.TempDir(),
|
||||
Name: "dynamic-range.data",
|
||||
Extra: &http.OptsExtra{
|
||||
Connections: 4,
|
||||
},
|
||||
}); err != nil {
|
||||
}
|
||||
if err := f.Resolve(&base.Request{URL: server.URL + "/dynamic-range.data"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := f.Start(); err != nil {
|
||||
@@ -884,13 +891,14 @@ func TestFetcher_InitialIgnoredRangeUsesResponseSize(t *testing.T) {
|
||||
defer releaseResolve()
|
||||
|
||||
f := buildFetcher()
|
||||
if err := f.Resolve(&base.Request{URL: server.URL + "/ignored-range.data"}, &base.Options{
|
||||
f.Meta().Opts = &base.Options{
|
||||
Path: t.TempDir(),
|
||||
Name: "ignored-range.data",
|
||||
Extra: &http.OptsExtra{
|
||||
Connections: 4,
|
||||
},
|
||||
}); err != nil {
|
||||
}
|
||||
if err := f.Resolve(&base.Request{URL: server.URL + "/ignored-range.data"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := f.Start(); err != nil {
|
||||
@@ -981,13 +989,14 @@ func TestFetcher_InterruptedChunkedReplacementRestartsFromZero(t *testing.T) {
|
||||
defer server.Close()
|
||||
|
||||
f := buildFetcher()
|
||||
if err := f.Resolve(&base.Request{URL: server.URL + "/chunked-replacement.data"}, &base.Options{
|
||||
f.Meta().Opts = &base.Options{
|
||||
Path: t.TempDir(),
|
||||
Name: "chunked-replacement.data",
|
||||
Extra: &http.OptsExtra{
|
||||
Connections: 4,
|
||||
},
|
||||
}); err != nil {
|
||||
}
|
||||
if err := f.Resolve(&base.Request{URL: server.URL + "/chunked-replacement.data"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := f.Start(); err != nil {
|
||||
@@ -1167,13 +1176,14 @@ func TestFetcher_UsesResolveResponseWhenRangeTakeoverFails(t *testing.T) {
|
||||
defer server.Close()
|
||||
|
||||
f := buildFetcher()
|
||||
if err := f.Resolve(&base.Request{URL: server.URL + "/resolve-fallback.data"}, &base.Options{
|
||||
f.Meta().Opts = &base.Options{
|
||||
Path: t.TempDir(),
|
||||
Name: "resolve-fallback.data",
|
||||
Extra: &http.OptsExtra{
|
||||
Connections: 1,
|
||||
},
|
||||
}); err != nil {
|
||||
}
|
||||
if err := f.Resolve(&base.Request{URL: server.URL + "/resolve-fallback.data"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer f.Close()
|
||||
@@ -1246,13 +1256,14 @@ func TestFetcher_ValidRangeTakesOverResolveResponse(t *testing.T) {
|
||||
defer server.Close()
|
||||
|
||||
f := buildFetcher()
|
||||
if err := f.Resolve(&base.Request{URL: server.URL + "/resolve-takeover.data"}, &base.Options{
|
||||
f.Meta().Opts = &base.Options{
|
||||
Path: t.TempDir(),
|
||||
Name: "resolve-takeover.data",
|
||||
Extra: &http.OptsExtra{
|
||||
Connections: 4,
|
||||
},
|
||||
}); err != nil {
|
||||
}
|
||||
if err := f.Resolve(&base.Request{URL: server.URL + "/resolve-takeover.data"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer f.Close()
|
||||
@@ -1276,6 +1287,12 @@ func TestFetcher_ValidRangeTakesOverResolveResponse(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestFetcher_UnknownSizeRangeUsesOpenEndedWorker(t *testing.T) {
|
||||
for _, hint := range []int64{0, 3, 8 * 1024 * 1024} {
|
||||
t.Run(fmt.Sprint(hint), func(t *testing.T) { testFetcherUnknownSizeRange(t, hint) })
|
||||
}
|
||||
}
|
||||
|
||||
func testFetcherUnknownSizeRange(t *testing.T, hint int64) {
|
||||
const resolveETag = `"unknown-size-v1"`
|
||||
payload := make([]byte, 4*1024*1024)
|
||||
for i := range payload {
|
||||
@@ -1340,18 +1357,20 @@ func TestFetcher_UnknownSizeRangeUsesOpenEndedWorker(t *testing.T) {
|
||||
defer server.Close()
|
||||
|
||||
f := buildFetcher()
|
||||
if err := f.Resolve(&base.Request{URL: server.URL + "/unknown-size-range.data"}, &base.Options{
|
||||
f.Meta().Opts = &base.Options{
|
||||
Size: hint,
|
||||
Path: t.TempDir(),
|
||||
Name: "unknown-size-range.data",
|
||||
Extra: &http.OptsExtra{
|
||||
Connections: 4,
|
||||
},
|
||||
}); err != nil {
|
||||
}
|
||||
if err := f.Resolve(&base.Request{URL: server.URL + "/unknown-size-range.data"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer f.Close()
|
||||
|
||||
if f.meta.Res.Size != 0 || !f.meta.Res.Range {
|
||||
if f.meta.Res.Size != hint || f.hasKnownSize() || !f.meta.Res.Range {
|
||||
t.Fatalf("resolved resource = %+v, want unknown size with Range support", f.meta.Res)
|
||||
}
|
||||
if err := f.Start(); err != nil {
|
||||
@@ -1434,13 +1453,14 @@ func TestFetcher_UnknownSizeRangeRetriesShortPartialBody(t *testing.T) {
|
||||
defer server.Close()
|
||||
|
||||
f := buildFetcher()
|
||||
if err := f.Resolve(&base.Request{URL: server.URL + "/short-range.data"}, &base.Options{
|
||||
f.Meta().Opts = &base.Options{
|
||||
Path: t.TempDir(),
|
||||
Name: "short-range.data",
|
||||
Extra: &http.OptsExtra{
|
||||
Connections: 1,
|
||||
},
|
||||
}); err != nil {
|
||||
}
|
||||
if err := f.Resolve(&base.Request{URL: server.URL + "/short-range.data"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer f.Close()
|
||||
@@ -1493,13 +1513,14 @@ func TestFetcher_UnknownSizeRangeFallsBackToResolve(t *testing.T) {
|
||||
defer server.Close()
|
||||
|
||||
f := buildFetcher()
|
||||
if err := f.Resolve(&base.Request{URL: server.URL + "/unknown-size-fallback.data"}, &base.Options{
|
||||
f.Meta().Opts = &base.Options{
|
||||
Path: t.TempDir(),
|
||||
Name: "unknown-size-fallback.data",
|
||||
Extra: &http.OptsExtra{
|
||||
Connections: 1,
|
||||
},
|
||||
}); err != nil {
|
||||
}
|
||||
if err := f.Resolve(&base.Request{URL: server.URL + "/unknown-size-fallback.data"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer f.Close()
|
||||
@@ -1580,13 +1601,15 @@ func TestFetcher_UnknownSizeRangeRemainsOpenEndedAfterPause(t *testing.T) {
|
||||
defer server.Close()
|
||||
|
||||
f := buildFetcher()
|
||||
if err := f.Resolve(&base.Request{URL: server.URL + "/pause-open-range.data"}, &base.Options{
|
||||
f.Meta().Opts = &base.Options{
|
||||
Size: 3,
|
||||
Path: t.TempDir(),
|
||||
Name: "pause-open-range.data",
|
||||
Extra: &http.OptsExtra{
|
||||
Connections: 1,
|
||||
},
|
||||
}); err != nil {
|
||||
}
|
||||
if err := f.Resolve(&base.Request{URL: server.URL + "/pause-open-range.data"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer f.Close()
|
||||
@@ -1635,13 +1658,14 @@ func TestFetcher_UnknownSizeResolveReadErrorFailsTask(t *testing.T) {
|
||||
defer server.Close()
|
||||
|
||||
f := buildFetcher()
|
||||
if err := f.Resolve(&base.Request{URL: server.URL + "/broken-chunked.data"}, &base.Options{
|
||||
f.Meta().Opts = &base.Options{
|
||||
Path: t.TempDir(),
|
||||
Name: "broken-chunked.data",
|
||||
Extra: &http.OptsExtra{
|
||||
Connections: 1,
|
||||
},
|
||||
}); err != nil {
|
||||
}
|
||||
if err := f.Resolve(&base.Request{URL: server.URL + "/broken-chunked.data"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer f.Close()
|
||||
@@ -1667,13 +1691,14 @@ func TestFetcher_UnknownSizeResolveCleanEOFUpdatesSize(t *testing.T) {
|
||||
defer server.Close()
|
||||
|
||||
f := buildFetcher()
|
||||
if err := f.Resolve(&base.Request{URL: server.URL + "/unknown-size.data"}, &base.Options{
|
||||
f.Meta().Opts = &base.Options{
|
||||
Path: t.TempDir(),
|
||||
Name: "unknown-size.data",
|
||||
Extra: &http.OptsExtra{
|
||||
Connections: 1,
|
||||
},
|
||||
}); err != nil {
|
||||
}
|
||||
if err := f.Resolve(&base.Request{URL: server.URL + "/unknown-size.data"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer f.Close()
|
||||
@@ -2269,14 +2294,15 @@ func TestFetcher_DownloadOneTimeURL(t *testing.T) {
|
||||
defer listener.Close()
|
||||
|
||||
fetcher := buildFetcher()
|
||||
err := fetcher.Resolve(&base.Request{
|
||||
URL: "http://" + listener.Addr().String() + "/" + test.BuildName,
|
||||
}, &base.Options{
|
||||
fetcher.Meta().Opts = &base.Options{
|
||||
Name: test.DownloadName,
|
||||
Path: test.Dir,
|
||||
Extra: &http.OptsExtra{
|
||||
Connections: 4, // Try to use multiple connections, but only first should work
|
||||
},
|
||||
}
|
||||
err := fetcher.Resolve(&base.Request{
|
||||
URL: "http://" + listener.Addr().String() + "/" + test.BuildName,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
@@ -2335,14 +2361,15 @@ func TestFetcher_SlowStartExpansion(t *testing.T) {
|
||||
Connections: tc.maxConns,
|
||||
})
|
||||
|
||||
err := fetcher.Resolve(&base.Request{
|
||||
URL: "http://" + listener.Addr().String() + "/" + test.BuildName,
|
||||
}, &base.Options{
|
||||
fetcher.Meta().Opts = &base.Options{
|
||||
Name: test.DownloadName,
|
||||
Path: test.Dir,
|
||||
Extra: &http.OptsExtra{
|
||||
Connections: tc.maxConns,
|
||||
},
|
||||
}
|
||||
err := fetcher.Resolve(&base.Request{
|
||||
URL: "http://" + listener.Addr().String() + "/" + test.BuildName,
|
||||
})
|
||||
if err != nil {
|
||||
cleanup()
|
||||
@@ -2398,14 +2425,15 @@ func TestFetcher_AsyncPrefetch(t *testing.T) {
|
||||
defer listener.Close()
|
||||
|
||||
fetcher := buildFetcher()
|
||||
err := fetcher.Resolve(&base.Request{
|
||||
URL: "http://" + listener.Addr().String() + "/" + test.BuildName,
|
||||
}, &base.Options{
|
||||
fetcher.Meta().Opts = &base.Options{
|
||||
Name: test.DownloadName,
|
||||
Path: test.Dir,
|
||||
Extra: &http.OptsExtra{
|
||||
Connections: 4,
|
||||
},
|
||||
}
|
||||
err := fetcher.Resolve(&base.Request{
|
||||
URL: "http://" + listener.Addr().String() + "/" + test.BuildName,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
@@ -2469,14 +2497,15 @@ func TestFetcher_AsyncPrefetch(t *testing.T) {
|
||||
defer listener.Close()
|
||||
|
||||
fetcher := buildFetcher()
|
||||
err := fetcher.Resolve(&base.Request{
|
||||
URL: "http://" + listener.Addr().String() + "/" + test.BuildName,
|
||||
}, &base.Options{
|
||||
fetcher.Meta().Opts = &base.Options{
|
||||
Name: test.DownloadName,
|
||||
Path: test.Dir,
|
||||
Extra: &http.OptsExtra{
|
||||
Connections: 4,
|
||||
},
|
||||
}
|
||||
err := fetcher.Resolve(&base.Request{
|
||||
URL: "http://" + listener.Addr().String() + "/" + test.BuildName,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
@@ -2551,14 +2580,15 @@ func TestFetcher_DownloadExpiringRedirectURL(t *testing.T) {
|
||||
defer listener.Close()
|
||||
|
||||
fetcher := buildFetcher()
|
||||
err := fetcher.Resolve(&base.Request{
|
||||
URL: "http://" + listener.Addr().String() + "/" + test.BuildName,
|
||||
}, &base.Options{
|
||||
fetcher.Meta().Opts = &base.Options{
|
||||
Name: test.DownloadName,
|
||||
Path: test.Dir,
|
||||
Extra: &http.OptsExtra{
|
||||
Connections: 4, // Use multiple connections to trigger redirect expiration
|
||||
},
|
||||
}
|
||||
err := fetcher.Resolve(&base.Request{
|
||||
URL: "http://" + listener.Addr().String() + "/" + test.BuildName,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
@@ -2592,14 +2622,15 @@ func TestFetcher_DownloadExpiringRedirectURL(t *testing.T) {
|
||||
defer listener.Close()
|
||||
|
||||
fetcher := buildFetcher()
|
||||
err := fetcher.Resolve(&base.Request{
|
||||
URL: "http://" + listener.Addr().String() + "/" + test.BuildName,
|
||||
}, &base.Options{
|
||||
fetcher.Meta().Opts = &base.Options{
|
||||
Name: test.DownloadName,
|
||||
Path: test.Dir,
|
||||
Extra: &http.OptsExtra{
|
||||
Connections: 8,
|
||||
},
|
||||
}
|
||||
err := fetcher.Resolve(&base.Request{
|
||||
URL: "http://" + listener.Addr().String() + "/" + test.BuildName,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
@@ -2639,14 +2670,15 @@ func TestFetcher_RetryAfterError(t *testing.T) {
|
||||
defer listener.Close()
|
||||
|
||||
fetcher := buildFetcher()
|
||||
err := fetcher.Resolve(&base.Request{
|
||||
URL: "http://" + listener.Addr().String() + "/" + test.BuildName,
|
||||
}, &base.Options{
|
||||
fetcher.Meta().Opts = &base.Options{
|
||||
Name: test.DownloadName,
|
||||
Path: test.Dir,
|
||||
Extra: &http.OptsExtra{
|
||||
Connections: 1, // Use single connection to simplify test
|
||||
},
|
||||
}
|
||||
err := fetcher.Resolve(&base.Request{
|
||||
URL: "http://" + listener.Addr().String() + "/" + test.BuildName,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
@@ -3067,9 +3099,10 @@ func doDownloadReady(f fetcher.Fetcher, listener net.Listener, connections int,
|
||||
Path: test.Dir,
|
||||
Extra: extra,
|
||||
}
|
||||
f.Meta().Opts = opts
|
||||
err := f.Resolve(&base.Request{
|
||||
URL: "http://" + listener.Addr().String() + "/" + test.BuildName,
|
||||
}, opts)
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -3118,7 +3151,8 @@ func downloadPost(listener net.Listener, connections int, t *testing.T) {
|
||||
Body: fmt.Sprintf(`{"name":"%s"}`, test.BuildName),
|
||||
},
|
||||
}
|
||||
err := f.Resolve(req, opts)
|
||||
f.Meta().Opts = opts
|
||||
err := f.Resolve(req)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -3164,11 +3198,12 @@ func downloadContinue(listener net.Listener, connections int, t *testing.T) {
|
||||
|
||||
func downloadError(listener net.Listener, connections int, t *testing.T) {
|
||||
fetcher := buildFetcher()
|
||||
err := fetcher.Resolve(&base.Request{
|
||||
URL: "http://" + listener.Addr().String() + "/" + test.BuildName,
|
||||
}, &base.Options{
|
||||
fetcher.Meta().Opts = &base.Options{
|
||||
Name: test.DownloadName,
|
||||
Path: test.Dir,
|
||||
}
|
||||
err := fetcher.Resolve(&base.Request{
|
||||
URL: "http://" + listener.Addr().String() + "/" + test.BuildName,
|
||||
})
|
||||
// With the new async design, Resolve may succeed (HTTP response received)
|
||||
// but errors occur during async download or Start/Wait
|
||||
@@ -3292,7 +3327,8 @@ func TestFetcher_Patch_URLChange(t *testing.T) {
|
||||
}
|
||||
|
||||
// Step 1: Try to resolve with bad URL - should fail with error
|
||||
err := f.Resolve(&base.Request{URL: badURL}, opts)
|
||||
f.Meta().Opts = opts
|
||||
err := f.Resolve(&base.Request{URL: badURL})
|
||||
if err == nil {
|
||||
t.Fatal("Expected error for bad URL, got nil")
|
||||
}
|
||||
@@ -3302,7 +3338,8 @@ func TestFetcher_Patch_URLChange(t *testing.T) {
|
||||
f2 := buildFetcher()
|
||||
|
||||
// First resolve with good URL to create a valid fetcher state
|
||||
err = f2.Resolve(&base.Request{URL: goodURL}, opts)
|
||||
f2.Meta().Opts = opts
|
||||
err = f2.Resolve(&base.Request{URL: goodURL})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -3354,13 +3391,14 @@ func TestFetcher_Patch_Labels(t *testing.T) {
|
||||
Extra: &http.OptsExtra{Connections: 1},
|
||||
}
|
||||
|
||||
f.Meta().Opts = opts
|
||||
err := f.Resolve(&base.Request{
|
||||
URL: "http://" + listener.Addr().String() + "/" + test.BuildName,
|
||||
Labels: map[string]string{
|
||||
"key1": "value1",
|
||||
"key3": "value3",
|
||||
},
|
||||
}, opts)
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -3411,6 +3449,7 @@ func TestFetcher_Patch_Extra(t *testing.T) {
|
||||
}
|
||||
|
||||
// Resolve with initial Extra
|
||||
f.Meta().Opts = opts
|
||||
err := f.Resolve(&base.Request{
|
||||
URL: "http://" + listener.Addr().String() + "/" + test.BuildName,
|
||||
Extra: &http.ReqExtra{
|
||||
@@ -3421,7 +3460,7 @@ func TestFetcher_Patch_Extra(t *testing.T) {
|
||||
"X-Custom": "original",
|
||||
},
|
||||
},
|
||||
}, opts)
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -3499,9 +3538,10 @@ func TestFetcher_Patch_NilData(t *testing.T) {
|
||||
Extra: &http.OptsExtra{Connections: 1},
|
||||
}
|
||||
|
||||
f.Meta().Opts = opts
|
||||
err := f.Resolve(&base.Request{
|
||||
URL: "http://" + listener.Addr().String() + "/" + test.BuildName,
|
||||
}, opts)
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -3551,6 +3591,7 @@ func TestFetcher_Patch_CookieExpired(t *testing.T) {
|
||||
|
||||
// Step 1: Resolve with old_token - should succeed (first request accepts old_token)
|
||||
f := buildFetcher()
|
||||
f.Meta().Opts = opts
|
||||
err := f.Resolve(&base.Request{
|
||||
URL: downloadURL,
|
||||
Extra: &http.ReqExtra{
|
||||
@@ -3558,7 +3599,7 @@ func TestFetcher_Patch_CookieExpired(t *testing.T) {
|
||||
"Cookie": "session=old_token",
|
||||
},
|
||||
},
|
||||
}, opts)
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Resolve should succeed with old_token: %v", err)
|
||||
}
|
||||
|
||||
@@ -49,7 +49,8 @@ func TestResolveResponseSize(t *testing.T) {
|
||||
defer server.Close()
|
||||
f := buildFetcher()
|
||||
defer f.Close()
|
||||
err := f.Resolve(&base.Request{URL: server.URL + "/file"}, &base.Options{Path: t.TempDir()})
|
||||
f.Meta().Opts = &base.Options{Path: t.TempDir()}
|
||||
err := f.Resolve(&base.Request{URL: server.URL + "/file"})
|
||||
if tc.invalid {
|
||||
if !errors.Is(err, errInvalidRangeResponse) {
|
||||
t.Fatalf("Resolve error = %v, want invalid range", err)
|
||||
@@ -97,7 +98,8 @@ func TestDownloadPartialResolve(t *testing.T) {
|
||||
defer server.Close()
|
||||
f := buildFetcher()
|
||||
defer f.Close()
|
||||
if err := f.Resolve(&base.Request{URL: server.URL + "/file"}, &base.Options{Path: t.TempDir(), Name: "file", Extra: &fhttp.OptsExtra{Connections: connections}}); err != nil {
|
||||
f.Meta().Opts = &base.Options{Path: t.TempDir(), Name: "file", Extra: &fhttp.OptsExtra{Connections: connections}}
|
||||
if err := f.Resolve(&base.Request{URL: server.URL + "/file"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := f.Start(); err != nil {
|
||||
|
||||
@@ -0,0 +1,245 @@
|
||||
package http
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
|
||||
"github.com/GopeedLab/gopeed/pkg/base"
|
||||
)
|
||||
|
||||
func TestFetcher_OptionsSizeHintAndPatch(t *testing.T) {
|
||||
for _, known := range []bool{false, true} {
|
||||
for _, payload := range []string{"", "0123456789"} {
|
||||
t.Run(fmt.Sprintf("known=%v/bytes=%d", known, len(payload)), func(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
if known {
|
||||
w.Header().Set("Content-Length", fmt.Sprint(len(payload)))
|
||||
}
|
||||
w.(http.Flusher).Flush()
|
||||
fmt.Fprint(w, payload)
|
||||
}))
|
||||
defer server.Close()
|
||||
f := buildFetcher()
|
||||
defer f.Close()
|
||||
f.Meta().Opts = &base.Options{Size: 7, Path: t.TempDir(), Name: "output.bin"}
|
||||
if err := f.Resolve(&base.Request{URL: server.URL}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
want := int64(7)
|
||||
if known {
|
||||
want = int64(len(payload))
|
||||
}
|
||||
if f.meta.Res.Size != want || f.hasKnownSize() != known {
|
||||
t.Fatalf("resolved size=%d known=%v", f.meta.Res.Size, f.hasKnownSize())
|
||||
}
|
||||
if err := f.Patch(nil, &base.Options{Size: 9}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !known {
|
||||
want = 9
|
||||
}
|
||||
if f.meta.Res.Size != want {
|
||||
t.Fatal("hint update overrode a real size or failed to update an estimate")
|
||||
}
|
||||
if err := f.Start(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := f.Wait(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if f.meta.Res.Size != int64(len(payload)) || !f.hasKnownSize() {
|
||||
t.Fatalf("final size=%d known=%v", f.meta.Res.Size, f.hasKnownSize())
|
||||
}
|
||||
snapshot, err := new(FetcherManager).Store(f)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
fd := snapshot.(*fetcherData)
|
||||
if fd.SizeKnown == nil || !*fd.SizeKnown {
|
||||
t.Fatal("snapshot lost confirmed size authority")
|
||||
}
|
||||
_, restore := new(FetcherManager).Restore()
|
||||
restored := restore(f.meta, fd).(*Fetcher)
|
||||
if !restored.hasKnownSize() || restored.knownSize() != int64(len(payload)) {
|
||||
t.Fatal("restored size lost authority, including known zero")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestFetcher_SizeHintCannotCompleteForbiddenUnknownRange(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Header.Get("Range") != "" {
|
||||
w.WriteHeader(http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Accept-Ranges", "bytes")
|
||||
w.(http.Flusher).Flush()
|
||||
fmt.Fprint(w, "0123456789")
|
||||
w.(http.Flusher).Flush()
|
||||
<-r.Context().Done()
|
||||
}))
|
||||
defer server.Close()
|
||||
f := buildFetcher()
|
||||
defer f.Close()
|
||||
f.Meta().Opts = &base.Options{Size: 3, Path: t.TempDir(), Name: "output.bin"}
|
||||
if err := f.Resolve(&base.Request{URL: server.URL}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := f.Start(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := f.Wait(); err == nil {
|
||||
t.Fatal("a failed unknown-length Range request was completed from an estimate")
|
||||
}
|
||||
}
|
||||
|
||||
func TestFetcher_SizeHintPreservesHTTPIntegrity(t *testing.T) {
|
||||
for _, chunked := range []bool{false, true} {
|
||||
for _, broken := range []bool{false, true} {
|
||||
t.Run(fmt.Sprintf("chunked=%v/broken=%v", chunked, broken), func(t *testing.T) {
|
||||
payload := []byte("0123456789")
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
if chunked && broken {
|
||||
conn, rw, err := w.(http.Hijacker).Hijack()
|
||||
if err != nil {
|
||||
t.Error(err)
|
||||
return
|
||||
}
|
||||
defer conn.Close()
|
||||
fmt.Fprint(rw, "HTTP/1.1 200 OK\r\nTransfer-Encoding: chunked\r\nConnection: close\r\n\r\na\r\n0123456789\r\n")
|
||||
rw.Flush() // Omit the final chunk to simulate a connection interruption.
|
||||
return
|
||||
}
|
||||
if !chunked {
|
||||
length := len(payload)
|
||||
if broken {
|
||||
length++
|
||||
}
|
||||
w.Header().Set("Content-Length", fmt.Sprint(length))
|
||||
}
|
||||
w.(http.Flusher).Flush()
|
||||
w.Write(payload)
|
||||
}))
|
||||
defer server.Close()
|
||||
f := buildFetcher()
|
||||
defer f.Close()
|
||||
f.Meta().Opts = &base.Options{Size: 3, Path: t.TempDir(), Name: "output.bin"}
|
||||
if err := f.Resolve(&base.Request{URL: server.URL}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !chunked {
|
||||
length := int64(len(payload))
|
||||
if broken {
|
||||
length++
|
||||
}
|
||||
if f.meta.Res.Size != length || f.sequentialSizeUnknown {
|
||||
t.Fatal("HTTP length did not override size hint")
|
||||
}
|
||||
} else if f.meta.Res.Size != 3 || !f.sequentialSizeUnknown {
|
||||
t.Fatal("unknown HTTP length did not retain the display hint")
|
||||
}
|
||||
if err := f.Start(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
err := f.Wait()
|
||||
if broken {
|
||||
if err == nil {
|
||||
t.Fatal("truncated HTTP response succeeded after exceeding the size hint")
|
||||
}
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if f.meta.Res.Size != int64(len(payload)) || f.sequentialSizeUnknown {
|
||||
t.Fatalf("completed size=%d unknown=%v", f.meta.Res.Size, f.sequentialSizeUnknown)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestFetcher_SizeHintSurvivesRestore(t *testing.T) {
|
||||
for _, partial := range []bool{false, true} {
|
||||
for _, fixedLength := range []bool{false, true} {
|
||||
t.Run(fmt.Sprintf("partial=%v/fixedLength=%v", partial, fixedLength), func(t *testing.T) {
|
||||
payload := []byte("0123456789")
|
||||
var requests atomic.Int64
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
request := requests.Add(1)
|
||||
if got := r.Header.Get("Range"); got != "" {
|
||||
t.Errorf("estimated sizes must not be used for resume ranges: %s", got)
|
||||
}
|
||||
if fixedLength && request > 1 {
|
||||
w.Header().Set("Content-Length", fmt.Sprint(len(payload)))
|
||||
}
|
||||
w.(http.Flusher).Flush()
|
||||
if fixedLength && request == 2 {
|
||||
w.Write(payload[:4]) // Retry must discard this interrupted attempt.
|
||||
return
|
||||
}
|
||||
w.Write(payload)
|
||||
}))
|
||||
defer server.Close()
|
||||
f := buildFetcher()
|
||||
opts := &base.Options{Size: 3, Path: t.TempDir(), Name: "output.bin"}
|
||||
f.Meta().Opts = opts
|
||||
if err := f.Resolve(&base.Request{URL: server.URL}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
fm := new(FetcherManager)
|
||||
snapshot, err := fm.Store(f)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
data, err := json.Marshal(snapshot)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var saved fetcherData
|
||||
if err := json.Unmarshal(data, &saved); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !saved.SequentialSizeUnknown || saved.SizeKnown == nil || *saved.SizeKnown || *saved.ResourceSize != 3 {
|
||||
t.Fatal("snapshot lost the size hint or its advisory state")
|
||||
}
|
||||
f.Close()
|
||||
// An interrupted earlier attempt left a tail longer than the new output.
|
||||
if err := os.WriteFile(filepath.Join(opts.Path, opts.Name), bytes.Repeat([]byte("old"), 100), 0600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if partial {
|
||||
chunk := newChunk(0, 0)
|
||||
chunk.Downloaded = 7 // Already exceeds the display estimate.
|
||||
saved.Connections = []*connection{{ID: 0, Role: rolePrimary, State: connNotStarted, Chunk: chunk, Downloaded: 7}}
|
||||
}
|
||||
_, restore := fm.Restore()
|
||||
restored := restore(f.meta, &saved).(*Fetcher)
|
||||
restored.Setup(f.ctl)
|
||||
defer restored.Close()
|
||||
if err := restored.Start(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := restored.Wait(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
body, err := os.ReadFile(restored.meta.SingleFilepath())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !bytes.Equal(body, payload) || restored.meta.Res.Size != int64(len(payload)) || restored.Progress().TotalDownloaded() != int64(len(payload)) {
|
||||
t.Fatalf("restored stream retained an estimate or stale bytes: body=%q size=%d progress=%d", body, restored.meta.Res.Size, restored.Progress().TotalDownloaded())
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
+6
-1
@@ -2,6 +2,7 @@ package api
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/url"
|
||||
pathpkg "path"
|
||||
"strings"
|
||||
)
|
||||
@@ -75,7 +76,11 @@ func (r RouteSpec) Match(rawPath string) (map[string]string, bool) {
|
||||
params := make(map[string]string)
|
||||
for index, part := range r.parts {
|
||||
if part.param != "" {
|
||||
params[part.param] = segments[index]
|
||||
value, err := url.PathUnescape(segments[index])
|
||||
if err != nil {
|
||||
return nil, false
|
||||
}
|
||||
params[part.param] = value
|
||||
continue
|
||||
}
|
||||
if part.literal != segments[index] {
|
||||
|
||||
+36
-2
@@ -2,7 +2,9 @@ package api
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/url"
|
||||
@@ -62,6 +64,8 @@ func NewService(downloader *download.Downloader) (*Service, error) {
|
||||
newRoute(http.MethodGet, "/api/v1/tasks/{id}/status", service.getTaskStatus),
|
||||
newRoute(http.MethodGet, "/api/v1/tasks", service.getTasks),
|
||||
newRoute(http.MethodGet, "/api/v1/tasks/{id}/stats", service.getStats),
|
||||
newRoute(http.MethodPost, "/api/v1/protocols/{protocol}/jobs/{name}/trigger", service.triggerProtocolJob),
|
||||
newRoute(http.MethodPost, "/api/v1/mirrors/{provider}/candidates", service.mirrorCandidates),
|
||||
newRoute(http.MethodGet, "/api/v1/config", service.getConfig),
|
||||
newRoute(http.MethodPut, "/api/v1/config", service.putConfig),
|
||||
newRoute(http.MethodPost, "/api/v1/extensions", service.installExtension),
|
||||
@@ -139,7 +143,7 @@ func (s *Service) resolve(ctx *Context) *Response {
|
||||
if response := decodeRequest(ctx.Body, &request); response != nil {
|
||||
return response
|
||||
}
|
||||
result, err := s.Downloader.Resolve(request.Req, request.Opts)
|
||||
result, err := s.Downloader.Resolve(request.Req)
|
||||
if err != nil {
|
||||
return newErrorResponse(err.Error())
|
||||
}
|
||||
@@ -188,7 +192,7 @@ func (s *Service) patchTask(ctx *Context) *Response {
|
||||
if taskID == "" {
|
||||
return invalidIDResponse()
|
||||
}
|
||||
var request restmodel.ResolveTask
|
||||
var request restmodel.PatchTask
|
||||
if response := decodeRequest(ctx.Body, &request); response != nil {
|
||||
return response
|
||||
}
|
||||
@@ -294,6 +298,36 @@ func (s *Service) getStats(ctx *Context) *Response {
|
||||
return newOKResponse(stats)
|
||||
}
|
||||
|
||||
func (s *Service) triggerProtocolJob(ctx *Context) *Response {
|
||||
if err := s.Downloader.TriggerProtocolJob(context.Background(), ctx.PathParam("protocol"), ctx.PathParam("name")); err != nil {
|
||||
status := http.StatusBadRequest
|
||||
if errors.Is(err, download.ErrUnSupportedProtocol) || errors.Is(err, download.ErrProtocolJobNotFound) {
|
||||
status = http.StatusNotFound
|
||||
}
|
||||
return newStatusResponse(status, restmodel.NewErrorResult(err.Error()))
|
||||
}
|
||||
return newNilResponse()
|
||||
}
|
||||
|
||||
func (s *Service) mirrorCandidates(ctx *Context) *Response {
|
||||
var request restmodel.MirrorCandidatesReq
|
||||
if response := decodeRequest(ctx.Body, &request); response != nil {
|
||||
return response
|
||||
}
|
||||
urls, err := s.Downloader.GetMirrorCandidates(ctx.PathParam("provider"), request.URL)
|
||||
if err != nil {
|
||||
status := http.StatusInternalServerError
|
||||
switch {
|
||||
case errors.Is(err, download.ErrMirrorProviderNotFound):
|
||||
status = http.StatusNotFound
|
||||
case errors.Is(err, download.ErrInvalidMirrorURL):
|
||||
status = http.StatusBadRequest
|
||||
}
|
||||
return newStatusResponse(status, restmodel.NewErrorResult(err.Error()))
|
||||
}
|
||||
return newOKResponse(&restmodel.MirrorCandidatesResp{URLs: urls})
|
||||
}
|
||||
|
||||
func (s *Service) getConfig(_ *Context) *Response {
|
||||
config, err := s.Downloader.GetConfig()
|
||||
if err != nil {
|
||||
|
||||
@@ -0,0 +1,269 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"reflect"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/GopeedLab/gopeed/internal/fetcher"
|
||||
httpProtocol "github.com/GopeedLab/gopeed/internal/protocol/http"
|
||||
"github.com/GopeedLab/gopeed/pkg/base"
|
||||
"github.com/GopeedLab/gopeed/pkg/download"
|
||||
restmodel "github.com/GopeedLab/gopeed/pkg/rest/model"
|
||||
)
|
||||
|
||||
func TestResolveThenCreateWithDownloadOptions(t *testing.T) {
|
||||
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
w.Header().Set("Content-Disposition", `attachment; filename="payload.txt"`)
|
||||
w.Header().Set("Content-Length", "4")
|
||||
_, _ = w.Write([]byte("test"))
|
||||
}))
|
||||
t.Cleanup(upstream.Close)
|
||||
d := download.NewDownloader(&download.DownloaderConfig{StorageDir: t.TempDir()})
|
||||
if err := d.Setup(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
if err := d.Close(); err != nil {
|
||||
t.Error(err)
|
||||
}
|
||||
})
|
||||
// Resolving must work even when the default download directory is unusable.
|
||||
blocked := filepath.Join(t.TempDir(), "not-a-directory")
|
||||
if err := os.WriteFile(blocked, nil, 0600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
current, err := d.GetConfig()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
config := *current
|
||||
config.DownloadDir = blocked
|
||||
config.MaxRunning = 0 // Inspect the created task before it starts downloading.
|
||||
if err := d.PutConfig(&config); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
service, err := NewService(d)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
resolved := serviceTestRequest[*download.ResolveResult](t, service, http.MethodPost, "/api/v1/resolve", &restmodel.ResolveTask{
|
||||
Req: &base.Request{URL: upstream.URL},
|
||||
})
|
||||
if resolved.ID == "" || resolved.Res.Size != 4 || resolved.Res.Files[0].Name != "payload.txt" {
|
||||
t.Fatalf("unexpected resolved resource: %+v", resolved)
|
||||
}
|
||||
dir := filepath.Join(t.TempDir(), "chosen-after-resolve")
|
||||
opts := &base.Options{Path: dir, Name: "renamed.txt", SelectFiles: []int{0}}
|
||||
id := serviceTestRequest[string](t, service, http.MethodPost, "/api/v1/tasks", &restmodel.CreateTask{Rid: resolved.ID, Opts: opts})
|
||||
task := serviceTestRequest[*download.Task](t, service, http.MethodGet, "/api/v1/tasks/"+id, nil)
|
||||
if task.Meta.Opts.Path != dir || task.Meta.Opts.Name != opts.Name || !reflect.DeepEqual(task.Meta.Opts.SelectFiles, opts.SelectFiles) {
|
||||
t.Fatalf("creation lost submitted download options: %+v", task.Meta.Opts)
|
||||
}
|
||||
}
|
||||
|
||||
func serviceTestRequest[T any](t *testing.T, service *Service, method, path string, body any) T {
|
||||
t.Helper()
|
||||
encoded, err := json.Marshal(body)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
status, response := service.JSON(method, path, nil, encoded, nil)
|
||||
var result restmodel.Result[T]
|
||||
if err := json.Unmarshal(response, &result); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if status != http.StatusOK || result.Code != restmodel.CodeOk {
|
||||
t.Fatalf("%s %s failed: status=%d response=%s", method, path, status, response)
|
||||
}
|
||||
return result.Data
|
||||
}
|
||||
|
||||
func TestFullConfigDispatch(t *testing.T) {
|
||||
d := download.NewDownloader(&download.DownloaderConfig{StorageDir: t.TempDir(), TempDir: t.TempDir(), FetchManagers: []fetcher.FetcherManager{&httpProtocol.FetcherManager{}}})
|
||||
if err := d.Setup(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer d.Close()
|
||||
service, err := NewService(d)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
response := service.Dispatch(http.MethodPut, "/api/v1/config", nil, []byte(`{"protocolConfig":{"http":{"connections":3,"useServerCtime":false}}}`), nil)
|
||||
if response.StatusCode != http.StatusOK {
|
||||
t.Fatalf("%+v", response)
|
||||
}
|
||||
status, body := service.JSON(http.MethodGet, "/api/v1/config", nil, nil, nil)
|
||||
var result struct {
|
||||
Data struct {
|
||||
ProtocolConfig map[string]struct {
|
||||
Connections int `json:"connections"`
|
||||
} `json:"protocolConfig"`
|
||||
} `json:"data"`
|
||||
}
|
||||
if err := json.Unmarshal(body, &result); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if status != http.StatusOK || result.Data.ProtocolConfig["http"].Connections != 3 {
|
||||
t.Fatalf("%d %s", status, body)
|
||||
}
|
||||
if response := service.Dispatch(http.MethodPost, "/api/v1/protocols/http/jobs/missing/trigger", nil, nil, nil); response.StatusCode != http.StatusNotFound {
|
||||
t.Fatalf("%+v", response)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNativePathParametersAreDecodedOnce(t *testing.T) {
|
||||
route := newRoute(http.MethodGet, "/api/v1/tasks/{id}/status", func(c *Context) *Response { return newOKResponse(c.PathParam("id")) })
|
||||
for _, test := range []struct{ path, want string }{
|
||||
{"/api/v1/tasks/%E5%BC%80%E5%8F%91%E8%80%85%40name/status", "开发者@name"},
|
||||
{"/api/v1/tasks/literal%252Fname/status", "literal%2Fname"},
|
||||
} {
|
||||
response := (&Service{routes: []RouteSpec{route}}).Dispatch(http.MethodGet, test.path, nil, nil, nil)
|
||||
raw, _ := json.Marshal(response.Body)
|
||||
var body struct {
|
||||
Data string `json:"data"`
|
||||
}
|
||||
_ = json.Unmarshal(raw, &body)
|
||||
if body.Data != test.want {
|
||||
t.Fatalf("%s => %q", test.path, body.Data)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestMirrorCandidates(t *testing.T) {
|
||||
d := download.NewDownloader(&download.DownloaderConfig{StorageDir: t.TempDir(), TempDir: t.TempDir(), FetchManagers: []fetcher.FetcherManager{&httpProtocol.FetcherManager{}}})
|
||||
if err := d.Setup(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer d.Close()
|
||||
cfg, _ := d.GetConfig()
|
||||
cfg.GithubMirror = &base.GithubMirrorConfig{Enabled: true, Mirrors: []base.GithubMirror{
|
||||
{Type: "jsdelivr", URL: "https://cdn.example/gh"},
|
||||
{Type: "ghProxy", URL: "https://proxy.example"},
|
||||
{Type: "ghProxy", URL: "https://proxy.example"},
|
||||
}}
|
||||
if err := d.PutConfig(cfg); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
service, err := NewService(d)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, official := range []string{
|
||||
"https://github.com/GopeedLab/gopeed/releases/download/v2.0.0/package.zip",
|
||||
"https://raw.githubusercontent.com/owner/repo/main/trackers.txt",
|
||||
} {
|
||||
request, _ := json.Marshal(map[string]string{"url": official})
|
||||
status, raw := service.JSON(http.MethodPost, "/api/v1/mirrors/github/candidates", nil, request, nil)
|
||||
var body struct {
|
||||
Data struct {
|
||||
URLs []string `json:"urls"`
|
||||
} `json:"data"`
|
||||
}
|
||||
if err := json.Unmarshal(raw, &body); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
want := 2
|
||||
if official == "https://raw.githubusercontent.com/owner/repo/main/trackers.txt" {
|
||||
want = 3
|
||||
}
|
||||
urls := body.Data.URLs
|
||||
if status != http.StatusOK || len(urls) != want || urls[0] != official || urls[len(urls)-1] != "https://proxy.example/"+official {
|
||||
t.Fatalf("%d %s", status, raw)
|
||||
}
|
||||
}
|
||||
for _, invalid := range []string{"https://api.github.com/repos/GopeedLab/gopeed/releases?per_page=10", "", "https://example.com/x", "http://github.com/x", "https://github.com.evil.test/x", "https://[email protected]/x", "https://github.com:443/x", "https://github.com:/x", "https://github.com/x#fragment", "https://github.com/x#", "/relative", "https://github.com%2fevil.test/x"} {
|
||||
request, _ := json.Marshal(map[string]string{"url": invalid})
|
||||
if response := service.Dispatch(http.MethodPost, "/api/v1/mirrors/github/candidates", nil, request, nil); response.StatusCode != http.StatusBadRequest {
|
||||
t.Fatalf("accepted %s: %+v", invalid, response)
|
||||
}
|
||||
}
|
||||
cfg.GithubMirror.Enabled = false
|
||||
if err := d.PutConfig(cfg); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
status, raw := service.JSON(http.MethodPost, "/api/v1/mirrors/github/candidates", nil, []byte(`{"url":"https://github.com/owner/repo"}`), nil)
|
||||
var body struct {
|
||||
Data struct {
|
||||
URLs []string `json:"urls"`
|
||||
} `json:"data"`
|
||||
}
|
||||
if err := json.Unmarshal(raw, &body); err != nil || status != http.StatusOK || len(body.Data.URLs) != 1 || body.Data.URLs[0] != "https://github.com/owner/repo" {
|
||||
t.Fatalf("%d %s", status, raw)
|
||||
}
|
||||
|
||||
for _, provider := range []string{"unknown", "GitHub"} {
|
||||
if response := service.Dispatch(http.MethodPost, "/api/v1/mirrors/"+provider+"/candidates", nil, []byte(`{"url":"https://github.com/owner/repo"}`), nil); response.StatusCode != http.StatusNotFound {
|
||||
t.Fatalf("unknown provider accepted: %+v", response)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
type apiJobState struct {
|
||||
Runs int `json:"runs"`
|
||||
}
|
||||
type apiJobConfig struct {
|
||||
fetcher.ConfigState[apiJobState]
|
||||
Writable int `json:"writable"`
|
||||
}
|
||||
type apiStateJobManager struct{ httpProtocol.FetcherManager }
|
||||
|
||||
func (*apiStateJobManager) Name() string { return "state-api" }
|
||||
func (*apiStateJobManager) DefaultConfig() any { return &apiJobConfig{Writable: 7} }
|
||||
func (*apiStateJobManager) CronJobs(host fetcher.ProtocolHost) []fetcher.CronJob {
|
||||
return []fetcher.CronJob{{Name: "refresh", Every: time.Hour, Timeout: time.Second, Run: func(_ context.Context, source fetcher.TriggerSource) error {
|
||||
if source == fetcher.TriggerScheduled {
|
||||
return fetcher.ErrJobSkipped
|
||||
}
|
||||
return fetcher.UpdateState[apiJobConfig, apiJobState](host, func(cfg *apiJobConfig, state *apiJobState) error { cfg.Writable = 999; state.Runs++; return nil })
|
||||
}}}
|
||||
}
|
||||
|
||||
func TestFullConfigAndJobTriggerShareProtectedProtocolState(t *testing.T) {
|
||||
d := download.NewDownloader(&download.DownloaderConfig{StorageDir: t.TempDir(), TempDir: t.TempDir(), FetchManagers: []fetcher.FetcherManager{&apiStateJobManager{}}})
|
||||
if err := d.Setup(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer d.Close()
|
||||
service, err := NewService(d)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
put := []byte(`{"protocolConfig":{"state-api":{"writable":9,"state":"ignore forged state"}}}`)
|
||||
if response := service.Dispatch(http.MethodPut, "/api/v1/config", nil, put, nil); response.StatusCode != http.StatusOK {
|
||||
t.Fatalf("%+v", response)
|
||||
}
|
||||
if response := service.Dispatch(http.MethodPost, "/api/v1/protocols/state-api/jobs/refresh/trigger", nil, nil, nil); response.StatusCode != http.StatusOK {
|
||||
t.Fatalf("%+v", response)
|
||||
}
|
||||
status, raw := service.JSON(http.MethodGet, "/api/v1/config", nil, nil, nil)
|
||||
var response struct {
|
||||
Data struct {
|
||||
ProtocolConfig map[string]apiJobConfig `json:"protocolConfig"`
|
||||
} `json:"data"`
|
||||
}
|
||||
if err := json.Unmarshal(raw, &response); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
cfg := response.Data.ProtocolConfig["state-api"]
|
||||
if status != http.StatusOK || cfg.Writable != 9 || cfg.State.Runs != 1 {
|
||||
t.Fatalf("%d %s", status, raw)
|
||||
}
|
||||
// Replaying an old full PUT must not undo the manual job's state change.
|
||||
if response := service.Dispatch(http.MethodPut, "/api/v1/config", nil, put, nil); response.StatusCode != http.StatusOK {
|
||||
t.Fatalf("%+v", response)
|
||||
}
|
||||
_, raw = service.JSON(http.MethodGet, "/api/v1/config", nil, nil, nil)
|
||||
if err := json.Unmarshal(raw, &response); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if response.Data.ProtocolConfig["state-api"].State.Runs != 1 {
|
||||
t.Fatalf("job state overwritten: %s", raw)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
package base
|
||||
|
||||
import "github.com/GopeedLab/gopeed/internal/githubmirror"
|
||||
|
||||
// Preserve downloader configuration type names and the existing JSON schema.
|
||||
type GithubMirror = githubmirror.Mirror
|
||||
type GithubMirrorConfig = githubmirror.Config
|
||||
|
||||
func DefaultGithubMirrorConfig() *GithubMirrorConfig {
|
||||
return githubmirror.DefaultConfig()
|
||||
}
|
||||
@@ -1,9 +1,11 @@
|
||||
package base
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"path/filepath"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
@@ -108,12 +110,18 @@ type FileInfo struct {
|
||||
|
||||
// Options for download
|
||||
type Options struct {
|
||||
// Size is an optional HTTP download size estimate. HTTP metadata and clean EOF take precedence.
|
||||
// Other protocols ignore it.
|
||||
Size int64 `json:"size,omitempty"`
|
||||
// Download file name
|
||||
Name string `json:"name"`
|
||||
// Download file path
|
||||
Path string `json:"path"`
|
||||
// Use this path as the default download directory after creation succeeds
|
||||
AsDefaultPath bool `json:"asDefaultPath,omitempty"`
|
||||
// SkipAutoCategorize keeps the base download directory of this task instead
|
||||
// of routing it to the category directory of its resolved file
|
||||
SkipAutoCategorize bool `json:"skipAutoCategorize,omitempty"`
|
||||
// Select file indexes to download
|
||||
SelectFiles []int `json:"selectFiles"`
|
||||
// Extra info for specific fetcher
|
||||
@@ -181,12 +189,15 @@ type DownloaderStoreConfig struct {
|
||||
DownloadDir string `json:"downloadDir"` // DownloadDir is the default directory to save the downloaded files
|
||||
MaxRunning int `json:"maxRunning"` // MaxRunning is the max running download count
|
||||
ProtocolConfig map[string]any `json:"protocolConfig"` // ProtocolConfig is special config for each protocol
|
||||
GithubMirror *GithubMirrorConfig `json:"githubMirror"`
|
||||
Extra map[string]any `json:"extra"`
|
||||
Proxy *DownloaderProxyConfig `json:"proxy"`
|
||||
Webhook *WebhookConfig `json:"webhook"` // Webhook is the webhook configuration
|
||||
Script *ScriptConfig `json:"script"` // Script is the script execution configuration
|
||||
AutoTorrent *AutoTorrentConfig `json:"autoTorrent"` // AutoTorrent is the auto torrent task creation configuration
|
||||
Archive *ArchiveConfig `json:"archive"` // Archive is the archive extraction configuration
|
||||
Categories []*DownloadCategory `json:"categories"` // Categories is the download directory category configuration
|
||||
AutoCategorize *bool `json:"autoCategorize,omitempty"` // AutoCategorize routes downloads to category directories by file extension; unset defaults to disabled
|
||||
API *APIServerConfig `json:"api"` // API is the optional REST server configuration
|
||||
AutoStartTasks bool `json:"autoStartTasks"` // AutoStartTasks continues all unfinished tasks when the backend starts
|
||||
AutoDeleteMissingFileTasks bool `json:"autoDeleteMissingFileTasks"` // AutoDeleteMissingFileTasks enables automatic deletion of tasks with missing files
|
||||
@@ -199,6 +210,9 @@ func (cfg *DownloaderStoreConfig) Init() *DownloaderStoreConfig {
|
||||
if cfg.ProtocolConfig == nil {
|
||||
cfg.ProtocolConfig = make(map[string]any)
|
||||
}
|
||||
if cfg.GithubMirror == nil {
|
||||
cfg.GithubMirror = DefaultGithubMirrorConfig()
|
||||
}
|
||||
if cfg.Proxy == nil {
|
||||
cfg.Proxy = &DownloaderProxyConfig{}
|
||||
}
|
||||
@@ -220,9 +234,85 @@ func (cfg *DownloaderStoreConfig) Init() *DownloaderStoreConfig {
|
||||
DeleteAfterExtract: false,
|
||||
}
|
||||
}
|
||||
// Seed the built-in categories for deployments that never run the flutter
|
||||
// startup flow, e.g. headless api servers.
|
||||
if len(cfg.Categories) == 0 && cfg.DownloadDir != "" {
|
||||
cfg.Categories = []*DownloadCategory{
|
||||
builtinSeedCategory("categoryMusic", "Music", cfg.DownloadDir),
|
||||
builtinSeedCategory("categoryVideo", "Video", cfg.DownloadDir),
|
||||
builtinSeedCategory("categoryDocument", "Document", cfg.DownloadDir),
|
||||
builtinSeedCategory("categoryProgram", "Program", cfg.DownloadDir),
|
||||
}
|
||||
}
|
||||
return cfg
|
||||
}
|
||||
|
||||
// builtinCategoryExtensions mirrors the flutter side
|
||||
// kDefaultCategoryExtensions, keep both maps in sync.
|
||||
var builtinCategoryExtensions = map[string][]string{
|
||||
"categoryProgram": {"exe", "msi", "msix", "apk", "dmg", "deb", "rpm", "pkg", "appimage"},
|
||||
"categoryVideo": {"mp4", "mkv", "avi", "mov", "wmv", "flv", "webm", "m4v", "mpg", "mpeg", "3gp"},
|
||||
"categoryMusic": {"mp3", "flac", "wav", "aac", "ogg", "m4a", "wma", "ape"},
|
||||
"categoryDocument": {"pdf", "doc", "docx", "xls", "xlsx", "ppt", "pptx", "txt", "md", "csv", "epub", "rtf"},
|
||||
}
|
||||
|
||||
func builtinSeedCategory(nameKey, name, downloadDir string) *DownloadCategory {
|
||||
return &DownloadCategory{
|
||||
Name: "",
|
||||
Path: filepath.Join(downloadDir, name),
|
||||
NameKey: nameKey,
|
||||
IsBuiltIn: true,
|
||||
Extensions: append([]string(nil), builtinCategoryExtensions[nameKey]...),
|
||||
}
|
||||
}
|
||||
|
||||
// MigrateLegacyExtraCategories moves the extra.downloadCategories list written
|
||||
// by v2.0.0-beta builds into the top level categories field and clears the
|
||||
// legacy key. The legacy list predates extension based routing, so built-in
|
||||
// entries regain their default extension lists from their name key, while user
|
||||
// created folders keep their empty list until edited in settings. It reports
|
||||
// whether the config changed so the caller can persist the cleared key.
|
||||
func (cfg *DownloaderStoreConfig) MigrateLegacyExtraCategories() bool {
|
||||
if len(cfg.Categories) > 0 || cfg.Extra == nil {
|
||||
return false
|
||||
}
|
||||
raw, ok := cfg.Extra["downloadCategories"]
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
data, err := json.Marshal(raw)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
var legacy []*DownloadCategory
|
||||
if err := json.Unmarshal(data, &legacy); err != nil {
|
||||
return false
|
||||
}
|
||||
categories := make([]*DownloadCategory, 0, len(legacy))
|
||||
for _, category := range legacy {
|
||||
if category == nil {
|
||||
continue
|
||||
}
|
||||
if extensions, exist := builtinCategoryExtensions[category.NameKey]; exist && len(category.Extensions) == 0 {
|
||||
category.Extensions = append([]string(nil), extensions...)
|
||||
}
|
||||
categories = append(categories, category)
|
||||
}
|
||||
if len(categories) == 0 {
|
||||
return false
|
||||
}
|
||||
cfg.Categories = categories
|
||||
delete(cfg.Extra, "downloadCategories")
|
||||
return true
|
||||
}
|
||||
|
||||
// AutoCategorizeEnabled reports whether downloads are routed to category
|
||||
// directories by file extension. The switch is optional: an unset value keeps
|
||||
// the feature off, so an older config that never saved the field stays off.
|
||||
func (cfg *DownloaderStoreConfig) AutoCategorizeEnabled() bool {
|
||||
return cfg.AutoCategorize != nil && *cfg.AutoCategorize
|
||||
}
|
||||
|
||||
func (cfg *DownloaderStoreConfig) Merge(beforeCfg *DownloaderStoreConfig) *DownloaderStoreConfig {
|
||||
if beforeCfg == nil {
|
||||
return cfg
|
||||
@@ -239,6 +329,9 @@ func (cfg *DownloaderStoreConfig) Merge(beforeCfg *DownloaderStoreConfig) *Downl
|
||||
if cfg.Extra == nil {
|
||||
cfg.Extra = beforeCfg.Extra
|
||||
}
|
||||
if cfg.GithubMirror == nil {
|
||||
cfg.GithubMirror = beforeCfg.GithubMirror
|
||||
}
|
||||
if cfg.Proxy == nil {
|
||||
cfg.Proxy = beforeCfg.Proxy
|
||||
}
|
||||
@@ -254,6 +347,12 @@ func (cfg *DownloaderStoreConfig) Merge(beforeCfg *DownloaderStoreConfig) *Downl
|
||||
if cfg.Archive == nil {
|
||||
cfg.Archive = beforeCfg.Archive
|
||||
}
|
||||
if cfg.Categories == nil {
|
||||
cfg.Categories = beforeCfg.Categories
|
||||
}
|
||||
if cfg.AutoCategorize == nil {
|
||||
cfg.AutoCategorize = beforeCfg.AutoCategorize
|
||||
}
|
||||
if cfg.API == nil {
|
||||
cfg.API = beforeCfg.API
|
||||
}
|
||||
@@ -302,6 +401,16 @@ type ArchiveConfig struct {
|
||||
DeleteAfterExtract bool `json:"deleteAfterExtract"` // DeleteAfterExtract deletes the archive after successful extraction
|
||||
}
|
||||
|
||||
// DownloadCategory is a download directory category that files can be routed to by extension
|
||||
type DownloadCategory struct {
|
||||
Name string `json:"name"` // Name is the category display name, empty for built-in categories that use NameKey
|
||||
Path string `json:"path"` // Path is the download directory of the category
|
||||
NameKey string `json:"nameKey,omitempty"` // NameKey is the i18n key of the built-in category name
|
||||
IsBuiltIn bool `json:"isBuiltIn,omitempty"` // IsBuiltIn marks the category as built-in
|
||||
IsDeleted bool `json:"isDeleted,omitempty"` // IsDeleted marks the built-in category as deleted
|
||||
Extensions []string `json:"extensions,omitempty"` // Extensions is the file extension list routed to the category, without leading dots
|
||||
}
|
||||
|
||||
type DownloaderProxyConfig struct {
|
||||
Enable bool `json:"enable"`
|
||||
// System is the flag that use system proxy
|
||||
|
||||
+243
-1
@@ -1,8 +1,13 @@
|
||||
package base
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"path/filepath"
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/GopeedLab/gopeed/pkg/util"
|
||||
)
|
||||
|
||||
func TestDownloaderStoreConfig_Init(t *testing.T) {
|
||||
@@ -15,6 +20,7 @@ func TestDownloaderStoreConfig_Init(t *testing.T) {
|
||||
"Init",
|
||||
&DownloaderStoreConfig{},
|
||||
&DownloaderStoreConfig{
|
||||
GithubMirror: DefaultGithubMirrorConfig(),
|
||||
MaxRunning: 5,
|
||||
ProtocolConfig: map[string]any{},
|
||||
Proxy: &DownloaderProxyConfig{},
|
||||
@@ -36,6 +42,7 @@ func TestDownloaderStoreConfig_Init(t *testing.T) {
|
||||
MaxRunning: 10,
|
||||
},
|
||||
&DownloaderStoreConfig{
|
||||
GithubMirror: DefaultGithubMirrorConfig(),
|
||||
MaxRunning: 10,
|
||||
ProtocolConfig: map[string]any{},
|
||||
Proxy: &DownloaderProxyConfig{},
|
||||
@@ -59,7 +66,8 @@ func TestDownloaderStoreConfig_Init(t *testing.T) {
|
||||
},
|
||||
},
|
||||
&DownloaderStoreConfig{
|
||||
MaxRunning: 5,
|
||||
GithubMirror: DefaultGithubMirrorConfig(),
|
||||
MaxRunning: 5,
|
||||
ProtocolConfig: map[string]any{
|
||||
"key": "value",
|
||||
},
|
||||
@@ -84,6 +92,7 @@ func TestDownloaderStoreConfig_Init(t *testing.T) {
|
||||
},
|
||||
},
|
||||
&DownloaderStoreConfig{
|
||||
GithubMirror: DefaultGithubMirrorConfig(),
|
||||
MaxRunning: 5,
|
||||
ProtocolConfig: map[string]any{},
|
||||
Proxy: &DownloaderProxyConfig{
|
||||
@@ -110,6 +119,7 @@ func TestDownloaderStoreConfig_Init(t *testing.T) {
|
||||
},
|
||||
},
|
||||
&DownloaderStoreConfig{
|
||||
GithubMirror: DefaultGithubMirrorConfig(),
|
||||
MaxRunning: 5,
|
||||
ProtocolConfig: map[string]any{},
|
||||
Proxy: &DownloaderProxyConfig{},
|
||||
@@ -134,6 +144,7 @@ func TestDownloaderStoreConfig_Init(t *testing.T) {
|
||||
},
|
||||
},
|
||||
&DownloaderStoreConfig{
|
||||
GithubMirror: DefaultGithubMirrorConfig(),
|
||||
MaxRunning: 5,
|
||||
ProtocolConfig: map[string]any{},
|
||||
Proxy: &DownloaderProxyConfig{},
|
||||
@@ -149,6 +160,20 @@ func TestDownloaderStoreConfig_Init(t *testing.T) {
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "Init explicitly disabled mirrors with empty list",
|
||||
fields: &DownloaderStoreConfig{GithubMirror: &GithubMirrorConfig{Enabled: false, Mirrors: []GithubMirror{}}},
|
||||
want: &DownloaderStoreConfig{
|
||||
MaxRunning: 5,
|
||||
ProtocolConfig: map[string]any{},
|
||||
GithubMirror: &GithubMirrorConfig{Enabled: false, Mirrors: []GithubMirror{}},
|
||||
Proxy: &DownloaderProxyConfig{},
|
||||
Webhook: &WebhookConfig{},
|
||||
Script: &ScriptConfig{},
|
||||
AutoTorrent: &AutoTorrentConfig{},
|
||||
Archive: &ArchiveConfig{},
|
||||
},
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
@@ -157,6 +182,7 @@ func TestDownloaderStoreConfig_Init(t *testing.T) {
|
||||
DownloadDir: tt.fields.DownloadDir,
|
||||
MaxRunning: tt.fields.MaxRunning,
|
||||
ProtocolConfig: tt.fields.ProtocolConfig,
|
||||
GithubMirror: tt.fields.GithubMirror,
|
||||
Extra: tt.fields.Extra,
|
||||
Proxy: tt.fields.Proxy,
|
||||
Webhook: tt.fields.Webhook,
|
||||
@@ -171,6 +197,202 @@ func TestDownloaderStoreConfig_Init(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestDownloaderStoreConfig_InitSeedsBuiltInCategories(t *testing.T) {
|
||||
t.Run("fresh config with a download dir gets the built-in categories", func(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
cfg := &DownloaderStoreConfig{DownloadDir: dir}
|
||||
cfg.Init()
|
||||
|
||||
wantKeys := []string{"categoryMusic", "categoryVideo", "categoryDocument", "categoryProgram"}
|
||||
wantNames := []string{"Music", "Video", "Document", "Program"}
|
||||
if len(cfg.Categories) != len(wantKeys) {
|
||||
t.Fatalf("categories = %d, want %d", len(cfg.Categories), len(wantKeys))
|
||||
}
|
||||
for i, category := range cfg.Categories {
|
||||
if category.NameKey != wantKeys[i] {
|
||||
t.Errorf("category[%d].NameKey = %q, want %q", i, category.NameKey, wantKeys[i])
|
||||
}
|
||||
if !category.IsBuiltIn {
|
||||
t.Errorf("category[%d] %q is not built-in", i, category.NameKey)
|
||||
}
|
||||
if category.Path != filepath.Join(dir, wantNames[i]) {
|
||||
t.Errorf("category[%d].Path = %q, want %q", i, category.Path, filepath.Join(dir, wantNames[i]))
|
||||
}
|
||||
if len(category.Extensions) == 0 {
|
||||
t.Errorf("category[%d] %q has no default extensions", i, category.NameKey)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
// Init itself never reads the legacy key: Setup calls
|
||||
// MigrateLegacyExtraCategories first, and only a config that skipped the
|
||||
// migration ends up with the built-in seed.
|
||||
t.Run("legacy extra downloadCategories is not read by Init alone", func(t *testing.T) {
|
||||
cfg := &DownloaderStoreConfig{
|
||||
DownloadDir: t.TempDir(),
|
||||
Extra: map[string]any{
|
||||
"downloadCategories": []any{map[string]any{"name": "Music", "path": "/old/Music"}},
|
||||
},
|
||||
}
|
||||
cfg.Init()
|
||||
if len(cfg.Categories) != 4 {
|
||||
t.Fatalf("categories = %d, want the 4 built-ins seeded anyway", len(cfg.Categories))
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("existing categories and missing download dirs are untouched", func(t *testing.T) {
|
||||
existing := []*DownloadCategory{{Name: "Mine", Path: "/downloads/Mine"}}
|
||||
cfg := &DownloaderStoreConfig{DownloadDir: t.TempDir(), Categories: existing}
|
||||
cfg.Init()
|
||||
if len(cfg.Categories) != 1 || cfg.Categories[0].Name != "Mine" {
|
||||
t.Fatalf("categories = %v, want the existing one", cfg.Categories)
|
||||
}
|
||||
|
||||
empty := &DownloaderStoreConfig{}
|
||||
empty.Init()
|
||||
if len(empty.Categories) != 0 {
|
||||
t.Fatalf("categories = %d, want 0 without a download dir", len(empty.Categories))
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestDownloaderStoreConfig_AutoCategorizeEnabled(t *testing.T) {
|
||||
// A config written before the switch existed has no value, and stays off.
|
||||
if (&DownloaderStoreConfig{}).AutoCategorizeEnabled() {
|
||||
t.Fatal("unset auto categorize = enabled, want disabled by default")
|
||||
}
|
||||
if !(&DownloaderStoreConfig{AutoCategorize: util.BoolPtr(true)}).AutoCategorizeEnabled() {
|
||||
t.Fatal("explicit on = disabled, want the opt-in preserved")
|
||||
}
|
||||
if (&DownloaderStoreConfig{AutoCategorize: util.BoolPtr(false)}).AutoCategorizeEnabled() {
|
||||
t.Fatal("explicit off = enabled, want the opt-out preserved")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDownloaderStoreConfig_MigrateLegacyExtraCategories(t *testing.T) {
|
||||
t.Run("legacy built-ins move to the top level with their default extensions", func(t *testing.T) {
|
||||
cfg := &DownloaderStoreConfig{
|
||||
DownloadDir: "/downloads",
|
||||
Extra: map[string]any{
|
||||
"downloadCategories": []any{
|
||||
map[string]any{"name": "", "path": "/old/Music", "isBuiltIn": true, "nameKey": "categoryMusic"},
|
||||
map[string]any{"name": "Games", "path": "/old/Games"},
|
||||
},
|
||||
"themeMode": "dark",
|
||||
},
|
||||
}
|
||||
if !cfg.MigrateLegacyExtraCategories() {
|
||||
t.Fatal("migration reported no change")
|
||||
}
|
||||
if len(cfg.Categories) != 2 {
|
||||
t.Fatalf("categories = %d, want the 2 legacy entries", len(cfg.Categories))
|
||||
}
|
||||
builtIn := cfg.Categories[0]
|
||||
if builtIn.NameKey != "categoryMusic" || !builtIn.IsBuiltIn || builtIn.Path != "/old/Music" {
|
||||
t.Fatalf("built-in category = %+v, want the legacy entry", builtIn)
|
||||
}
|
||||
if len(builtIn.Extensions) == 0 {
|
||||
t.Fatal("migrated built-in must regain its default extensions")
|
||||
}
|
||||
custom := cfg.Categories[1]
|
||||
if custom.Name != "Games" || custom.Path != "/old/Games" || len(custom.Extensions) != 0 {
|
||||
t.Fatalf("custom category = %+v, want the legacy folder untouched", custom)
|
||||
}
|
||||
if _, ok := cfg.Extra["downloadCategories"]; ok {
|
||||
t.Fatal("legacy key must be cleared")
|
||||
}
|
||||
if cfg.Extra["themeMode"] != "dark" {
|
||||
t.Fatalf("unrelated extra entries must survive, got %v", cfg.Extra)
|
||||
}
|
||||
|
||||
// Setup runs Init after the migration, which must not seed over it.
|
||||
cfg.Init()
|
||||
if len(cfg.Categories) != 2 {
|
||||
t.Fatalf("categories after Init = %d, want the migrated list kept", len(cfg.Categories))
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("existing top level categories are left alone", func(t *testing.T) {
|
||||
cfg := &DownloaderStoreConfig{
|
||||
Extra: map[string]any{
|
||||
"downloadCategories": []any{map[string]any{"name": "Old", "path": "/old/Default"}},
|
||||
},
|
||||
Categories: []*DownloadCategory{{Name: "Mine", Path: "/downloads/Mine"}},
|
||||
}
|
||||
if cfg.MigrateLegacyExtraCategories() {
|
||||
t.Fatal("migration must not overwrite existing categories")
|
||||
}
|
||||
if len(cfg.Categories) != 1 || cfg.Categories[0].Name != "Mine" {
|
||||
t.Fatalf("categories = %v, want the existing one", cfg.Categories)
|
||||
}
|
||||
if _, ok := cfg.Extra["downloadCategories"]; !ok {
|
||||
t.Fatal("legacy key must stay for a manual move")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("missing, empty and malformed legacy lists are a no-op", func(t *testing.T) {
|
||||
if (&DownloaderStoreConfig{}).MigrateLegacyExtraCategories() {
|
||||
t.Fatal("empty config reported a change")
|
||||
}
|
||||
unrelated := &DownloaderStoreConfig{
|
||||
Extra: map[string]any{"themeMode": "dark"},
|
||||
}
|
||||
if unrelated.MigrateLegacyExtraCategories() {
|
||||
t.Fatal("config without the legacy key reported a change")
|
||||
}
|
||||
empty := &DownloaderStoreConfig{
|
||||
Extra: map[string]any{"downloadCategories": []any{}},
|
||||
}
|
||||
if empty.MigrateLegacyExtraCategories() {
|
||||
t.Fatal("empty legacy list reported a change")
|
||||
}
|
||||
malformed := &DownloaderStoreConfig{
|
||||
Extra: map[string]any{"downloadCategories": "not-a-list"},
|
||||
}
|
||||
if malformed.MigrateLegacyExtraCategories() {
|
||||
t.Fatal("malformed legacy list reported a change")
|
||||
}
|
||||
if _, ok := malformed.Extra["downloadCategories"]; !ok {
|
||||
t.Fatal("malformed legacy key must stay untouched")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("a nil entry in the legacy list is skipped", func(t *testing.T) {
|
||||
cfg := &DownloaderStoreConfig{
|
||||
DownloadDir: "/downloads",
|
||||
Extra: map[string]any{
|
||||
"downloadCategories": []any{
|
||||
nil,
|
||||
map[string]any{"name": "Games", "path": "/old/Games"},
|
||||
},
|
||||
},
|
||||
}
|
||||
if !cfg.MigrateLegacyExtraCategories() {
|
||||
t.Fatal("migration reported no change")
|
||||
}
|
||||
if len(cfg.Categories) != 1 || cfg.Categories[0].Name != "Games" {
|
||||
t.Fatalf("categories = %+v, want the single valid legacy entry", cfg.Categories)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestDownloaderStoreConfig_AutoCategorizeJSON(t *testing.T) {
|
||||
unset, err := json.Marshal(&DownloaderStoreConfig{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if strings.Contains(string(unset), `"autoCategorize"`) {
|
||||
t.Fatalf("unset autoCategorize must be omitted from the api json, got %s", unset)
|
||||
}
|
||||
off, err := json.Marshal(&DownloaderStoreConfig{AutoCategorize: util.BoolPtr(false)})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !strings.Contains(string(off), `"autoCategorize":false`) {
|
||||
t.Fatalf("explicit autoCategorize=false must be emitted, got %s", off)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDownloaderStoreConfig_Merge(t *testing.T) {
|
||||
type args struct {
|
||||
beforeCfg *DownloaderStoreConfig
|
||||
@@ -215,6 +437,24 @@ func TestDownloaderStoreConfig_Merge(t *testing.T) {
|
||||
DownloadDir: "after",
|
||||
},
|
||||
},
|
||||
{
|
||||
"Merge Categories and AutoCategorize Override",
|
||||
&DownloaderStoreConfig{},
|
||||
args{
|
||||
beforeCfg: &DownloaderStoreConfig{
|
||||
Categories: []*DownloadCategory{
|
||||
{Name: "Music", Path: "/downloads/Music"},
|
||||
},
|
||||
AutoCategorize: util.BoolPtr(true),
|
||||
},
|
||||
},
|
||||
&DownloaderStoreConfig{
|
||||
Categories: []*DownloadCategory{
|
||||
{Name: "Music", Path: "/downloads/Music"},
|
||||
},
|
||||
AutoCategorize: util.BoolPtr(true),
|
||||
},
|
||||
},
|
||||
{
|
||||
"Merge MaxRunning No Override",
|
||||
&DownloaderStoreConfig{
|
||||
@@ -428,6 +668,8 @@ func TestDownloaderStoreConfig_Merge(t *testing.T) {
|
||||
Webhook: tt.fields.Webhook,
|
||||
AutoTorrent: tt.fields.AutoTorrent,
|
||||
Archive: tt.fields.Archive,
|
||||
Categories: tt.fields.Categories,
|
||||
AutoCategorize: tt.fields.AutoCategorize,
|
||||
}
|
||||
if got := cfg.Merge(tt.args.beforeCfg); !reflect.DeepEqual(got, tt.want) {
|
||||
t.Errorf("Merge() = %v, want %v", got, tt.want)
|
||||
|
||||
@@ -0,0 +1,245 @@
|
||||
package download
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/GopeedLab/gopeed/internal/fetcher"
|
||||
btProtocol "github.com/GopeedLab/gopeed/internal/protocol/bt"
|
||||
httpProtocol "github.com/GopeedLab/gopeed/internal/protocol/http"
|
||||
"github.com/GopeedLab/gopeed/pkg/base"
|
||||
)
|
||||
|
||||
type testJobManager struct {
|
||||
httpProtocol.FetcherManager
|
||||
job fetcher.CronJob
|
||||
}
|
||||
|
||||
func (m *testJobManager) Name() string { return "test" }
|
||||
func (m *testJobManager) CronJobs(fetcher.ProtocolHost) []fetcher.CronJob {
|
||||
return []fetcher.CronJob{m.job}
|
||||
}
|
||||
func testBackendDownloader(t *testing.T, managers ...fetcher.FetcherManager) *Downloader {
|
||||
t.Helper()
|
||||
d := NewDownloader(&DownloaderConfig{StorageDir: t.TempDir(), TempDir: t.TempDir(), FetchManagers: managers})
|
||||
if err := d.Setup(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
if err := d.Close(); err != nil {
|
||||
t.Error(err)
|
||||
}
|
||||
})
|
||||
return d
|
||||
}
|
||||
func TestProtocolJobCoalescesAndCancelsOnClose(t *testing.T) {
|
||||
entered := make(chan struct{})
|
||||
var calls atomic.Int32
|
||||
manager := &testJobManager{job: fetcher.CronJob{Name: "refresh", Every: time.Hour, Timeout: time.Minute, Run: func(ctx context.Context, _ fetcher.TriggerSource) error {
|
||||
if calls.Add(1) == 1 {
|
||||
close(entered)
|
||||
}
|
||||
<-ctx.Done()
|
||||
return ctx.Err()
|
||||
}}}
|
||||
d := testBackendDownloader(t, manager)
|
||||
result := make(chan error, 2)
|
||||
go func() { result <- d.TriggerProtocolJob(context.Background(), "test", "refresh") }()
|
||||
<-entered
|
||||
waiterCtx, cancel := context.WithCancel(context.Background())
|
||||
// A canceled waiter joins the existing work without canceling it.
|
||||
cancel()
|
||||
if err := d.TriggerProtocolJob(waiterCtx, "test", "refresh"); !errors.Is(err, context.Canceled) {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if calls.Load() != 1 {
|
||||
t.Fatalf("executed %d times", calls.Load())
|
||||
}
|
||||
if err := d.Close(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := <-result; !errors.Is(err, context.Canceled) {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := d.TriggerProtocolJob(context.Background(), "test", "missing"); !errors.Is(err, ErrProtocolJobNotFound) {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
func TestProtocolJobPersistsSuccess(t *testing.T) {
|
||||
manager := &testJobManager{job: fetcher.CronJob{Name: "refresh", Every: time.Hour, Timeout: time.Minute, Run: func(context.Context, fetcher.TriggerSource) error { return nil }}}
|
||||
d := testBackendDownloader(t, manager)
|
||||
if err := d.TriggerProtocolJob(context.Background(), "test", "refresh"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var state protocolJobState
|
||||
exists, err := d.storage.Get(bucketProtocolJobs, "test/refresh", &state)
|
||||
if err != nil || !exists || state.LastSuccess.IsZero() {
|
||||
t.Fatalf("state %+v: %v", state, err)
|
||||
}
|
||||
}
|
||||
func TestRaceResourceValidatesResponsesAndCancelsLosers(t *testing.T) {
|
||||
loserCanceled := make(chan struct{})
|
||||
loserStarted := make(chan struct{})
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
switch {
|
||||
case r.Method == http.MethodConnect:
|
||||
http.Error(w, "official unavailable", http.StatusBadGateway)
|
||||
case strings.HasPrefix(r.URL.Path, "/bad/"):
|
||||
io.WriteString(w, "<html>proxy landing page</html>")
|
||||
case strings.HasPrefix(r.URL.Path, "/good/"):
|
||||
<-loserStarted
|
||||
io.WriteString(w, `{"ok":true}`)
|
||||
case strings.HasPrefix(r.URL.Path, "/slow/"):
|
||||
close(loserStarted)
|
||||
<-r.Context().Done()
|
||||
close(loserCanceled)
|
||||
default:
|
||||
http.NotFound(w, r)
|
||||
}
|
||||
}))
|
||||
defer server.Close()
|
||||
d := testBackendDownloader(t, &httpProtocol.FetcherManager{})
|
||||
cfg, _ := d.GetConfig()
|
||||
cfg.Proxy = &base.DownloaderProxyConfig{Enable: true, Scheme: "http", Host: strings.TrimPrefix(server.URL, "http://")}
|
||||
cfg.GithubMirror = &base.GithubMirrorConfig{Enabled: true, Mirrors: []base.GithubMirror{
|
||||
{Type: "ghProxy", URL: server.URL + "/bad"},
|
||||
{Type: "ghProxy", URL: server.URL + "/slow"},
|
||||
{Type: "ghProxy", URL: server.URL + "/good"},
|
||||
}}
|
||||
if err := d.PutConfig(cfg); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
body, err := d.fetchResource(context.Background(), "https://raw.githubusercontent.com/owner/repo/main/test.json", 1024, false, func(body []byte) error {
|
||||
if !json.Valid(body) {
|
||||
return errors.New("invalid JSON")
|
||||
}
|
||||
return nil
|
||||
})
|
||||
if err != nil || string(body) != `{"ok":true}` {
|
||||
t.Fatalf("%s %v", body, err)
|
||||
}
|
||||
select {
|
||||
case <-loserCanceled:
|
||||
case <-time.After(time.Second):
|
||||
t.Fatal("losing request was not canceled")
|
||||
}
|
||||
}
|
||||
|
||||
func TestProtocolConfigInitializesCurrentSchema(t *testing.T) {
|
||||
d := NewDownloader(&DownloaderConfig{StorageDir: t.TempDir(), TempDir: t.TempDir(), FetchManagers: []fetcher.FetcherManager{&httpProtocol.FetcherManager{}}})
|
||||
d.cfg.DownloaderStoreConfig = &base.DownloaderStoreConfig{
|
||||
GithubMirror: &base.GithubMirrorConfig{Enabled: false, Mirrors: []base.GithubMirror{}},
|
||||
ProtocolConfig: map[string]any{"http": map[string]any{"connections": 3}},
|
||||
}
|
||||
if err := d.storage.Setup([]string{bucketConfig}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer d.storage.Close()
|
||||
if err := d.initProtocolConfigs(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if d.cfg.GithubMirror.Enabled || len(d.cfg.GithubMirror.Mirrors) != 0 {
|
||||
t.Fatal("explicit disabled/empty mirrors lost")
|
||||
}
|
||||
value, err := d.readProtocolConfig("http")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
raw, _ := json.Marshal(value)
|
||||
var cfg struct {
|
||||
Connections int `json:"connections"`
|
||||
}
|
||||
if err := json.Unmarshal(raw, &cfg); err != nil || cfg.Connections != 3 {
|
||||
t.Fatalf("current protocol config: %s %v", raw, err)
|
||||
}
|
||||
if err := d.initProtocolConfigs(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProtocolConfigDoesNotImportOldExtraSettings(t *testing.T) {
|
||||
d := NewDownloader(&DownloaderConfig{StorageDir: t.TempDir(), TempDir: t.TempDir(), FetchManagers: []fetcher.FetcherManager{&btProtocol.FetcherManager{}}})
|
||||
d.cfg.DownloaderStoreConfig = &base.DownloaderStoreConfig{
|
||||
Extra: map[string]any{
|
||||
"githubMirror": "obsolete invalid value",
|
||||
"bt": map[string]any{"customTrackers": []string{"udp://old.example:80/announce"}, "autoUpdateTrackers": true},
|
||||
},
|
||||
ProtocolConfig: map[string]any{"bt": map[string]any{"autoUpdateTrackers": false, "listenPort": 6881}},
|
||||
}
|
||||
if err := d.storage.Setup([]string{bucketConfig}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer d.storage.Close()
|
||||
if err := d.initProtocolConfigs(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !d.cfg.GithubMirror.Enabled || len(d.cfg.GithubMirror.Mirrors) != 4 {
|
||||
t.Fatal("missing root mirror config must use new defaults")
|
||||
}
|
||||
value, err := d.readProtocolConfig("bt")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
raw, _ := json.Marshal(value)
|
||||
var cfg struct {
|
||||
ListenPort int `json:"listenPort"`
|
||||
AutoUpdateTrackers bool `json:"autoUpdateTrackers"`
|
||||
CustomTrackers []string `json:"customTrackers"`
|
||||
}
|
||||
if err := json.Unmarshal(raw, &cfg); err != nil || cfg.ListenPort != 6881 || cfg.AutoUpdateTrackers || len(cfg.CustomTrackers) != 0 {
|
||||
t.Fatalf("old extra settings affected protocol config: %s %v", raw, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProtocolJobsRunAutomaticallyAndRestoreSuccess(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
executed := make(chan fetcher.TriggerSource, 4)
|
||||
manager := func() *testJobManager {
|
||||
return &testJobManager{job: fetcher.CronJob{Name: "refresh", Every: time.Hour, Timeout: time.Second, Run: func(_ context.Context, source fetcher.TriggerSource) error { executed <- source; return nil }}}
|
||||
}
|
||||
makeDownloader := func() *Downloader {
|
||||
d := NewDownloader(&DownloaderConfig{Storage: NewBoltStorage(dir), StorageDir: dir, TempDir: t.TempDir(), FetchManagers: []fetcher.FetcherManager{manager()}})
|
||||
if err := d.Setup(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return d
|
||||
}
|
||||
d := makeDownloader()
|
||||
select {
|
||||
case source := <-executed:
|
||||
if source != fetcher.TriggerScheduled {
|
||||
t.Fatal(source)
|
||||
}
|
||||
case <-time.After(3 * time.Second):
|
||||
t.Fatal("overdue job was not run")
|
||||
}
|
||||
entry := d.protocolJobs.jobs["test/refresh"]
|
||||
entry.mu.Lock()
|
||||
run := entry.active
|
||||
entry.mu.Unlock()
|
||||
if run != nil {
|
||||
<-run.done
|
||||
}
|
||||
if err := d.Close(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
d = makeDownloader()
|
||||
defer d.Close()
|
||||
state := d.protocolJobs.jobs["test/refresh"].state
|
||||
if state.LastSuccess.IsZero() {
|
||||
t.Fatal("successful execution not restored")
|
||||
}
|
||||
select {
|
||||
case <-executed:
|
||||
t.Fatal("recently successful job ran again on restart")
|
||||
case <-time.After(30 * time.Millisecond):
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,186 @@
|
||||
package download
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"path"
|
||||
"strings"
|
||||
"unicode"
|
||||
|
||||
"github.com/GopeedLab/gopeed/pkg/base"
|
||||
)
|
||||
|
||||
// categoryForResource returns the category that receives the resolved
|
||||
// resource, or nil when auto categorize does not apply. Only a single file
|
||||
// resource is routed: a folder or a multi file resource keeps its directory
|
||||
// layout, so its files are never matched one by one, not even when only a
|
||||
// single one of them is selected.
|
||||
func categoryForResource(cfg *base.DownloaderStoreConfig, res *base.Resource) *base.DownloadCategory {
|
||||
if !cfg.AutoCategorizeEnabled() || len(cfg.Categories) == 0 {
|
||||
return nil
|
||||
}
|
||||
if res == nil || res.Name != "" || len(res.Files) != 1 {
|
||||
return nil
|
||||
}
|
||||
return matchDownloadCategory(cfg, res.Files[0].Name)
|
||||
}
|
||||
|
||||
// decideDownloadDir returns the download directory of a task. The base
|
||||
// directory is the one the task asked for, or the configured download
|
||||
// directory when the task left it empty. A task that did not skip auto
|
||||
// categorize is routed to the category directory of its resolved resource
|
||||
// when one matches. Path placeholders are expanded and the white list check
|
||||
// runs on the result, so a category directory the white list rejects falls
|
||||
// back to the base directory of this task instead of failing it.
|
||||
func (d *Downloader) decideDownloadDir(opts *base.Options, res *base.Resource) (string, error) {
|
||||
if opts == nil {
|
||||
opts = &base.Options{}
|
||||
}
|
||||
cfg, err := d.GetConfig()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
base := dartTrimSpace(opts.Path)
|
||||
if base == "" {
|
||||
base = cfg.DownloadDir
|
||||
}
|
||||
// Refuse to fall through to the process working directory when neither the
|
||||
// task nor the config names a directory.
|
||||
if base == "" {
|
||||
return "", errors.New("download directory is empty")
|
||||
}
|
||||
target := base
|
||||
if !opts.SkipAutoCategorize {
|
||||
if category := categoryForResource(cfg, res); category != nil {
|
||||
target = dartTrimSpace(category.Path)
|
||||
}
|
||||
}
|
||||
dir, err := d.initDownloadPath(target)
|
||||
if err != nil && target != base {
|
||||
d.Logger.Warn().Err(err).Msgf("category directory %q rejected by white list, falling back to %q", target, base)
|
||||
dir, err = d.initDownloadPath(base)
|
||||
}
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return dir, nil
|
||||
}
|
||||
|
||||
// suggestedDownloadDir returns the category directory a task would be routed
|
||||
// to, or an empty string when auto categorize does not apply. It only reports
|
||||
// the decision of decideDownloadDir, it expands no directory and writes
|
||||
// nothing.
|
||||
func (d *Downloader) suggestedDownloadDir(res *base.Resource) string {
|
||||
cfg, err := d.GetConfig()
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
category := categoryForResource(cfg, res)
|
||||
if category == nil {
|
||||
return ""
|
||||
}
|
||||
dir, err := d.initDownloadPath(dartTrimSpace(category.Path))
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
return dir
|
||||
}
|
||||
|
||||
// asciiLower lowercases the ascii letters of s and leaves the rest alone. The
|
||||
// ui lowercases the same way, because the unicode case tables of the two
|
||||
// languages disagree for some code points and would let the two sides pick a
|
||||
// different category for the same file.
|
||||
func asciiLower(s string) string {
|
||||
hasUpper := false
|
||||
for i := 0; i < len(s); i++ {
|
||||
if c := s[i]; c >= 'A' && c <= 'Z' {
|
||||
hasUpper = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !hasUpper {
|
||||
return s
|
||||
}
|
||||
b := []byte(s)
|
||||
for i, c := range b {
|
||||
if c >= 'A' && c <= 'Z' {
|
||||
b[i] = c + ('a' - 'A')
|
||||
}
|
||||
}
|
||||
return string(b)
|
||||
}
|
||||
|
||||
// matchDownloadCategory returns the first category whose extension list
|
||||
// contains the extension of the given file name, or nil when nothing matches.
|
||||
// An empty extension list never matches.
|
||||
func matchDownloadCategory(cfg *base.DownloaderStoreConfig, name string) *base.DownloadCategory {
|
||||
ext := fileExtension(name)
|
||||
if ext == "" {
|
||||
return nil
|
||||
}
|
||||
for _, category := range cfg.Categories {
|
||||
if category == nil || category.IsDeleted || dartTrimSpace(category.Path) == "" {
|
||||
continue
|
||||
}
|
||||
for _, e := range normalizeCategoryExtensions(category.Extensions) {
|
||||
if e == ext {
|
||||
return category
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// normalizeCategoryExtensions mirrors the dart side: entries are split on
|
||||
// commas and whitespace, trimmed, lowercased, stripped of leading dots and
|
||||
// deduplicated while keeping the input order, so a stored value like
|
||||
// "exe, msi" behaves the same in the backend as it does in the ui.
|
||||
func normalizeCategoryExtensions(raw []string) []string {
|
||||
var result []string
|
||||
seen := make(map[string]struct{}, len(raw))
|
||||
for _, item := range raw {
|
||||
for _, part := range strings.FieldsFunc(item, func(r rune) bool {
|
||||
return r == ',' || isECMAScriptSpace(r)
|
||||
}) {
|
||||
ext := asciiLower(strings.TrimLeft(dartTrimSpace(part), "."))
|
||||
if ext == "" {
|
||||
continue
|
||||
}
|
||||
if _, ok := seen[ext]; ok {
|
||||
continue
|
||||
}
|
||||
seen[ext] = struct{}{}
|
||||
result = append(result, ext)
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// fileExtension returns the lower case extension of a file name without the
|
||||
// leading dot, e.g. "setup.exe" -> "exe". Windows style separators are
|
||||
// treated like the ui does, so a dot inside a directory segment never counts
|
||||
// as an extension.
|
||||
func fileExtension(name string) string {
|
||||
name = strings.ReplaceAll(name, `\`, "/")
|
||||
return strings.TrimPrefix(asciiLower(path.Ext(name)), ".")
|
||||
}
|
||||
|
||||
// dartTrimSpace trims the whitespace the dart String.trim() removes, which is
|
||||
// the unicode white space set plus the byte order mark. The ui trims the
|
||||
// category paths and the extension lists with it, so the backend uses the same
|
||||
// set instead of the narrower unicode.IsSpace.
|
||||
func dartTrimSpace(s string) string {
|
||||
return strings.TrimFunc(s, func(r rune) bool {
|
||||
return unicode.IsSpace(r) || r == 0xFEFF
|
||||
})
|
||||
}
|
||||
|
||||
// isECMAScriptSpace reports whether r belongs to the whitespace class of the
|
||||
// ECMAScript regular expressions. The ui splits its extension lists with that
|
||||
// class, so the backend uses the same one instead of the wider unicode.IsSpace.
|
||||
func isECMAScriptSpace(r rune) bool {
|
||||
switch r {
|
||||
case '\t', '\n', '\v', '\f', '\r', ' ', 0x00A0, 0x1680, 0x2028, 0x2029, 0x202F, 0x205F, 0x3000, 0xFEFF:
|
||||
return true
|
||||
}
|
||||
return r >= 0x2000 && r <= 0x200A
|
||||
}
|
||||
@@ -0,0 +1,566 @@
|
||||
package download
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"reflect"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/GopeedLab/gopeed/pkg/base"
|
||||
"github.com/GopeedLab/gopeed/pkg/util"
|
||||
)
|
||||
|
||||
type failNextTaskPutStorage struct {
|
||||
Storage
|
||||
fail atomic.Bool
|
||||
failBucket string
|
||||
beforeTaskPut func(*Task)
|
||||
}
|
||||
|
||||
func (s *failNextTaskPutStorage) Put(bucket string, key string, value any) error {
|
||||
if bucket == bucketTask && s.beforeTaskPut != nil {
|
||||
s.beforeTaskPut(value.(*Task))
|
||||
}
|
||||
failBucket := s.failBucket
|
||||
if failBucket == "" {
|
||||
failBucket = bucketTask
|
||||
}
|
||||
if bucket == failBucket && (bucket != bucketTask || !value.(*Task).NeedsPreparation) && s.fail.CompareAndSwap(true, false) {
|
||||
return errors.New("task snapshot write failed")
|
||||
}
|
||||
return s.Storage.Put(bucket, key, value)
|
||||
}
|
||||
|
||||
func TestDownloader_PreparationSaveFailureCanRetry(t *testing.T) {
|
||||
for _, failBucket := range []string{bucketSave, bucketTask} {
|
||||
for _, restart := range []bool{false, true} {
|
||||
t.Run(fmt.Sprintf("%s/restart=%t", failBucket, restart), func(t *testing.T) {
|
||||
url := startCategoryFileServer(t, "setup.data", []byte("0123456789"))
|
||||
downloadDir := t.TempDir()
|
||||
storageDir := t.TempDir()
|
||||
storage := &failNextTaskPutStorage{Storage: NewBoltStorage(storageDir), failBucket: failBucket}
|
||||
downloader := NewDownloader(&DownloaderConfig{Storage: storage, StorageDir: storageDir})
|
||||
if err := downloader.Setup(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { downloader.Close() })
|
||||
categoryDir := putCategoryConfig(t, downloader, downloadDir, true, []string{"data"})
|
||||
if err := os.MkdirAll(categoryDir, 0755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(categoryDir, "setup.data"), []byte("existing"), 0644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
failed := watchTaskError(t, downloader)
|
||||
storage.fail.Store(true)
|
||||
id, err := downloader.CreateDirect(&base.Request{URL: url}, &base.Options{Path: downloadDir})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
waitTaskError(t, failed)
|
||||
task := downloader.GetTask(id)
|
||||
if !task.NeedsPreparation || task.Meta.Opts.Path != downloadDir || task.Meta.Opts.Name != "" {
|
||||
t.Fatal("failed preparation changed the original directory, name or flag")
|
||||
}
|
||||
if restart {
|
||||
if err := downloader.Close(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
downloader = NewDownloader(&DownloaderConfig{Storage: NewBoltStorage(storageDir), StorageDir: storageDir})
|
||||
if err := downloader.Setup(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
done := watchTaskDone(t, downloader)
|
||||
if err := downloader.Continue(&TaskFilter{IDs: []string{id}}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
waitTaskDone(t, done)
|
||||
task = downloader.GetTask(id)
|
||||
if task.NeedsPreparation || task.Meta.Opts.Path != categoryDir || task.Meta.Opts.Name != "setup (1).data" {
|
||||
t.Fatalf("retry did not prepare the final directory and duplicate name: %+v", task.Meta.Opts)
|
||||
}
|
||||
data, err := os.ReadFile(filepath.Join(categoryDir, "setup (1).data"))
|
||||
if err != nil || string(data) != "0123456789" {
|
||||
t.Fatalf("downloaded content = %q, %v", data, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// watchTaskError subscribes to the error event, so a failing task can be
|
||||
// awaited instead of polled.
|
||||
func watchTaskError(t *testing.T, d *Downloader) <-chan struct{} {
|
||||
t.Helper()
|
||||
failed := make(chan struct{}, 1)
|
||||
d.Listener(func(event *Event) {
|
||||
if event.Key == EventKeyError {
|
||||
select {
|
||||
case failed <- struct{}{}:
|
||||
default:
|
||||
}
|
||||
}
|
||||
})
|
||||
return failed
|
||||
}
|
||||
|
||||
func waitTaskError(t *testing.T, failed <-chan struct{}) {
|
||||
t.Helper()
|
||||
select {
|
||||
case <-failed:
|
||||
case <-time.After(30 * time.Second):
|
||||
t.Fatal("task did not fail")
|
||||
}
|
||||
}
|
||||
|
||||
// waitTaskStatus waits until the task reaches one of the given statuses.
|
||||
func waitTaskStatus(t *testing.T, d *Downloader, task *Task, statuses ...base.Status) base.Status {
|
||||
t.Helper()
|
||||
deadline := time.Now().Add(30 * time.Second)
|
||||
for time.Now().Before(deadline) {
|
||||
status := d.taskStatus(task)
|
||||
for _, want := range statuses {
|
||||
if status == want {
|
||||
return status
|
||||
}
|
||||
}
|
||||
time.Sleep(20 * time.Millisecond)
|
||||
}
|
||||
t.Fatalf("task status = %q, want one of %v", d.taskStatus(task), statuses)
|
||||
return ""
|
||||
}
|
||||
|
||||
// setMaxRunning limits the number of tasks that run at the same time.
|
||||
func setMaxRunning(t *testing.T, d *Downloader, max int) {
|
||||
t.Helper()
|
||||
cfg, err := d.GetConfig()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
cfg.MaxRunning = max
|
||||
if err := d.PutConfig(cfg); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
// A task that waits in the queue is prepared when it starts, the first
|
||||
// preparation must not be skipped by the wait status.
|
||||
func TestDownloader_QueuedTaskIsPreparedWhenItStarts(t *testing.T) {
|
||||
release := make(chan struct{})
|
||||
slow := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
<-release
|
||||
w.Header().Set("Content-Disposition", `attachment; filename="slow.bin"`)
|
||||
w.Write([]byte("slow"))
|
||||
}))
|
||||
defer slow.Close()
|
||||
fast := startCategoryFileServer(t, "setup.data", []byte("0123456789"))
|
||||
|
||||
downloadDir := t.TempDir()
|
||||
downloader := newSetupCategoryDownloader(t)
|
||||
categoryDir := putCategoryConfig(t, downloader, downloadDir, true, []string{"data"})
|
||||
setMaxRunning(t, downloader, 1)
|
||||
|
||||
// The blocker takes the only running slot.
|
||||
blockerID, err := downloader.CreateDirect(&base.Request{URL: slow.URL}, &base.Options{Path: downloadDir})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
blocker := downloader.GetTask(blockerID)
|
||||
if blocker == nil {
|
||||
t.Fatal("blocker task not found")
|
||||
}
|
||||
waitTaskStatus(t, downloader, blocker, base.DownloadStatusRunning)
|
||||
|
||||
queuedID, err := downloader.CreateDirect(&base.Request{URL: fast}, &base.Options{Path: downloadDir})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
queued := downloader.GetTask(queuedID)
|
||||
if queued == nil {
|
||||
t.Fatal("task not found")
|
||||
}
|
||||
if queued.Status != base.DownloadStatusWait {
|
||||
t.Fatalf("queued task status = %q, want wait", queued.Status)
|
||||
}
|
||||
|
||||
close(release)
|
||||
waitTaskStatus(t, downloader, queued, base.DownloadStatusDone, base.DownloadStatusError)
|
||||
|
||||
if queued.Meta.Opts.Path != categoryDir {
|
||||
t.Fatalf("queued task path = %q, want the category dir %q", queued.Meta.Opts.Path, categoryDir)
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(categoryDir, "setup.data")); err != nil {
|
||||
t.Fatalf("categorized file missing: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// A task that failed before it was prepared is prepared when it continues.
|
||||
func TestDownloader_FailedTaskIsPreparedWhenItContinues(t *testing.T) {
|
||||
url := startCategoryFileServer(t, "setup.data", []byte("0123456789"))
|
||||
downloadDir := t.TempDir()
|
||||
allowedDir := t.TempDir()
|
||||
|
||||
downloader := newSetupCategoryDownloader(t)
|
||||
categoryDir := putCategoryConfig(t, downloader, downloadDir, true, []string{"data"})
|
||||
// The white list rejects the base directory, so the first start fails.
|
||||
downloader.cfg.WhiteDownloadDirs = []string{allowedDir}
|
||||
|
||||
failed := watchTaskError(t, downloader)
|
||||
taskID, err := downloader.CreateDirect(&base.Request{URL: url}, &base.Options{Path: downloadDir})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
task := downloader.GetTask(taskID)
|
||||
if task == nil {
|
||||
t.Fatal("task not found")
|
||||
}
|
||||
waitTaskError(t, failed)
|
||||
waitTaskStatus(t, downloader, task, base.DownloadStatusError)
|
||||
if !task.NeedsPreparation {
|
||||
t.Fatal("a failed preparation must keep the needs preparation flag")
|
||||
}
|
||||
|
||||
// The white list accepts the base directory now, the retry prepares the
|
||||
// task again.
|
||||
downloader.cfg.WhiteDownloadDirs = []string{allowedDir, downloadDir, categoryDir}
|
||||
done := watchTaskDone(t, downloader)
|
||||
if err := downloader.Continue(&TaskFilter{IDs: []string{taskID}}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
waitTaskDone(t, done)
|
||||
|
||||
if task.Meta.Opts.Path != categoryDir {
|
||||
t.Fatalf("retried task path = %q, want the category dir %q", task.Meta.Opts.Path, categoryDir)
|
||||
}
|
||||
if task.NeedsPreparation {
|
||||
t.Fatal("a prepared task must clear the needs preparation flag")
|
||||
}
|
||||
}
|
||||
|
||||
// A failed preparation save leaves the live task untouched, so the next
|
||||
// attempt starts from the original base directory and name.
|
||||
func TestDownloader_PreparationSaveFailureKeepsOriginalData(t *testing.T) {
|
||||
url := startCategoryFileServer(t, "setup.data", []byte("0123456789"))
|
||||
downloadDir := t.TempDir()
|
||||
storage := &failNextTaskPutStorage{Storage: NewMemStorage()}
|
||||
downloader := NewDownloader(&DownloaderConfig{Storage: storage})
|
||||
if err := downloader.Setup(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
downloader.Delete(nil, true)
|
||||
downloader.Clear()
|
||||
})
|
||||
categoryDir := putCategoryConfig(t, downloader, downloadDir, true, []string{"data"})
|
||||
setMaxRunning(t, downloader, 0)
|
||||
|
||||
result, err := downloader.Resolve(&base.Request{URL: url})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
taskID, err := downloader.CreateWithOptions(result.ID, &base.Options{
|
||||
Path: downloadDir,
|
||||
Name: "renamed?.data",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
task := downloader.GetTask(taskID)
|
||||
if task == nil {
|
||||
t.Fatal("task not found")
|
||||
}
|
||||
originalMeta := util.DeepClone(task.Meta)
|
||||
originalRequest := task.Meta.Req
|
||||
storage.beforeTaskPut = func(snapshot *Task) {
|
||||
if !task.NeedsPreparation || !reflect.DeepEqual(task.Meta, originalMeta) {
|
||||
t.Error("preparation was published before the snapshot was saved")
|
||||
}
|
||||
if snapshot.NeedsPreparation || snapshot.Meta.Opts.Path != categoryDir {
|
||||
t.Error("the snapshot must contain the prepared directory and cleared flag")
|
||||
}
|
||||
}
|
||||
|
||||
storage.fail.Store(true)
|
||||
if err := downloader.prepareTask(task); err == nil {
|
||||
t.Fatal("prepareTask() succeeded, want a storage error")
|
||||
}
|
||||
if !task.NeedsPreparation {
|
||||
t.Fatal("a failed save must keep the needs preparation flag")
|
||||
}
|
||||
if !reflect.DeepEqual(task.Meta, originalMeta) {
|
||||
t.Fatalf("task metadata changed after a failed save:\n got: %#v\nwant: %#v", task.Meta, originalMeta)
|
||||
}
|
||||
if !reflect.DeepEqual(task.fetcher.Meta(), originalMeta) {
|
||||
t.Fatalf("fetcher metadata changed after a failed save:\n got: %#v\nwant: %#v", task.fetcher.Meta(), originalMeta)
|
||||
}
|
||||
|
||||
if err := downloader.prepareTask(task); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if task.NeedsPreparation {
|
||||
t.Fatal("a successful retry must clear the needs preparation flag")
|
||||
}
|
||||
if task.Meta.Opts.Path != categoryDir {
|
||||
t.Fatalf("retried task path = %q, want %q", task.Meta.Opts.Path, categoryDir)
|
||||
}
|
||||
if task.Meta != task.fetcher.Meta() || task.Meta.Req != originalRequest {
|
||||
t.Fatal("preparation must preserve the shared metadata and resolved request")
|
||||
}
|
||||
storage.beforeTaskPut = nil
|
||||
task.Meta.Res.Size++
|
||||
var saved Task
|
||||
if _, err := storage.Get(bucketTask, taskID, &saved); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if saved.Meta.Res.Size != originalMeta.Res.Size {
|
||||
t.Fatal("live metadata changes must not alter the saved task snapshot")
|
||||
}
|
||||
}
|
||||
|
||||
// The white list is checked on every start, a task that was rejected once
|
||||
// keeps failing instead of writing into the rejected directory.
|
||||
func TestDownloader_WhiteListIsCheckedOnEveryStart(t *testing.T) {
|
||||
url := startCategoryFileServer(t, "setup.data", []byte("0123456789"))
|
||||
downloadDir := t.TempDir()
|
||||
allowedDir := t.TempDir()
|
||||
|
||||
downloader := newSetupCategoryDownloader(t)
|
||||
putCategoryConfig(t, downloader, downloadDir, true, []string{"data"})
|
||||
downloader.cfg.WhiteDownloadDirs = []string{allowedDir}
|
||||
|
||||
failed := watchTaskError(t, downloader)
|
||||
taskID, err := downloader.CreateDirect(&base.Request{URL: url}, &base.Options{Path: downloadDir})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
task := downloader.GetTask(taskID)
|
||||
if task == nil {
|
||||
t.Fatal("task not found")
|
||||
}
|
||||
waitTaskError(t, failed)
|
||||
waitTaskStatus(t, downloader, task, base.DownloadStatusError)
|
||||
|
||||
// The task is continued, the white list still rejects the directory.
|
||||
failed = watchTaskError(t, downloader)
|
||||
if err := downloader.Continue(&TaskFilter{IDs: []string{taskID}}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
waitTaskError(t, failed)
|
||||
waitTaskStatus(t, downloader, task, base.DownloadStatusError)
|
||||
|
||||
if _, err := os.Stat(filepath.Join(downloadDir, "setup.data")); err == nil {
|
||||
t.Fatalf("the file was written into the rejected dir %q", downloadDir)
|
||||
}
|
||||
}
|
||||
|
||||
// The white list is checked on every start, even for a task that was prepared
|
||||
// before the white list changed.
|
||||
func TestDownloader_WhiteListIsCheckedForAPreparedTask(t *testing.T) {
|
||||
// The first request is truncated, so the download fails after the task was
|
||||
// prepared and the retry can show that the directory is checked again.
|
||||
var requests int32
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Disposition", `attachment; filename="setup.data"`)
|
||||
w.Header().Set("Content-Length", "8")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
if atomic.AddInt32(&requests, 1) == 1 {
|
||||
w.Write([]byte("0123"))
|
||||
return
|
||||
}
|
||||
w.Write([]byte("01234567"))
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
downloadDir := t.TempDir()
|
||||
allowedDir := t.TempDir()
|
||||
downloader := newSetupCategoryDownloader(t)
|
||||
categoryDir := putCategoryConfig(t, downloader, downloadDir, true, []string{"data"})
|
||||
downloader.cfg.WhiteDownloadDirs = []string{allowedDir, downloadDir, categoryDir}
|
||||
|
||||
failed := watchTaskError(t, downloader)
|
||||
taskID, err := downloader.CreateDirect(&base.Request{URL: server.URL}, &base.Options{Path: downloadDir})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
task := downloader.GetTask(taskID)
|
||||
if task == nil {
|
||||
t.Fatal("task not found")
|
||||
}
|
||||
waitTaskError(t, failed)
|
||||
waitTaskStatus(t, downloader, task, base.DownloadStatusError)
|
||||
if task.Meta.Opts.Path != categoryDir {
|
||||
t.Fatalf("task path = %q, want the category dir %q", task.Meta.Opts.Path, categoryDir)
|
||||
}
|
||||
|
||||
// The white list no longer accepts the prepared directory, the task must
|
||||
// not download into it.
|
||||
downloader.cfg.WhiteDownloadDirs = []string{allowedDir}
|
||||
requestsBeforeRetry := atomic.LoadInt32(&requests)
|
||||
failed = watchTaskError(t, downloader)
|
||||
if err := downloader.Continue(&TaskFilter{IDs: []string{taskID}}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
waitTaskError(t, failed)
|
||||
waitTaskStatus(t, downloader, task, base.DownloadStatusError)
|
||||
|
||||
if atomic.LoadInt32(&requests) != requestsBeforeRetry {
|
||||
t.Fatalf("the task downloaded into the rejected dir %q", categoryDir)
|
||||
}
|
||||
}
|
||||
|
||||
// A prepared task keeps its directory, a later start never classifies it
|
||||
// again.
|
||||
func TestDownloader_PreparedTaskKeepsItsDirectory(t *testing.T) {
|
||||
// The first request is truncated, so the download fails after the task was
|
||||
// prepared and the retry can show that the directory is kept.
|
||||
var requests int32
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Disposition", `attachment; filename="setup.data"`)
|
||||
w.Header().Set("Content-Length", "8")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
if atomic.AddInt32(&requests, 1) == 1 {
|
||||
w.Write([]byte("0123"))
|
||||
return
|
||||
}
|
||||
w.Write([]byte("01234567"))
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
downloadDir := t.TempDir()
|
||||
downloader := newSetupCategoryDownloader(t)
|
||||
categoryDir := putCategoryConfig(t, downloader, downloadDir, true, []string{"data"})
|
||||
|
||||
failed := watchTaskError(t, downloader)
|
||||
taskID, err := downloader.CreateDirect(&base.Request{URL: server.URL}, &base.Options{Path: downloadDir})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
task := downloader.GetTask(taskID)
|
||||
if task == nil {
|
||||
t.Fatal("task not found")
|
||||
}
|
||||
waitTaskError(t, failed)
|
||||
waitTaskStatus(t, downloader, task, base.DownloadStatusError)
|
||||
if task.Meta.Opts.Path != categoryDir {
|
||||
t.Fatalf("task path = %q, want the category dir %q", task.Meta.Opts.Path, categoryDir)
|
||||
}
|
||||
if task.NeedsPreparation {
|
||||
t.Fatal("a prepared task must clear the needs preparation flag")
|
||||
}
|
||||
|
||||
// The category no longer matches the file, the prepared task keeps the
|
||||
// directory it was prepared with.
|
||||
putCategoryConfig(t, downloader, downloadDir, true, []string{"iso"})
|
||||
done := watchTaskDone(t, downloader)
|
||||
if err := downloader.Continue(&TaskFilter{IDs: []string{taskID}}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
waitTaskDone(t, done)
|
||||
|
||||
if task.Meta.Opts.Path != categoryDir {
|
||||
t.Fatalf("task path = %q, want the prepared dir %q", task.Meta.Opts.Path, categoryDir)
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(categoryDir, "setup.data")); err != nil {
|
||||
t.Fatalf("the file was not written into the prepared dir: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// The global default download directory is only saved when the user named the
|
||||
// directory explicitly, an auto categorized task never becomes the default.
|
||||
func TestDownloader_DefaultPathNeedsTheSkipFlag(t *testing.T) {
|
||||
url := startCategoryFileServer(t, "setup.data", []byte("0123456789"))
|
||||
downloadDir := t.TempDir()
|
||||
chosenDir := t.TempDir()
|
||||
downloader := newSetupCategoryDownloader(t)
|
||||
putCategoryConfig(t, downloader, downloadDir, true, []string{"data"})
|
||||
done := watchTaskDone(t, downloader)
|
||||
|
||||
// The skip flag is missing, the task keeps the auto categorize decision
|
||||
// and the global default stays untouched.
|
||||
if _, err := downloader.CreateDirect(&base.Request{URL: url}, &base.Options{
|
||||
Path: chosenDir,
|
||||
AsDefaultPath: true,
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
waitTaskDone(t, done)
|
||||
cfg, err := downloader.GetConfig()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if cfg.DownloadDir == chosenDir {
|
||||
t.Fatalf("default download dir = %q, want it unchanged", cfg.DownloadDir)
|
||||
}
|
||||
|
||||
// The user named the directory, so it becomes the global default.
|
||||
if _, err := downloader.CreateDirect(&base.Request{URL: url}, &base.Options{
|
||||
Path: chosenDir,
|
||||
AsDefaultPath: true,
|
||||
SkipAutoCategorize: true,
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
waitTaskDone(t, done)
|
||||
cfg, err = downloader.GetConfig()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if cfg.DownloadDir != chosenDir {
|
||||
t.Fatalf("default download dir = %q, want %q", cfg.DownloadDir, chosenDir)
|
||||
}
|
||||
}
|
||||
|
||||
// Every task of a batch decides its own directory, the shared options of the
|
||||
// batch do not leak into the single tasks.
|
||||
func TestDownloader_BatchTasksDecideTheirOwnDirectory(t *testing.T) {
|
||||
url := startCategoryFileServer(t, "setup.data", []byte("0123456789"))
|
||||
downloadDir := t.TempDir()
|
||||
chosenDir := t.TempDir()
|
||||
downloader := newSetupCategoryDownloader(t)
|
||||
categoryDir := putCategoryConfig(t, downloader, downloadDir, true, []string{"data"})
|
||||
done := watchTaskDone(t, downloader)
|
||||
|
||||
taskIDs, err := downloader.CreateDirectBatch(&base.CreateTaskBatch{
|
||||
Reqs: []*base.CreateTaskBatchItem{
|
||||
{
|
||||
Req: &base.Request{URL: url},
|
||||
Opts: &base.Options{Path: chosenDir, SkipAutoCategorize: true},
|
||||
},
|
||||
{
|
||||
Req: &base.Request{URL: url},
|
||||
Opts: &base.Options{Path: downloadDir},
|
||||
},
|
||||
},
|
||||
Opts: &base.Options{Path: downloadDir},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(taskIDs) != 2 {
|
||||
t.Fatalf("task count = %d, want 2", len(taskIDs))
|
||||
}
|
||||
waitTaskDone(t, done)
|
||||
waitTaskDone(t, done)
|
||||
|
||||
skipped := downloader.GetTask(taskIDs[0])
|
||||
if skipped == nil {
|
||||
t.Fatal("task not found")
|
||||
}
|
||||
if skipped.Meta.Opts.Path != chosenDir {
|
||||
t.Fatalf("skipped task path = %q, want %q", skipped.Meta.Opts.Path, chosenDir)
|
||||
}
|
||||
categorized := downloader.GetTask(taskIDs[1])
|
||||
if categorized == nil {
|
||||
t.Fatal("task not found")
|
||||
}
|
||||
if categorized.Meta.Opts.Path != categoryDir {
|
||||
t.Fatalf("categorized task path = %q, want %q", categorized.Meta.Opts.Path, categoryDir)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,695 @@
|
||||
package download
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"slices"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/GopeedLab/gopeed/pkg/base"
|
||||
"github.com/GopeedLab/gopeed/pkg/util"
|
||||
)
|
||||
|
||||
func newTestCategoryConfig() *base.DownloaderStoreConfig {
|
||||
return &base.DownloaderStoreConfig{
|
||||
DownloadDir: "./downloads",
|
||||
AutoCategorize: util.BoolPtr(true),
|
||||
Categories: []*base.DownloadCategory{
|
||||
{Name: "Music", Path: filepath.Join("./downloads", "Music"), Extensions: []string{"mp3", "flac"}},
|
||||
{Name: "Video", Path: filepath.Join("./downloads", "Video"), Extensions: []string{"mp4", "mkv"}},
|
||||
{Name: "Program", Path: filepath.Join("./downloads", "Program"), Extensions: []string{"exe", "msi"}},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// singleFileResource is the resource shape of a one file task: no folder name
|
||||
// and a single file entry.
|
||||
func singleFileResource(name string) *base.Resource {
|
||||
return &base.Resource{Files: []*base.FileInfo{{Name: name, Size: 1}}}
|
||||
}
|
||||
|
||||
// folderResource is the resource shape of a multi file task: a folder name and
|
||||
// one entry per file, even when only one of them is selected later.
|
||||
func folderResource(name string, files ...string) *base.Resource {
|
||||
res := &base.Resource{Name: name}
|
||||
for _, file := range files {
|
||||
res.Files = append(res.Files, &base.FileInfo{Name: file, Size: 1})
|
||||
}
|
||||
return res
|
||||
}
|
||||
|
||||
func newUnitCategoryDownloader(cfg *base.DownloaderStoreConfig) *Downloader {
|
||||
d := NewDownloader(nil)
|
||||
d.cfg.DownloaderStoreConfig = cfg
|
||||
return d
|
||||
}
|
||||
|
||||
func TestDecideDownloadDir(t *testing.T) {
|
||||
programDir := filepath.Join("./downloads", "Program")
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
cfg func(cfg *base.DownloaderStoreConfig)
|
||||
opts *base.Options
|
||||
res *base.Resource
|
||||
want string
|
||||
}{
|
||||
{
|
||||
name: "matching single file routes to the category",
|
||||
opts: &base.Options{},
|
||||
res: singleFileResource("setup.exe"),
|
||||
want: programDir,
|
||||
},
|
||||
{
|
||||
name: "an explicit base directory is routed too",
|
||||
opts: &base.Options{Path: "./elsewhere"},
|
||||
res: singleFileResource("setup.exe"),
|
||||
want: programDir,
|
||||
},
|
||||
{
|
||||
name: "skip flag keeps the configured download directory",
|
||||
opts: &base.Options{Path: "./downloads", SkipAutoCategorize: true},
|
||||
res: singleFileResource("setup.exe"),
|
||||
want: "./downloads",
|
||||
},
|
||||
{
|
||||
name: "skip flag keeps an explicit directory",
|
||||
opts: &base.Options{Path: "./elsewhere", SkipAutoCategorize: true},
|
||||
res: singleFileResource("setup.exe"),
|
||||
want: "./elsewhere",
|
||||
},
|
||||
{
|
||||
name: "switch off keeps the base directory",
|
||||
cfg: func(cfg *base.DownloaderStoreConfig) { cfg.AutoCategorize = util.BoolPtr(false) },
|
||||
opts: &base.Options{},
|
||||
res: singleFileResource("setup.exe"),
|
||||
want: "./downloads",
|
||||
},
|
||||
{
|
||||
name: "no categories keeps the base directory",
|
||||
cfg: func(cfg *base.DownloaderStoreConfig) { cfg.Categories = nil },
|
||||
opts: &base.Options{},
|
||||
res: singleFileResource("setup.exe"),
|
||||
want: "./downloads",
|
||||
},
|
||||
{
|
||||
name: "unknown extension keeps the base directory",
|
||||
opts: &base.Options{},
|
||||
res: singleFileResource("archive.zip"),
|
||||
want: "./downloads",
|
||||
},
|
||||
{
|
||||
name: "a name without an extension keeps the base directory",
|
||||
opts: &base.Options{},
|
||||
res: singleFileResource("README"),
|
||||
want: "./downloads",
|
||||
},
|
||||
{
|
||||
name: "a deleted category is skipped",
|
||||
cfg: func(cfg *base.DownloaderStoreConfig) {
|
||||
cfg.Categories = []*base.DownloadCategory{
|
||||
{Name: "Program", Path: programDir, Extensions: []string{"exe"}, IsDeleted: true},
|
||||
}
|
||||
},
|
||||
opts: &base.Options{},
|
||||
res: singleFileResource("setup.exe"),
|
||||
want: "./downloads",
|
||||
},
|
||||
{
|
||||
name: "a category without a path is skipped",
|
||||
cfg: func(cfg *base.DownloaderStoreConfig) {
|
||||
cfg.Categories = []*base.DownloadCategory{
|
||||
{Name: "Program", Path: " ", Extensions: []string{"exe"}},
|
||||
}
|
||||
},
|
||||
opts: &base.Options{},
|
||||
res: singleFileResource("setup.exe"),
|
||||
want: "./downloads",
|
||||
},
|
||||
{
|
||||
name: "a multi file resource keeps its layout",
|
||||
opts: &base.Options{},
|
||||
res: folderResource("bundle", "setup.exe", "readme.txt"),
|
||||
want: "./downloads",
|
||||
},
|
||||
{
|
||||
name: "a multi file resource with one match still keeps its layout",
|
||||
opts: &base.Options{SelectFiles: []int{0}},
|
||||
res: folderResource("bundle", "setup.exe", "readme.txt"),
|
||||
want: "./downloads",
|
||||
},
|
||||
{
|
||||
name: "a single file inside a folder resource is not routed",
|
||||
opts: &base.Options{},
|
||||
res: folderResource("bundle", "setup.exe"),
|
||||
want: "./downloads",
|
||||
},
|
||||
{
|
||||
name: "a missing resource keeps the base directory",
|
||||
opts: &base.Options{},
|
||||
res: nil,
|
||||
want: "./downloads",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
cfg := newTestCategoryConfig()
|
||||
if tt.cfg != nil {
|
||||
tt.cfg(cfg)
|
||||
}
|
||||
d := newUnitCategoryDownloader(cfg)
|
||||
got, err := d.decideDownloadDir(tt.opts, tt.res)
|
||||
if err != nil {
|
||||
t.Fatalf("decideDownloadDir() error = %v", err)
|
||||
}
|
||||
if got != tt.want {
|
||||
t.Fatalf("decideDownloadDir() = %q, want %q", got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestDecideDownloadDirWhiteList(t *testing.T) {
|
||||
defaultDir := filepath.ToSlash(t.TempDir())
|
||||
categoryDir := defaultDir + "/Program"
|
||||
newConfig := func() *base.DownloaderStoreConfig {
|
||||
cfg := newTestCategoryConfig()
|
||||
cfg.DownloadDir = defaultDir
|
||||
cfg.Categories = []*base.DownloadCategory{
|
||||
{Name: "Program", Path: categoryDir, Extensions: []string{"exe"}},
|
||||
}
|
||||
return cfg
|
||||
}
|
||||
|
||||
t.Run("a rejected category directory falls back to the base directory", func(t *testing.T) {
|
||||
d := newUnitCategoryDownloader(newConfig())
|
||||
d.cfg.WhiteDownloadDirs = []string{defaultDir}
|
||||
|
||||
got, err := d.decideDownloadDir(&base.Options{Path: defaultDir}, singleFileResource("setup.exe"))
|
||||
if err != nil {
|
||||
t.Fatalf("expected white list fallback, got %v", err)
|
||||
}
|
||||
if got != defaultDir {
|
||||
t.Fatalf("path = %q, want the base directory %q", got, defaultDir)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("a rejected base directory is an error", func(t *testing.T) {
|
||||
d := newUnitCategoryDownloader(newConfig())
|
||||
d.cfg.WhiteDownloadDirs = []string{defaultDir}
|
||||
|
||||
_, err := d.decideDownloadDir(&base.Options{Path: defaultDir + "/Other", SkipAutoCategorize: true}, singleFileResource("setup.exe"))
|
||||
if err == nil {
|
||||
t.Fatal("expected a white list error for an explicit directory")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("equivalent category paths still fall back to the allowed spelling", func(t *testing.T) {
|
||||
cfg := newConfig()
|
||||
cfg.Categories[0].Path = defaultDir + "/."
|
||||
d := newUnitCategoryDownloader(cfg)
|
||||
d.cfg.WhiteDownloadDirs = []string{defaultDir}
|
||||
got, err := d.decideDownloadDir(&base.Options{Path: defaultDir}, singleFileResource("setup.exe"))
|
||||
if err != nil || got != defaultDir {
|
||||
t.Fatalf("decideDownloadDir() = %q, %v; want %q, nil", got, err, defaultDir)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("an empty base directory is an error", func(t *testing.T) {
|
||||
cfg := newConfig()
|
||||
cfg.DownloadDir = ""
|
||||
d := newUnitCategoryDownloader(cfg)
|
||||
|
||||
_, err := d.decideDownloadDir(&base.Options{SkipAutoCategorize: true}, singleFileResource("setup.exe"))
|
||||
if err == nil {
|
||||
t.Fatal("expected an error for an empty download directory")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestSuggestedDownloadDir(t *testing.T) {
|
||||
programDir := filepath.Join("./downloads", "Program")
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
cfg func(cfg *base.DownloaderStoreConfig)
|
||||
res *base.Resource
|
||||
want string
|
||||
}{
|
||||
{name: "a matching single file is suggested", res: singleFileResource("setup.exe"), want: programDir},
|
||||
{
|
||||
name: "a padded category path matches the creation decision",
|
||||
cfg: func(cfg *base.DownloaderStoreConfig) { cfg.Categories[2].Path = " " + programDir + " " },
|
||||
res: singleFileResource("setup.exe"),
|
||||
want: programDir,
|
||||
},
|
||||
{
|
||||
name: "the switch off suggests nothing",
|
||||
cfg: func(cfg *base.DownloaderStoreConfig) { cfg.AutoCategorize = util.BoolPtr(false) },
|
||||
res: singleFileResource("setup.exe"),
|
||||
want: "",
|
||||
},
|
||||
{name: "a multi file resource suggests nothing", res: folderResource("bundle", "setup.exe"), want: ""},
|
||||
{name: "a folder resource suggests nothing", res: folderResource("bundle", "setup.exe", "a.txt"), want: ""},
|
||||
{name: "an unknown extension suggests nothing", res: singleFileResource("archive.zip"), want: ""},
|
||||
{name: "a missing resource suggests nothing", res: nil, want: ""},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
cfg := newTestCategoryConfig()
|
||||
if tt.cfg != nil {
|
||||
tt.cfg(cfg)
|
||||
}
|
||||
d := newUnitCategoryDownloader(cfg)
|
||||
if got := d.suggestedDownloadDir(tt.res); got != tt.want {
|
||||
t.Fatalf("suggestedDownloadDir() = %q, want %q", got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
t.Run("a rejected category directory suggests nothing", func(t *testing.T) {
|
||||
dir := filepath.ToSlash(t.TempDir())
|
||||
cfg := newTestCategoryConfig()
|
||||
cfg.DownloadDir = dir
|
||||
cfg.Categories = []*base.DownloadCategory{
|
||||
{Name: "Program", Path: dir + "/Program", Extensions: []string{"exe"}},
|
||||
}
|
||||
d := newUnitCategoryDownloader(cfg)
|
||||
d.cfg.WhiteDownloadDirs = []string{dir}
|
||||
|
||||
if got := d.suggestedDownloadDir(singleFileResource("setup.exe")); got != "" {
|
||||
t.Fatalf("suggestedDownloadDir() = %q, want an empty suggestion", got)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestNormalizeCategoryExtensions(t *testing.T) {
|
||||
tests := []struct {
|
||||
raw []string
|
||||
want []string
|
||||
}{
|
||||
{[]string{"exe, msi"}, []string{"exe", "msi"}},
|
||||
{[]string{" mp3 ", "..exe", "MUSIC"}, []string{"mp3", "exe", "music"}},
|
||||
{[]string{"zip", "ZIP", "zip"}, []string{"zip"}},
|
||||
{[]string{" ", "..", ""}, nil},
|
||||
{nil, nil},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
got := normalizeCategoryExtensions(tt.raw)
|
||||
if len(got) != len(tt.want) {
|
||||
t.Fatalf("normalizeCategoryExtensions(%q) = %q, want %q", tt.raw, got, tt.want)
|
||||
}
|
||||
for i := range got {
|
||||
if got[i] != tt.want[i] {
|
||||
t.Fatalf("normalizeCategoryExtensions(%q) = %q, want %q", tt.raw, got, tt.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// startCategoryFileServer serves a nameless url whose real file name only
|
||||
// shows up in the Content-Disposition header.
|
||||
func startCategoryFileServer(t *testing.T, fileName string, data []byte) string {
|
||||
t.Helper()
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc("/download", func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Disposition", `attachment; filename="`+fileName+`"`)
|
||||
w.Header().Set("Content-Type", "application/octet-stream")
|
||||
http.ServeContent(w, r, fileName, time.Time{}, bytes.NewReader(data))
|
||||
})
|
||||
server := httptest.NewServer(mux)
|
||||
t.Cleanup(server.Close)
|
||||
return server.URL + "/download"
|
||||
}
|
||||
|
||||
func newSetupCategoryDownloader(t *testing.T) *Downloader {
|
||||
t.Helper()
|
||||
downloader := NewDownloader(nil)
|
||||
if err := downloader.Setup(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
downloader.Delete(nil, true)
|
||||
downloader.Clear()
|
||||
})
|
||||
return downloader
|
||||
}
|
||||
|
||||
func putCategoryConfig(t *testing.T, d *Downloader, downloadDir string, autoCategorize bool, extensions []string) string {
|
||||
t.Helper()
|
||||
categoryDir := filepath.Join(downloadDir, "Program")
|
||||
cfg, err := d.GetConfig()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
cfg.DownloadDir = downloadDir
|
||||
cfg.AutoCategorize = util.BoolPtr(autoCategorize)
|
||||
cfg.Categories = []*base.DownloadCategory{
|
||||
{Name: "Program", Path: categoryDir, Extensions: extensions},
|
||||
}
|
||||
if err := d.PutConfig(cfg); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return categoryDir
|
||||
}
|
||||
|
||||
// watchTaskDone subscribes to the done event before the task is created, so a
|
||||
// fast task can not finish before the test starts listening.
|
||||
func watchTaskDone(t *testing.T, d *Downloader) <-chan struct{} {
|
||||
t.Helper()
|
||||
done := make(chan struct{}, 1)
|
||||
d.Listener(func(event *Event) {
|
||||
if event.Key == EventKeyDone {
|
||||
select {
|
||||
case done <- struct{}{}:
|
||||
default:
|
||||
}
|
||||
}
|
||||
})
|
||||
return done
|
||||
}
|
||||
|
||||
func waitTaskDone(t *testing.T, done <-chan struct{}) {
|
||||
t.Helper()
|
||||
select {
|
||||
case <-done:
|
||||
case <-time.After(30 * time.Second):
|
||||
t.Fatal("task did not finish")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDownloader_CreateDirectAutoCategorize(t *testing.T) {
|
||||
// The url carries no extension, the category is matched on the file name
|
||||
// of the resolved resource.
|
||||
url := startCategoryFileServer(t, "setup.data", []byte("0123456789"))
|
||||
downloadDir := t.TempDir()
|
||||
downloader := newSetupCategoryDownloader(t)
|
||||
categoryDir := putCategoryConfig(t, downloader, downloadDir, true, []string{"data"})
|
||||
done := watchTaskDone(t, downloader)
|
||||
|
||||
taskID, err := downloader.CreateDirect(&base.Request{URL: url}, &base.Options{Path: downloadDir})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
task := downloader.GetTask(taskID)
|
||||
if task == nil {
|
||||
t.Fatal("task not found")
|
||||
}
|
||||
waitTaskDone(t, done)
|
||||
if task.Meta.Opts.Path != categoryDir {
|
||||
t.Fatalf("task path = %q, want the category dir %q", task.Meta.Opts.Path, categoryDir)
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(categoryDir, "setup.data")); err != nil {
|
||||
t.Fatalf("categorized file missing: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDownloader_CreateDirectDoesNotMatchTheRename(t *testing.T) {
|
||||
url := startCategoryFileServer(t, "setup.data", []byte("0123456789"))
|
||||
downloadDir := t.TempDir()
|
||||
downloader := newSetupCategoryDownloader(t)
|
||||
putCategoryConfig(t, downloader, downloadDir, true, []string{"exe"})
|
||||
done := watchTaskDone(t, downloader)
|
||||
|
||||
// opts.Name only renames the saved file, it never decides the category.
|
||||
taskID, err := downloader.CreateDirect(&base.Request{URL: url}, &base.Options{
|
||||
Path: downloadDir,
|
||||
Name: "renamed.exe",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
task := downloader.GetTask(taskID)
|
||||
if task == nil {
|
||||
t.Fatal("task not found")
|
||||
}
|
||||
waitTaskDone(t, done)
|
||||
if task.Meta.Opts.Path != downloadDir {
|
||||
t.Fatalf("task path = %q, want the base dir %q", task.Meta.Opts.Path, downloadDir)
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(downloadDir, "renamed.exe")); err != nil {
|
||||
t.Fatalf("renamed file missing: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDownloader_CreateDirectSkipAutoCategorize(t *testing.T) {
|
||||
url := startCategoryFileServer(t, "setup.data", []byte("0123456789"))
|
||||
downloadDir := t.TempDir()
|
||||
chosenDir := t.TempDir()
|
||||
downloader := newSetupCategoryDownloader(t)
|
||||
putCategoryConfig(t, downloader, downloadDir, true, []string{"data"})
|
||||
done := watchTaskDone(t, downloader)
|
||||
|
||||
taskID, err := downloader.CreateDirect(&base.Request{URL: url}, &base.Options{
|
||||
Path: chosenDir,
|
||||
SkipAutoCategorize: true,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
task := downloader.GetTask(taskID)
|
||||
if task == nil {
|
||||
t.Fatal("task not found")
|
||||
}
|
||||
waitTaskDone(t, done)
|
||||
if task.Meta.Opts.Path != chosenDir {
|
||||
t.Fatalf("task path = %q, want the chosen dir %q", task.Meta.Opts.Path, chosenDir)
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(chosenDir, "setup.data")); err != nil {
|
||||
t.Fatalf("file missing in the chosen dir: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDownloader_CreateWithOptionsAutoCategorize(t *testing.T) {
|
||||
url := startCategoryFileServer(t, "setup.data", []byte("0123456789"))
|
||||
downloadDir := t.TempDir()
|
||||
downloader := newSetupCategoryDownloader(t)
|
||||
categoryDir := putCategoryConfig(t, downloader, downloadDir, true, []string{"data"})
|
||||
done := watchTaskDone(t, downloader)
|
||||
|
||||
rr, err := downloader.Resolve(&base.Request{URL: url})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if rr.SuggestedDownloadDir != categoryDir {
|
||||
t.Fatalf("suggested dir = %q, want %q", rr.SuggestedDownloadDir, categoryDir)
|
||||
}
|
||||
taskID, err := downloader.CreateWithOptions(rr.ID, &base.Options{Path: downloadDir})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
task := downloader.GetTask(taskID)
|
||||
if task == nil {
|
||||
t.Fatal("task not found")
|
||||
}
|
||||
waitTaskDone(t, done)
|
||||
if task.Meta.Opts.Path != categoryDir {
|
||||
t.Fatalf("task path = %q, want the category dir %q", task.Meta.Opts.Path, categoryDir)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDownloader_CreateWithOptionsKeepsTheDefaultDirectory(t *testing.T) {
|
||||
url := startCategoryFileServer(t, "setup.data", []byte("0123456789"))
|
||||
downloadDir := t.TempDir()
|
||||
downloader := newSetupCategoryDownloader(t)
|
||||
putCategoryConfig(t, downloader, downloadDir, true, []string{"data"})
|
||||
done := watchTaskDone(t, downloader)
|
||||
|
||||
rr, err := downloader.Resolve(&base.Request{URL: url})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
taskID, err := downloader.CreateWithOptions(rr.ID, &base.Options{
|
||||
Path: downloadDir,
|
||||
AsDefaultPath: true,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
waitTaskDone(t, done)
|
||||
|
||||
// The task lands in the category directory, but that must not become the
|
||||
// global default download directory.
|
||||
task := downloader.GetTask(taskID)
|
||||
if task == nil {
|
||||
t.Fatal("task not found")
|
||||
}
|
||||
if task.Meta.Opts.Path == downloadDir {
|
||||
t.Fatalf("task path = %q, want the category dir", task.Meta.Opts.Path)
|
||||
}
|
||||
cfg, err := downloader.GetConfig()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if cfg.DownloadDir != downloadDir {
|
||||
t.Fatalf("default download dir = %q, want the base dir %q", cfg.DownloadDir, downloadDir)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDownloader_ResolveSuggestsWithoutSideEffects(t *testing.T) {
|
||||
url := startCategoryFileServer(t, "setup.data", []byte("0123456789"))
|
||||
downloadDir := t.TempDir()
|
||||
downloader := newSetupCategoryDownloader(t)
|
||||
categoryDir := putCategoryConfig(t, downloader, downloadDir, true, []string{"data"})
|
||||
|
||||
rr, err := downloader.Resolve(&base.Request{URL: url})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if rr.SuggestedDownloadDir != categoryDir {
|
||||
t.Fatalf("suggested dir = %q, want %q", rr.SuggestedDownloadDir, categoryDir)
|
||||
}
|
||||
if _, err := os.Stat(categoryDir); !os.IsNotExist(err) {
|
||||
t.Fatalf("resolve created the suggested directory: %v", err)
|
||||
}
|
||||
cfg, err := downloader.GetConfig()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if cfg.DownloadDir != downloadDir {
|
||||
t.Fatalf("resolve changed the download dir to %q", cfg.DownloadDir)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDownloader_ResolveSuggestsNothingWhenTheSwitchIsOff(t *testing.T) {
|
||||
url := startCategoryFileServer(t, "setup.data", []byte("0123456789"))
|
||||
downloadDir := t.TempDir()
|
||||
downloader := newSetupCategoryDownloader(t)
|
||||
putCategoryConfig(t, downloader, downloadDir, false, []string{"data"})
|
||||
|
||||
rr, err := downloader.Resolve(&base.Request{URL: url})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if rr.SuggestedDownloadDir != "" {
|
||||
t.Fatalf("suggested dir = %q, want an empty suggestion", rr.SuggestedDownloadDir)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDownloader_SetupMigratesLegacyExtraCategories(t *testing.T) {
|
||||
storage := NewMemStorage()
|
||||
if err := storage.Setup([]string{bucketConfig}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
legacy := &base.DownloaderStoreConfig{
|
||||
DownloadDir: t.TempDir(),
|
||||
Extra: map[string]any{
|
||||
"downloadCategories": []any{
|
||||
map[string]any{"name": "", "path": filepath.Join(t.TempDir(), "Music"), "isBuiltIn": true, "nameKey": "categoryMusic"},
|
||||
},
|
||||
},
|
||||
}
|
||||
if err := storage.Put(bucketConfig, "config", legacy); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
downloader := NewDownloader(&DownloaderConfig{Storage: storage, StorageDir: t.TempDir()})
|
||||
if err := downloader.Setup(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer func() {
|
||||
downloader.Delete(nil, true)
|
||||
downloader.Clear()
|
||||
}()
|
||||
|
||||
cfg, err := downloader.GetConfig()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(cfg.Categories) != 1 || cfg.Categories[0].NameKey != "categoryMusic" || len(cfg.Categories[0].Extensions) == 0 {
|
||||
t.Fatalf("categories = %+v, want the migrated built-in with its default extensions", cfg.Categories)
|
||||
}
|
||||
if _, ok := cfg.Extra["downloadCategories"]; ok {
|
||||
t.Fatal("in memory config still carries the legacy key")
|
||||
}
|
||||
|
||||
// The cleared key has to be persisted, not only held in memory.
|
||||
var stored base.DownloaderStoreConfig
|
||||
exist, err := storage.Get(bucketConfig, "config", &stored)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !exist {
|
||||
t.Fatal("stored config missing")
|
||||
}
|
||||
if _, ok := stored.Extra["downloadCategories"]; ok {
|
||||
t.Fatal("stored config still carries the legacy key")
|
||||
}
|
||||
if len(stored.Categories) != 1 || stored.Categories[0].NameKey != "categoryMusic" {
|
||||
t.Fatalf("stored categories = %+v, want the migrated list", stored.Categories)
|
||||
}
|
||||
}
|
||||
|
||||
// readOnlyConfigStorage refuses every config write, so the setup can not
|
||||
// persist a migrated category list.
|
||||
type readOnlyConfigStorage struct {
|
||||
Storage
|
||||
}
|
||||
|
||||
func (s *readOnlyConfigStorage) Put(bucket string, key string, v any) error {
|
||||
if bucket == bucketConfig {
|
||||
return errors.New("config storage is read only")
|
||||
}
|
||||
return s.Storage.Put(bucket, key, v)
|
||||
}
|
||||
|
||||
func TestDownloader_SetupFailsWhenTheMigrationCanNotBePersisted(t *testing.T) {
|
||||
storage := NewMemStorage()
|
||||
if err := storage.Setup([]string{bucketConfig}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
legacy := &base.DownloaderStoreConfig{
|
||||
DownloadDir: t.TempDir(),
|
||||
Extra: map[string]any{
|
||||
"downloadCategories": []any{
|
||||
map[string]any{"name": "Music", "path": filepath.Join(t.TempDir(), "Music")},
|
||||
},
|
||||
},
|
||||
}
|
||||
if err := storage.Put(bucketConfig, "config", legacy); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// The migrated list can not be persisted, so the setup reports the write
|
||||
// error instead of continuing with a category list that is lost on restart.
|
||||
downloader := NewDownloader(&DownloaderConfig{Storage: &readOnlyConfigStorage{Storage: storage}, StorageDir: t.TempDir()})
|
||||
if err := downloader.Setup(); err == nil {
|
||||
t.Fatal("setup reported no error, want the failed config write")
|
||||
}
|
||||
}
|
||||
|
||||
// categoryMatchingCases is the shared extension rule table of the backend and
|
||||
// the ui. The ui runs the same file, see
|
||||
// ui/flutter/test/api/downloader_config_test.dart, so a rule that only changes
|
||||
// on one side breaks the other side's test.
|
||||
type categoryMatchingCases struct {
|
||||
NormalizeExtensions []struct {
|
||||
Input []string `json:"input"`
|
||||
Expected []string `json:"expected"`
|
||||
} `json:"normalizeExtensions"`
|
||||
}
|
||||
|
||||
func TestSharedCategoryMatchingRules(t *testing.T) {
|
||||
data, err := os.ReadFile(filepath.Join("testdata", "category_matching_cases.json"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var cases categoryMatchingCases
|
||||
if err := json.Unmarshal(data, &cases); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, c := range cases.NormalizeExtensions {
|
||||
got := normalizeCategoryExtensions(c.Input)
|
||||
if !slices.Equal(got, c.Expected) {
|
||||
t.Errorf("normalizeCategoryExtensions(%q) = %q, want %q", c.Input, got, c.Expected)
|
||||
}
|
||||
}
|
||||
}
|
||||
+276
-88
@@ -2,6 +2,7 @@ package download
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
@@ -42,6 +43,7 @@ const (
|
||||
bucketTaskStats = "task_stats"
|
||||
// protocol-level shared client state bucket
|
||||
bucketProtocolState = "protocol_state"
|
||||
bucketProtocolJobs = "protocol_jobs"
|
||||
// downloader config bucket
|
||||
bucketConfig = "config"
|
||||
// downloader extension bucket
|
||||
@@ -99,6 +101,9 @@ type Progress struct {
|
||||
}
|
||||
|
||||
type Downloader struct {
|
||||
backgroundCtx context.Context
|
||||
cancelBackground context.CancelFunc
|
||||
protocolJobs *protocolScheduler
|
||||
tempFiles *tempfiles.Scope
|
||||
webviewProfilesLock sync.Mutex
|
||||
webviewProfiles map[string]*extensionWebViewProfile
|
||||
@@ -125,8 +130,10 @@ type Downloader struct {
|
||||
// Key: fullBaseName (e.g., "/path/archive.7z"), Value: taskID that claimed it
|
||||
claimedExtractions sync.Map
|
||||
|
||||
extensions []*Extension
|
||||
blob *internalblob.Registry
|
||||
extensions []*Extension
|
||||
extensionGitLock sync.Mutex
|
||||
extensionGitWG sync.WaitGroup
|
||||
blob *internalblob.Registry
|
||||
}
|
||||
|
||||
func NewDownloader(cfg *DownloaderConfig) *Downloader {
|
||||
@@ -135,11 +142,14 @@ func NewDownloader(cfg *DownloaderConfig) *Downloader {
|
||||
}
|
||||
cfg.Init()
|
||||
|
||||
backgroundCtx, cancelBackground := context.WithCancel(context.Background())
|
||||
d := &Downloader{
|
||||
cfg: cfg,
|
||||
fetcherCache: make(map[string]fetcher.Fetcher),
|
||||
waitTasks: make([]*Task, 0),
|
||||
storage: cfg.Storage,
|
||||
backgroundCtx: backgroundCtx,
|
||||
cancelBackground: cancelBackground,
|
||||
cfg: cfg,
|
||||
fetcherCache: make(map[string]fetcher.Fetcher),
|
||||
waitTasks: make([]*Task, 0),
|
||||
storage: cfg.Storage,
|
||||
|
||||
lock: &sync.Mutex{},
|
||||
configLock: &sync.RWMutex{},
|
||||
@@ -165,7 +175,7 @@ func (d *Downloader) Setup() error {
|
||||
d.blob = internalblob.NewRegistry("")
|
||||
|
||||
// setup storage
|
||||
if err := d.storage.Setup([]string{bucketTask, bucketSave, bucketTaskStats, bucketProtocolState, bucketConfig, bucketExtension, bucketExtensionStorage}); err != nil {
|
||||
if err := d.storage.Setup([]string{bucketTask, bucketSave, bucketTaskStats, bucketProtocolState, bucketProtocolJobs, bucketConfig, bucketExtension, bucketExtensionStorage}); err != nil {
|
||||
return err
|
||||
}
|
||||
// load config from storage
|
||||
@@ -181,14 +191,22 @@ func (d *Downloader) Setup() error {
|
||||
FirstLoad: true,
|
||||
}
|
||||
}
|
||||
// init default config
|
||||
d.cfg.DownloaderStoreConfig.Init()
|
||||
// init protocol config, if not exist, use default config
|
||||
// Move the legacy extra.downloadCategories list written by v2.0.0-beta
|
||||
// builds into the top level categories field and persist the cleared key,
|
||||
// before Init seeds the built-in categories. The config is written
|
||||
// directly, PutConfig would validate a config that is not initialized yet.
|
||||
if d.cfg.DownloaderStoreConfig.MigrateLegacyExtraCategories() {
|
||||
if err := d.storage.Put(bucketConfig, "config", d.cfg.DownloaderStoreConfig); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
// Initialize the current backend configuration schema and defaults.
|
||||
if err := d.initProtocolConfigs(); err != nil {
|
||||
return err
|
||||
}
|
||||
// Attach optional protocol state stores after configuration initialization.
|
||||
for _, fm := range d.cfg.FetchManagers {
|
||||
protocol := fm.Name()
|
||||
if _, ok := d.cfg.DownloaderStoreConfig.ProtocolConfig[protocol]; !ok {
|
||||
d.cfg.DownloaderStoreConfig.ProtocolConfig[protocol] = fm.DefaultConfig()
|
||||
}
|
||||
if sfm, ok := fm.(fetcher.StatefulFetcherManager); ok {
|
||||
sfm.SetStateStore(&protocolStateStore{
|
||||
storage: d.storage,
|
||||
@@ -197,6 +215,10 @@ func (d *Downloader) Setup() error {
|
||||
}
|
||||
}
|
||||
|
||||
if err := d.setupProtocolJobs(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// load tasks from storage
|
||||
var tasks []*Task
|
||||
if err = d.storage.List(bucketTask, &tasks); err != nil {
|
||||
@@ -244,16 +266,25 @@ func (d *Downloader) Setup() error {
|
||||
tasks := append([]*Task(nil), d.tasks...)
|
||||
d.lock.Unlock()
|
||||
for _, task := range tasks {
|
||||
if task.Status == base.DownloadStatusDone && task.Uploading {
|
||||
func() {
|
||||
task.lock.Lock()
|
||||
defer task.lock.Unlock()
|
||||
if d.closed.Load() {
|
||||
return
|
||||
}
|
||||
if d.taskStatus(task) != base.DownloadStatusDone || !task.Uploading {
|
||||
return
|
||||
}
|
||||
if err := d.restoreTask(task); err != nil {
|
||||
d.Logger.Error().Stack().Err(err).Msgf("task upload restore fetcher failed, task id: %s", task.ID)
|
||||
d.Logger.Error().Err(err).Msgf("task upload restore fetcher failed, task id: %s", task.ID)
|
||||
return
|
||||
}
|
||||
if uploader, ok := task.fetcher.(fetcher.Uploader); ok {
|
||||
if err := uploader.Upload(); err != nil {
|
||||
d.Logger.Error().Stack().Err(err).Msgf("task upload failed, task id: %s", task.ID)
|
||||
d.Logger.Error().Err(err).Msgf("task upload failed, task id: %s", task.ID)
|
||||
}
|
||||
}
|
||||
}
|
||||
}()
|
||||
}
|
||||
}()
|
||||
|
||||
@@ -356,6 +387,7 @@ func (d *Downloader) Setup() error {
|
||||
time.Sleep(time.Millisecond * time.Duration(d.cfg.RefreshInterval))
|
||||
}
|
||||
}()
|
||||
d.protocolJobs.start()
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -467,7 +499,10 @@ func (d *Downloader) saveTask(task *Task) error {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if err := d.storage.Put(bucketTask, task.ID, task); err != nil {
|
||||
task.statusLock.Lock()
|
||||
snapshot := task.clone()
|
||||
task.statusLock.Unlock()
|
||||
if err := d.storage.Put(bucketTask, task.ID, snapshot); err != nil {
|
||||
d.Logger.Error().Stack().Err(err).Msgf("persist task failed: %s", task.ID)
|
||||
return err
|
||||
}
|
||||
@@ -498,7 +533,7 @@ func ensureResourceRequestRawURLs(parentReq *base.Request, res *base.Resource) {
|
||||
}
|
||||
}
|
||||
|
||||
func (d *Downloader) Resolve(req *base.Request, opts *base.Options) (rr *ResolveResult, err error) {
|
||||
func (d *Downloader) Resolve(req *base.Request) (rr *ResolveResult, err error) {
|
||||
rrId, err := gonanoid.New()
|
||||
if err != nil {
|
||||
return
|
||||
@@ -511,7 +546,8 @@ func (d *Downloader) Resolve(req *base.Request, opts *base.Options) (rr *Resolve
|
||||
}
|
||||
if res != nil && len(res.Files) > 0 {
|
||||
rr = &ResolveResult{
|
||||
Res: res,
|
||||
Res: res,
|
||||
SuggestedDownloadDir: d.suggestedDownloadDir(res),
|
||||
}
|
||||
return
|
||||
}
|
||||
@@ -520,11 +556,7 @@ func (d *Downloader) Resolve(req *base.Request, opts *base.Options) (rr *Resolve
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
initOpt, err := d.initOptions(opts)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
err = fetcher.Resolve(req, initOpt)
|
||||
err = fetcher.Resolve(req)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
@@ -532,8 +564,9 @@ func (d *Downloader) Resolve(req *base.Request, opts *base.Options) (rr *Resolve
|
||||
d.fetcherCache[rrId] = fetcher
|
||||
d.fetcherMapLock.Unlock()
|
||||
rr = &ResolveResult{
|
||||
ID: rrId,
|
||||
Res: fetcher.Meta().Res,
|
||||
ID: rrId,
|
||||
Res: fetcher.Meta().Res,
|
||||
SuggestedDownloadDir: d.suggestedDownloadDir(fetcher.Meta().Res),
|
||||
}
|
||||
return
|
||||
}
|
||||
@@ -558,7 +591,7 @@ func (d *Downloader) notifyRunning() {
|
||||
func (d *Downloader) remainRunningCount() int {
|
||||
runningCount := 0
|
||||
for _, t := range d.tasks {
|
||||
if t.Status == base.DownloadStatusRunning {
|
||||
if d.taskStatus(t) == base.DownloadStatusRunning {
|
||||
runningCount++
|
||||
}
|
||||
}
|
||||
@@ -585,7 +618,7 @@ func (d *Downloader) createDirect(req *base.Request, opts *base.Options) (taskId
|
||||
return
|
||||
}
|
||||
fetcher.Meta().Req = req
|
||||
initOpt, err := d.initOptions(opts)
|
||||
initOpt, err := d.initOptions(opts, req.URL)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
@@ -621,7 +654,7 @@ func (d *Downloader) Create(rrId string) (taskId string, err error) {
|
||||
}
|
||||
|
||||
// CreateWithOptions creates a resolved task using the submitted options, or the
|
||||
// resolve-time options when opts is nil.
|
||||
// default download options when opts is nil.
|
||||
func (d *Downloader) CreateWithOptions(rrId string, opts *base.Options) (taskId string, err error) {
|
||||
d.fetcherMapLock.RLock()
|
||||
fetcher, ok := d.fetcherCache[rrId]
|
||||
@@ -629,33 +662,43 @@ func (d *Downloader) CreateWithOptions(rrId string, opts *base.Options) (taskId
|
||||
if !ok {
|
||||
return "", errors.New("invalid resource id")
|
||||
}
|
||||
if opts != nil {
|
||||
opts, err = d.initOptions(opts.Clone())
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if res := fetcher.Meta().Res; res != nil {
|
||||
for _, index := range opts.SelectFiles {
|
||||
if index < 0 || index >= len(res.Files) {
|
||||
return "", fmt.Errorf("invalid selected file index: %d", index)
|
||||
}
|
||||
}
|
||||
opts.InitSelectFiles(len(res.Files))
|
||||
}
|
||||
fetcher.Meta().Opts = opts
|
||||
if opts == nil {
|
||||
opts = &base.Options{}
|
||||
}
|
||||
// The options of the caller decide the global default download directory,
|
||||
// the task options are prepared later, when the task starts.
|
||||
defaultPath, err := d.prepareDefaultPath(opts)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
taskOpts, err := d.initOptions(opts.Clone(), fetcher.Meta().Req.URL)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if res := fetcher.Meta().Res; res != nil {
|
||||
for _, index := range taskOpts.SelectFiles {
|
||||
if index < 0 || index >= len(res.Files) {
|
||||
return "", fmt.Errorf("invalid selected file index: %d", index)
|
||||
}
|
||||
}
|
||||
taskOpts.InitSelectFiles(len(res.Files))
|
||||
}
|
||||
fetcher.Meta().Opts = taskOpts
|
||||
if res := fetcher.Meta().Res; res != nil {
|
||||
res.CalcSize(fetcher.Meta().Opts.SelectFiles)
|
||||
}
|
||||
// Resolve has no creation options. Publish a submitted HTTP estimate now
|
||||
// while preserving any size already confirmed by the response.
|
||||
if taskOpts.Size > 0 {
|
||||
if err := fetcher.Patch(nil, &base.Options{Size: taskOpts.Size}); err != nil {
|
||||
return "", err
|
||||
}
|
||||
}
|
||||
defer func() {
|
||||
d.fetcherMapLock.Lock()
|
||||
delete(d.fetcherCache, rrId)
|
||||
d.fetcherMapLock.Unlock()
|
||||
}()
|
||||
defaultPath, err := d.prepareDefaultPath(fetcher.Meta().Opts)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
taskId, err = d.doCreate(fetcher, nil)
|
||||
if err == nil {
|
||||
d.persistDefaultPathBestEffort(defaultPath)
|
||||
@@ -689,7 +732,7 @@ func (d *Downloader) Patch(id string, req *base.Request, opts *base.Options) err
|
||||
}
|
||||
|
||||
// Call the fetcher's Patch method
|
||||
if err := task.fetcher.Patch(req, opts); err != nil {
|
||||
if err := task.fetcher.Patch(req, httpSizeOptions(opts, task.Protocol)); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -1100,6 +1143,9 @@ func (d *Downloader) RuntimeStatus(id string) (*TaskRuntimeStatus, error) {
|
||||
result.ExtractProgress = task.Progress.ExtractProgress
|
||||
}
|
||||
if task.Meta == nil || task.Meta.Res == nil {
|
||||
if task.Meta != nil && task.Meta.Opts != nil && task.Protocol == "http" {
|
||||
result.Total = max(0, task.Meta.Opts.Size)
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
@@ -1172,7 +1218,14 @@ func (d *Downloader) doDelete(task *Task, force bool) (err error) {
|
||||
}
|
||||
|
||||
func (d *Downloader) Close() error {
|
||||
d.closed.Store(true)
|
||||
if d.closed.Swap(true) {
|
||||
return nil
|
||||
}
|
||||
d.cancelBackground()
|
||||
d.waitForExtensionGit()
|
||||
if d.protocolJobs != nil {
|
||||
d.protocolJobs.close()
|
||||
}
|
||||
d.webviewProxyLock.Lock()
|
||||
if d.webviewProxy != nil {
|
||||
_ = d.webviewProxy.Close()
|
||||
@@ -1327,7 +1380,16 @@ func (d *Downloader) GetTasksByFilter(filter *TaskFilter) []*Task {
|
||||
func (d *Downloader) GetConfig() (*base.DownloaderStoreConfig, error) {
|
||||
d.configLock.RLock()
|
||||
defer d.configLock.RUnlock()
|
||||
return d.cfg.DownloaderStoreConfig, nil
|
||||
return util.DeepClone(d.cfg.DownloaderStoreConfig), nil
|
||||
}
|
||||
|
||||
// IsFirstLoad reports whether Setup created the config instead of loading it
|
||||
// from storage. FirstLoad is not serialized, so the clone GetConfig returns
|
||||
// always reports false.
|
||||
func (d *Downloader) IsFirstLoad() bool {
|
||||
d.configLock.RLock()
|
||||
defer d.configLock.RUnlock()
|
||||
return d.cfg.FirstLoad
|
||||
}
|
||||
|
||||
func (d *Downloader) PutConfig(v *base.DownloaderStoreConfig) error {
|
||||
@@ -1335,17 +1397,68 @@ func (d *Downloader) PutConfig(v *base.DownloaderStoreConfig) error {
|
||||
return errors.New("config is nil")
|
||||
}
|
||||
next := util.DeepClone(v)
|
||||
changedProtocols := []string{}
|
||||
d.configLock.Lock()
|
||||
defer d.configLock.Unlock()
|
||||
if d.closed.Load() {
|
||||
d.configLock.Unlock()
|
||||
return errors.New("downloader is closed")
|
||||
}
|
||||
if next.GithubMirror == nil {
|
||||
next.GithubMirror = util.DeepClone(d.cfg.GithubMirror)
|
||||
}
|
||||
if err := next.GithubMirror.Validate(); err != nil {
|
||||
d.configLock.Unlock()
|
||||
return err
|
||||
}
|
||||
if next.ProtocolConfig == nil {
|
||||
next.ProtocolConfig = map[string]any{}
|
||||
}
|
||||
for name := range next.ProtocolConfig {
|
||||
if _, err := d.protocolManager(name); err != nil {
|
||||
d.configLock.Unlock()
|
||||
return err
|
||||
}
|
||||
}
|
||||
for _, fm := range d.cfg.FetchManagers {
|
||||
name := fm.Name()
|
||||
value, exists := next.ProtocolConfig[name]
|
||||
if !exists {
|
||||
value = d.cfg.ProtocolConfig[name]
|
||||
}
|
||||
value, err := preserveProtocolState(value, d.cfg.ProtocolConfig[name])
|
||||
if err == nil {
|
||||
value, err = normalizeProtocol(fm, value)
|
||||
}
|
||||
if err != nil {
|
||||
d.configLock.Unlock()
|
||||
return err
|
||||
}
|
||||
next.ProtocolConfig[name] = value
|
||||
oldJSON, _ := json.Marshal(d.cfg.ProtocolConfig[name])
|
||||
newJSON, _ := json.Marshal(value)
|
||||
if !bytes.Equal(oldJSON, newJSON) {
|
||||
changedProtocols = append(changedProtocols, name)
|
||||
}
|
||||
}
|
||||
if err := d.storage.Put(bucketConfig, "config", next); err != nil {
|
||||
d.configLock.Unlock()
|
||||
return err
|
||||
}
|
||||
d.cfg.DownloaderStoreConfig = next
|
||||
d.configLock.Unlock()
|
||||
if d.protocolJobs != nil {
|
||||
for _, name := range changedProtocols {
|
||||
d.protocolJobs.changed(name)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (d *Downloader) prepareDefaultPath(opts *base.Options) (string, error) {
|
||||
if opts == nil || !opts.AsDefaultPath || strings.TrimSpace(opts.Path) == "" {
|
||||
// Only a directory the user named explicitly becomes the global default
|
||||
// download directory. A task that keeps the auto categorize decision owns
|
||||
// its directory, the backend must not turn it into the global default.
|
||||
if opts == nil || !opts.SkipAutoCategorize || !opts.AsDefaultPath || strings.TrimSpace(opts.Path) == "" {
|
||||
return "", nil
|
||||
}
|
||||
selectedPath := strings.TrimSpace(opts.Path)
|
||||
@@ -1469,7 +1582,10 @@ func (d *Downloader) watch(task *Task) {
|
||||
if err := d.captureTaskStats(task, false, true); err != nil {
|
||||
d.Logger.Error().Stack().Err(err).Msgf("persist completed task stats failed, task id: %s", task.ID)
|
||||
}
|
||||
d.storage.Put(bucketTask, task.ID, task.clone())
|
||||
task.statusLock.Lock()
|
||||
snapshot := task.clone()
|
||||
task.statusLock.Unlock()
|
||||
d.storage.Put(bucketTask, task.ID, snapshot)
|
||||
d.emit(EventKeyDone, task)
|
||||
d.emit(EventKeyFinally, task, err)
|
||||
d.notifyRunning()
|
||||
@@ -1689,6 +1805,9 @@ func (d *Downloader) doCreate(f fetcher.Fetcher, opts *base.Options) (taskId str
|
||||
task.Meta = f.Meta()
|
||||
task.Progress = &Progress{}
|
||||
_, task.Uploading = f.(fetcher.Uploader)
|
||||
// A new task still needs its first preparation, it decides the download
|
||||
// directory and the final name of the task.
|
||||
task.NeedsPreparation = true
|
||||
initTask(task)
|
||||
if err = d.syncBlobTaskLease(task); err != nil {
|
||||
return "", err
|
||||
@@ -1722,25 +1841,41 @@ func (d *Downloader) doCreate(f fetcher.Fetcher, opts *base.Options) (taskId str
|
||||
return
|
||||
}
|
||||
|
||||
func (d *Downloader) initOptions(opts *base.Options) (*base.Options, error) {
|
||||
// httpSizeOptions keeps estimates out of other protocols, including HLS URLs
|
||||
// served over HTTP. Clone before filtering because batch tasks may share options.
|
||||
func httpSizeOptions(opts *base.Options, protocol string) *base.Options {
|
||||
if opts != nil && opts.Size != 0 && (protocol != "http" || opts.Size < 0) {
|
||||
opts = opts.Clone()
|
||||
opts.Size = 0
|
||||
}
|
||||
return opts
|
||||
}
|
||||
|
||||
// initOptions makes sure the option set is usable: a nil option set becomes
|
||||
// an empty one, the selection is never nil and the base download directory
|
||||
// falls back to the configured one. The directory is decided and checked
|
||||
// against the white list when the task starts.
|
||||
func (d *Downloader) initOptions(opts *base.Options, requestURL string) (*base.Options, error) {
|
||||
if opts == nil {
|
||||
opts = &base.Options{}
|
||||
}
|
||||
if opts.Size != 0 {
|
||||
fm, err := d.parseFm(requestURL)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
opts = httpSizeOptions(opts, fm.Name())
|
||||
}
|
||||
if opts.SelectFiles == nil {
|
||||
opts.SelectFiles = make([]int, 0)
|
||||
}
|
||||
if opts.Path == "" {
|
||||
storeConfig, err := d.GetConfig()
|
||||
cfg, err := d.GetConfig()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
opts.Path = storeConfig.DownloadDir
|
||||
opts.Path = cfg.DownloadDir
|
||||
}
|
||||
path, err := d.initDownloadPath(opts.Path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
opts.Path = path
|
||||
return opts, nil
|
||||
}
|
||||
|
||||
@@ -1771,8 +1906,68 @@ func (d *Downloader) statusMut(task *Task, fn func() (bool, error)) (bool, error
|
||||
return fn()
|
||||
}
|
||||
|
||||
// prepareTask runs the first preparation of a task: it decides the download
|
||||
// directory, resolves a duplicate name in it and persists both. The task keeps
|
||||
// its NeedsPreparation flag when the preparation fails, so a retry prepares it
|
||||
// again. The caller holds checkDuplicateLock when the fetcher manager renames
|
||||
// duplicates, the lock has to stay held until the fetcher created the file.
|
||||
func (d *Downloader) prepareTask(task *Task) error {
|
||||
if task.Meta.Res == nil {
|
||||
return errors.New("task resource is not resolved")
|
||||
}
|
||||
preparedMeta := *task.Meta
|
||||
preparedOpts := *task.Meta.Opts
|
||||
preparedMeta.Opts = &preparedOpts
|
||||
preparedMeta.Res = util.DeepClone(task.Meta.Res)
|
||||
dir, err := d.decideDownloadDir(preparedMeta.Opts, preparedMeta.Res)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
preparedMeta.Opts.Path = dir
|
||||
|
||||
if task.fetcherManager.AutoRename() {
|
||||
preparedMeta.Opts.Name = util.SafeFilename(preparedMeta.Opts.Name)
|
||||
// check if the download file is duplicated and rename it automatically.
|
||||
if preparedMeta.Res.Name != "" {
|
||||
preparedMeta.Res.Name = util.SafeFilename(preparedMeta.Res.Name)
|
||||
fullDirPath := preparedMeta.FolderPath()
|
||||
newName, err := util.CheckDuplicateAndRename(fullDirPath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
preparedMeta.Opts.Name = newName
|
||||
} else {
|
||||
preparedMeta.Res.Files[0].Name = util.SafeFilename(preparedMeta.Res.Files[0].Name)
|
||||
fullFilePath := preparedMeta.SingleFilepath()
|
||||
newName, err := util.CheckDuplicateAndRename(fullFilePath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
preparedMeta.Opts.Name = newName
|
||||
}
|
||||
}
|
||||
|
||||
preparedMeta.Res.CalcSize(preparedMeta.Opts.SelectFiles)
|
||||
|
||||
// Save a candidate task before publishing its metadata. The fetcher keeps
|
||||
// its resolved protocol state, so a failed save cannot change the live
|
||||
// directory, name or preparation flag.
|
||||
task.statusLock.Lock()
|
||||
preparedTask := *task
|
||||
task.statusLock.Unlock()
|
||||
preparedTask.Meta = &preparedMeta
|
||||
preparedTask.NeedsPreparation = false
|
||||
if err := d.saveTask(&preparedTask); err != nil {
|
||||
return err
|
||||
}
|
||||
task.statusLock.Lock()
|
||||
*task.Meta = preparedMeta
|
||||
task.NeedsPreparation = false
|
||||
task.statusLock.Unlock()
|
||||
return nil
|
||||
}
|
||||
|
||||
func (d *Downloader) doStart(task *Task) (err error) {
|
||||
var isCreate bool
|
||||
var generation uint64
|
||||
isReturn, err := d.statusMut(task, func() (isReturn bool, err error) {
|
||||
if task.Status == base.DownloadStatusRunning || task.Status == base.DownloadStatusDone {
|
||||
@@ -1785,7 +1980,6 @@ func (d *Downloader) doStart(task *Task) (err error) {
|
||||
d.Logger.Error().Stack().Err(err).Msgf("restore fetcher failed, task id: %s", task.ID)
|
||||
return
|
||||
}
|
||||
isCreate = task.Status == base.DownloadStatusReady
|
||||
task.updateStatus(base.DownloadStatusRunning)
|
||||
task.runGeneration++
|
||||
generation = task.runGeneration
|
||||
@@ -1825,40 +2019,34 @@ func (d *Downloader) doStart(task *Task) (err error) {
|
||||
if err := d.syncBlobTaskLease(task); err != nil {
|
||||
return err
|
||||
}
|
||||
if task.Meta.Res == nil {
|
||||
err := task.fetcher.Resolve(task.Meta.Req, task.Meta.Opts)
|
||||
resolvedHere := task.Meta.Res == nil
|
||||
if resolvedHere {
|
||||
err := task.fetcher.Resolve(task.Meta.Req)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
task.Meta.Res = task.fetcher.Meta().Res
|
||||
}
|
||||
|
||||
if isCreate {
|
||||
if task.NeedsPreparation {
|
||||
if task.fetcherManager.AutoRename() {
|
||||
// The duplicate name is resolved from the files on disk, but
|
||||
// the file only appears when the fetcher starts, so the lock
|
||||
// has to cover the start as well.
|
||||
d.checkDuplicateLock.Lock()
|
||||
defer d.checkDuplicateLock.Unlock()
|
||||
task.Meta.Opts.Name = util.SafeFilename(task.Meta.Opts.Name)
|
||||
// check if the download file is duplicated and rename it automatically.
|
||||
if task.Meta.Res.Name != "" {
|
||||
task.Meta.Res.Name = util.SafeFilename(task.Meta.Res.Name)
|
||||
fullDirPath := task.Meta.FolderPath()
|
||||
newName, err := util.CheckDuplicateAndRename(fullDirPath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
task.Meta.Opts.Name = newName
|
||||
} else {
|
||||
task.Meta.Res.Files[0].Name = util.SafeFilename(task.Meta.Res.Files[0].Name)
|
||||
fullFilePath := task.Meta.SingleFilepath()
|
||||
newName, err := util.CheckDuplicateAndRename(fullFilePath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
task.Meta.Opts.Name = newName
|
||||
}
|
||||
}
|
||||
|
||||
task.Meta.Res.CalcSize(task.Meta.Opts.SelectFiles)
|
||||
// The first preparation decides the download directory and the
|
||||
// final name of the task. It runs for every task that has not
|
||||
// been prepared yet, no matter whether the task waited in the
|
||||
// queue, was paused or failed before.
|
||||
if err := d.prepareTask(task); err != nil {
|
||||
return err
|
||||
}
|
||||
} else if _, err := d.initDownloadPath(task.Meta.Opts.Path); err != nil {
|
||||
// A prepared task keeps its directory, but the directory still has
|
||||
// to pass the white list on every start.
|
||||
return err
|
||||
}
|
||||
|
||||
task.statusLock.Lock()
|
||||
|
||||
@@ -132,7 +132,7 @@ func (f *generationTestFetcher) Setup(*controller.Controller) {
|
||||
f.done = make(chan error, 2)
|
||||
}
|
||||
}
|
||||
func (f *generationTestFetcher) Resolve(req *base.Request, opts *base.Options) error {
|
||||
func (f *generationTestFetcher) Resolve(req *base.Request) error {
|
||||
if f.manager.resolveStarted != nil {
|
||||
f.manager.resolveOnce.Do(func() { close(f.manager.resolveStarted) })
|
||||
}
|
||||
@@ -143,7 +143,7 @@ func (f *generationTestFetcher) Resolve(req *base.Request, opts *base.Options) e
|
||||
return f.manager.resolveErr
|
||||
}
|
||||
f.meta.Req = req
|
||||
f.meta.Opts = opts
|
||||
|
||||
f.meta.Res = &base.Resource{Files: []*base.FileInfo{{Name: "generation.bin", Size: 1}}}
|
||||
return nil
|
||||
}
|
||||
@@ -245,7 +245,7 @@ func TestDownloader_Resolve(t *testing.T) {
|
||||
req := &base.Request{
|
||||
URL: "http://" + listener.Addr().String() + "/" + test.BuildName,
|
||||
}
|
||||
rr, err := downloader.Resolve(req, nil)
|
||||
rr, err := downloader.Resolve(req)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -772,7 +772,7 @@ func TestDownloader_Create(t *testing.T) {
|
||||
URL: "http://" + listener.Addr().String() + "/" + test.BuildName,
|
||||
}
|
||||
opts := newTestDownloadOpt(t)
|
||||
rr, err := downloader.Resolve(req, opts)
|
||||
rr, err := downloader.Resolve(req)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -784,7 +784,7 @@ func TestDownloader_Create(t *testing.T) {
|
||||
wg.Done()
|
||||
}
|
||||
})
|
||||
_, err = downloader.Create(rr.ID)
|
||||
_, err = downloader.CreateWithOptions(rr.ID, opts)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -841,6 +841,9 @@ func TestDownloader_CreateDirectUsesPathAsDefault(t *testing.T) {
|
||||
selectedRoot := t.TempDir()
|
||||
defaultPathOpts := newTestDownloadOptAt(selectedRoot)
|
||||
defaultPathOpts.AsDefaultPath = true
|
||||
// The global default download directory is only saved for a directory the
|
||||
// user named explicitly, so the task skips the auto categorize decision.
|
||||
defaultPathOpts.SkipAutoCategorize = true
|
||||
createAndWait(defaultPathOpts)
|
||||
cfg, err = downloader.GetConfig()
|
||||
if err != nil {
|
||||
@@ -878,14 +881,29 @@ func TestDownloader_CreateNotInWhite(t *testing.T) {
|
||||
req := &base.Request{
|
||||
URL: "http://" + listener.Addr().String() + "/" + test.BuildName,
|
||||
}
|
||||
// With new fetcher design, white list check happens during Resolve (not Create)
|
||||
// because Resolve now requires Options which includes the download path
|
||||
_, err := downloader.Resolve(req, newTestDownloadOpt(t))
|
||||
if err == nil {
|
||||
t.Error("TestDownloader_CreateNotInWhite() expected error but got nil")
|
||||
rr, err := downloader.Resolve(req)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !strings.Contains(err.Error(), "white") {
|
||||
t.Errorf("TestDownloader_CreateNotInWhite() got = %v, want error containing 'white'", err.Error())
|
||||
// The directory is decided when the task starts, so a directory outside
|
||||
// the allowlist fails the task instead of the create call.
|
||||
taskId, err := downloader.CreateWithOptions(rr.ID, newTestDownloadOpt(t))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
task := downloader.GetTask(taskId)
|
||||
if task == nil {
|
||||
t.Fatal("task not found")
|
||||
}
|
||||
deadline := time.Now().Add(5 * time.Second)
|
||||
for time.Now().Before(deadline) {
|
||||
if task.Status == base.DownloadStatusError {
|
||||
break
|
||||
}
|
||||
time.Sleep(20 * time.Millisecond)
|
||||
}
|
||||
if task.Status != base.DownloadStatusError {
|
||||
t.Fatalf("task status = %q, want the task to be rejected by the allowlist", task.Status)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1072,7 +1090,7 @@ func doTestDownloaderCreateWithProxy(t *testing.T, auth bool, buildReqProxy func
|
||||
Pwd: pwd,
|
||||
})
|
||||
}
|
||||
rr, err := downloader.Resolve(req, nil)
|
||||
rr, err := downloader.Resolve(req)
|
||||
if err != nil {
|
||||
if errHandler == nil {
|
||||
t.Fatal(err)
|
||||
@@ -1142,6 +1160,33 @@ func TestDownloader_CreateRename(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestDownloader_IsFirstLoadSurvivesTheConfigClone(t *testing.T) {
|
||||
d := NewDownloader(&DownloaderConfig{StorageDir: t.TempDir(), TempDir: t.TempDir()})
|
||||
if err := d.Setup(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer d.Close()
|
||||
|
||||
if !d.IsFirstLoad() {
|
||||
t.Fatal("a downloader that created its config reports IsFirstLoad() = false")
|
||||
}
|
||||
// GetConfig clones through json and FirstLoad is not serialized, so the cmd
|
||||
// layer cannot read the flag from the returned config.
|
||||
cfg, err := d.GetConfig()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if cfg.FirstLoad {
|
||||
t.Fatal("the cloned config must not report the first load")
|
||||
}
|
||||
if err := d.PutConfig(cfg); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if d.IsFirstLoad() {
|
||||
t.Fatal("a downloader that stored its config reports IsFirstLoad() = true")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDownloader_StoreAndRestore(t *testing.T) {
|
||||
listener := test.StartTestSlowFileServer(time.Millisecond * 2000)
|
||||
defer listener.Close()
|
||||
@@ -1158,7 +1203,7 @@ func TestDownloader_StoreAndRestore(t *testing.T) {
|
||||
URL: "http://" + listener.Addr().String() + "/" + test.BuildName,
|
||||
}
|
||||
opts := newTestDownloadOpt(t)
|
||||
rr, err := downloader.Resolve(req, opts)
|
||||
rr, err := downloader.Resolve(req)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -1172,7 +1217,7 @@ func TestDownloader_StoreAndRestore(t *testing.T) {
|
||||
})
|
||||
}
|
||||
})
|
||||
id, err := downloader.Create(rr.ID)
|
||||
id, err := downloader.CreateWithOptions(rr.ID, opts)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -1241,7 +1286,7 @@ func TestDownloader_Protocol_Config(t *testing.T) {
|
||||
"trackerSubscribeUrls": []string{
|
||||
"https://raw.githubusercontent.com/XIU2/TrackersListCollection/master/best.txt",
|
||||
},
|
||||
"trackers": []string{
|
||||
"customTrackers": []string{
|
||||
"udp://tracker.coppersurfer.tk:6969/announce",
|
||||
"udp://tracker.leechers-paradise.org:6969/announce",
|
||||
},
|
||||
@@ -1261,8 +1306,20 @@ func TestDownloader_Protocol_Config(t *testing.T) {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if !test.JsonEqual(storeCfg, newStoreCfg) {
|
||||
t.Errorf("GetConfig() got = %v, want %v", test.ToJson(storeCfg), test.ToJson(newStoreCfg))
|
||||
if newStoreCfg.DownloadDir != storeCfg.DownloadDir || newStoreCfg.Extra["theme"] != "dark" {
|
||||
t.Fatalf("downloader config was not saved: %+v", newStoreCfg)
|
||||
}
|
||||
var savedHTTP struct {
|
||||
Connections int `json:"connections"`
|
||||
}
|
||||
if err := util.MapToStruct(newStoreCfg.ProtocolConfig["http"], &savedHTTP); err != nil || savedHTTP.Connections != 4 {
|
||||
t.Fatalf("HTTP config: %+v %v", savedHTTP, err)
|
||||
}
|
||||
var savedBT struct {
|
||||
CustomTrackers []string `json:"customTrackers"`
|
||||
}
|
||||
if err := util.MapToStruct(newStoreCfg.ProtocolConfig["bt"], &savedBT); err != nil || len(savedBT.CustomTrackers) != 2 {
|
||||
t.Fatalf("BT derived trackers: %+v %v", savedBT, err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1434,7 +1491,7 @@ func TestDownloader_Stats(t *testing.T) {
|
||||
req := &base.Request{
|
||||
URL: "http://" + listener.Addr().String() + "/" + test.BuildName,
|
||||
}
|
||||
rr, err := downloader.Resolve(req, newTestDownloadOpt(t))
|
||||
rr, err := downloader.Resolve(req)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -1447,7 +1504,7 @@ func TestDownloader_Stats(t *testing.T) {
|
||||
}
|
||||
})
|
||||
|
||||
taskId, err := downloader.Create(rr.ID)
|
||||
taskId, err := downloader.CreateWithOptions(rr.ID, newTestDownloadOpt(t))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -1677,7 +1734,7 @@ func TestDownloader_CompletedHTTPStatsSurviveRestore(t *testing.T) {
|
||||
t.Fatal(err)
|
||||
}
|
||||
req := &base.Request{URL: "http://" + listener.Addr().String() + "/" + test.BuildName}
|
||||
rr, err := downloader.Resolve(req, newTestDownloadOpt(t))
|
||||
rr, err := downloader.Resolve(req)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -1688,7 +1745,7 @@ func TestDownloader_CompletedHTTPStatsSurviveRestore(t *testing.T) {
|
||||
done <- struct{}{}
|
||||
}
|
||||
})
|
||||
taskID, err := downloader.Create(rr.ID)
|
||||
taskID, err := downloader.CreateWithOptions(rr.ID, newTestDownloadOpt(t))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -1861,11 +1918,11 @@ func TestDownloader_PauseAndContinue(t *testing.T) {
|
||||
req := &base.Request{
|
||||
URL: "http://" + listener.Addr().String() + "/" + test.BuildName,
|
||||
}
|
||||
rr, err := downloader.Resolve(req, newTestDownloadOpt(t))
|
||||
rr, err := downloader.Resolve(req)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
taskId, err := downloader.Create(rr.ID)
|
||||
taskId, err := downloader.CreateWithOptions(rr.ID, newTestDownloadOpt(t))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -2002,6 +2059,9 @@ func TestDownloader_ContinueOnStartupUsesContinueAllSemantics(t *testing.T) {
|
||||
t.Fatal(err)
|
||||
}
|
||||
cfg.Extra = map[string]any{"autoStartTasks": true}
|
||||
if err := downloader.PutConfig(cfg); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := downloader.ContinueOnStartup(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -2010,6 +2070,9 @@ func TestDownloader_ContinueOnStartupUsesContinueAllSemantics(t *testing.T) {
|
||||
}
|
||||
|
||||
cfg.AutoStartTasks = true
|
||||
if err := downloader.PutConfig(cfg); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := downloader.ContinueOnStartup(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -2045,11 +2108,11 @@ func TestDownloader_PauseAllAndContinueAll(t *testing.T) {
|
||||
req := &base.Request{
|
||||
URL: "http://" + listener.Addr().String() + "/" + test.BuildName,
|
||||
}
|
||||
rr, err := downloader.Resolve(req, newTestDownloadOpt(t))
|
||||
rr, err := downloader.Resolve(req)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
taskId, err := downloader.Create(rr.ID)
|
||||
taskId, err := downloader.CreateWithOptions(rr.ID, newTestDownloadOpt(t))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
@@ -30,6 +30,21 @@ support must remain disabled. Do not declare the sum of the input sizes as the
|
||||
output size. The downloader reads the merged output as one file; FFmpeg errors
|
||||
propagate through the stream instead of completing a partial download.
|
||||
|
||||
Extension resolve results can set `files[].size` to an estimated output size,
|
||||
for example the sum of the selected video and audio input lengths. Clients forward
|
||||
this value as the optional task creation `opts.size` for HTTP downloads only
|
||||
(including Blob URLs served over HTTP). Other protocols, including HLS, ignore
|
||||
this option. The estimate is persisted
|
||||
and displayed while queued and downloading. A valid HTTP total length (including
|
||||
zero) always overrides it; otherwise a clean EOF records the actual output length.
|
||||
Estimates never determine byte ranges, file allocation, integrity checks, or task
|
||||
completion. Size authority is persisted alongside HTTP connection state for
|
||||
pause/retry and process restart.
|
||||
|
||||
Blob options are unchanged: `createObjectURL`'s `size` is an exact source length,
|
||||
not an estimate. Leave it unset for generated merge output and pass any estimate
|
||||
through the extension resolve file instead.
|
||||
|
||||
## JS inputs, including SABR
|
||||
|
||||
Stream inputs **must** be created by an `inputs({ signal })` callback. It returns
|
||||
@@ -130,7 +145,8 @@ associates its input progress with that Blob. No public progress callback or
|
||||
extra `createObjectURL` argument is required. Return the merge stream itself;
|
||||
wrapping it in another stream loses this association. Downloaded bytes count
|
||||
unique input offsets; speed counts received input bytes, including re-fetches.
|
||||
The task stays downloading with unknown total size while queued or merging;
|
||||
The task stays downloading while queued or merging, displaying the optional
|
||||
`files[].size` estimate (or an unknown total when omitted);
|
||||
once input traffic stops the displayed speed settles to zero. Completion uses
|
||||
the actual merged output size. Generated output waits are governed by producer
|
||||
cancellation/errors rather than the HTTP body idle timeout; HTTP media inputs
|
||||
|
||||
+77
-36
@@ -1,17 +1,19 @@
|
||||
package download
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/url"
|
||||
"os"
|
||||
"path"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/GopeedLab/gopeed/internal/fetcher"
|
||||
"github.com/GopeedLab/gopeed/internal/githubmirror"
|
||||
"github.com/GopeedLab/gopeed/internal/logger"
|
||||
"github.com/GopeedLab/gopeed/pkg/base"
|
||||
"github.com/GopeedLab/gopeed/pkg/download/engine"
|
||||
@@ -196,50 +198,85 @@ func (d *Downloader) getExtension(identity string) *Extension {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (d *Downloader) fetchExtensionByGit(url string, handler func(tempExtPath string, devMode bool) (*Extension, error)) (*Extension, error) {
|
||||
if !strings.HasPrefix(url, "https://") && !strings.HasPrefix(url, "http://") {
|
||||
url = "https://" + url
|
||||
}
|
||||
// waitForExtensionGit waits for clones and their install/check handlers before
|
||||
// shutdown releases storage and temporary files. Close cancels backgroundCtx first.
|
||||
func (d *Downloader) waitForExtensionGit() {
|
||||
d.extensionGitLock.Lock()
|
||||
d.extensionGitLock.Unlock()
|
||||
d.extensionGitWG.Wait()
|
||||
}
|
||||
|
||||
// resolve project path
|
||||
parentPath, projectPath := path.Split(url)
|
||||
// resolve project name and sub path
|
||||
pathArr := strings.SplitN(projectPath, "#", 2)
|
||||
projectPath = strings.TrimSuffix(pathArr[0], gitSuffix)
|
||||
subPath := ""
|
||||
if len(pathArr) > 1 {
|
||||
subPath = pathArr[1]
|
||||
func (d *Downloader) fetchExtensionByGit(rawURL string, handler func(string, bool) (*Extension, error)) (*Extension, error) {
|
||||
// Pair admission with shutdown so no operation is added after its wait starts.
|
||||
d.extensionGitLock.Lock()
|
||||
if d.closed.Load() {
|
||||
d.extensionGitLock.Unlock()
|
||||
return nil, fmt.Errorf("downloader is closed")
|
||||
}
|
||||
|
||||
// Use unique suffix to avoid concurrent conflicts when multiple git operations
|
||||
// target the same extension (e.g., install and update check happening simultaneously)
|
||||
tempExtDir, err := os.MkdirTemp(d.cfg.TempDir, "gopeed-extension-")
|
||||
d.extensionGitWG.Add(1)
|
||||
d.extensionGitLock.Unlock()
|
||||
defer d.extensionGitWG.Done()
|
||||
ctx := d.backgroundCtx
|
||||
if !strings.HasPrefix(rawURL, "https://") && !strings.HasPrefix(rawURL, "http://") {
|
||||
rawURL = "https://" + rawURL
|
||||
}
|
||||
parsed, err := url.Parse(rawURL)
|
||||
if err != nil || parsed.Host == "" {
|
||||
return nil, fmt.Errorf("invalid extension repository URL")
|
||||
}
|
||||
subPath := filepath.FromSlash(parsed.Fragment)
|
||||
if filepath.IsAbs(subPath) || subPath == ".." || strings.HasPrefix(filepath.Clean(subPath), ".."+string(filepath.Separator)) {
|
||||
return nil, fmt.Errorf("invalid extension directory")
|
||||
}
|
||||
parsed.Fragment = ""
|
||||
parsed.Path = strings.TrimSuffix(strings.TrimRight(parsed.Path, "/"), gitSuffix) + gitSuffix
|
||||
officialURL := parsed.String()
|
||||
cfg, err := d.GetConfig()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := d.tempFiles.Track(tempExtDir); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer d.tempFiles.Remove(tempExtDir)
|
||||
|
||||
proxyOptions := transport.ProxyOptions{}
|
||||
proxyUrl := d.cfg.DownloaderStoreConfig.Proxy.ToUrl()
|
||||
if proxyUrl != nil {
|
||||
proxyOptions.URL = proxyUrl.Scheme + "://" + proxyUrl.Host
|
||||
proxyOptions.Username = proxyUrl.User.Username()
|
||||
proxyOptions.Password, _ = proxyUrl.User.Password()
|
||||
if proxyURL := cfg.Proxy.ToUrl(); proxyURL != nil {
|
||||
proxyOptions.URL = proxyURL.Scheme + "://" + proxyURL.Host
|
||||
if proxyURL.User != nil {
|
||||
proxyOptions.Username = proxyURL.User.Username()
|
||||
proxyOptions.Password, _ = proxyURL.User.Password()
|
||||
}
|
||||
}
|
||||
// clone project to extension temp dir
|
||||
gitUrl := parentPath + projectPath + gitSuffix
|
||||
if _, err := git.PlainClone(tempExtDir, false, &git.CloneOptions{
|
||||
URL: gitUrl,
|
||||
Depth: 1,
|
||||
ProxyOptions: proxyOptions,
|
||||
}); err != nil {
|
||||
tempDir, err := os.MkdirTemp(d.cfg.TempDir, "gopeed-extension-")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return handler(filepath.Join(tempExtDir, subPath), false)
|
||||
if err = d.tempFiles.Track(tempDir); err != nil {
|
||||
_ = os.RemoveAll(tempDir)
|
||||
return nil, err
|
||||
}
|
||||
defer d.tempFiles.Remove(tempDir)
|
||||
repoDir := filepath.Join(tempDir, "repo")
|
||||
repo, err := githubmirror.Fallback(ctx, cfg.GithubMirror, officialURL,
|
||||
githubmirror.Options[*git.Repository]{Kind: githubmirror.Repository},
|
||||
func(ctx context.Context, candidate string) (*git.Repository, error) {
|
||||
if err := os.RemoveAll(repoDir); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return git.PlainCloneContext(ctx, repoDir, false, &git.CloneOptions{URL: candidate, Depth: 1, ProxyOptions: proxyOptions})
|
||||
},
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// Preserve the canonical source instead of persisting a selected mirror.
|
||||
repoConfig, err := repo.Config()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if remote := repoConfig.Remotes["origin"]; remote != nil {
|
||||
remote.URLs = []string{officialURL}
|
||||
}
|
||||
if err = repo.SetConfig(repoConfig); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return handler(filepath.Join(repoDir, subPath), false)
|
||||
}
|
||||
|
||||
func (d *Downloader) parseExtensionByPath(path string) (*Extension, error) {
|
||||
@@ -753,6 +790,10 @@ type OnErrorExtensionTask struct {
|
||||
}
|
||||
|
||||
func cloneExtensionTask(task *Task) *Task {
|
||||
if task.statusLock != nil {
|
||||
task.statusLock.Lock()
|
||||
defer task.statusLock.Unlock()
|
||||
}
|
||||
newTask := task.clone()
|
||||
newTask.Meta.Req = task.Meta.Req
|
||||
return newTask
|
||||
|
||||
@@ -65,11 +65,11 @@ func TestExtensionExtraMutationHTTP(t *testing.T) {
|
||||
var id string
|
||||
var err error
|
||||
if resolved {
|
||||
rr, resolveErr := d.Resolve(req, opts)
|
||||
rr, resolveErr := d.Resolve(req)
|
||||
if resolveErr != nil {
|
||||
t.Fatal(resolveErr)
|
||||
}
|
||||
id, err = d.Create(rr.ID)
|
||||
id, err = d.CreateWithOptions(rr.ID, opts)
|
||||
} else {
|
||||
id, err = d.CreateDirect(req, opts)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,210 @@
|
||||
package download
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/cgi"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
httpProtocol "github.com/GopeedLab/gopeed/internal/protocol/http"
|
||||
"github.com/GopeedLab/gopeed/pkg/base"
|
||||
)
|
||||
|
||||
// Exercise real Git clone/install/check/update without depending on GitHub or
|
||||
// public mirror availability. The proxy deliberately rejects the official URL.
|
||||
// github.com/gopeed-test/repo is a placeholder: the successful mirror serves
|
||||
// the temporary local repository through git http-backend instead.
|
||||
func newExtensionGitFixture(t *testing.T) (*Downloader, func(string), *atomic.Int32, *atomic.Int32) {
|
||||
t.Helper()
|
||||
git, err := exec.LookPath("git")
|
||||
if err != nil {
|
||||
t.Skip("Git executable required for HTTP Git fixture")
|
||||
}
|
||||
root := t.TempDir()
|
||||
bare := filepath.Join(root, "repo.git")
|
||||
work := t.TempDir()
|
||||
command := func(args ...string) {
|
||||
t.Helper()
|
||||
cmd := exec.Command(git, args...)
|
||||
if output, err := cmd.CombinedOutput(); err != nil {
|
||||
t.Fatalf("git %v: %v: %s", args, err, output)
|
||||
}
|
||||
}
|
||||
command("-c", "init.defaultBranch=main", "init", "--bare", bare)
|
||||
command("clone", bare, work)
|
||||
command("-C", work, "config", "user.name", "Gopeed Test")
|
||||
command("-C", work, "config", "user.email", "[email protected]")
|
||||
publish := func(version string) {
|
||||
t.Helper()
|
||||
directory := filepath.Join(work, "nested", "extension")
|
||||
if err := os.MkdirAll(directory, 0700); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
manifest := map[string]any{"name": "mirror-test", "author": "test", "title": "Mirror test", "version": version, "repository": map[string]string{"url": "https://github.com/gopeed-test/repo", "directory": "nested/extension"}}
|
||||
body, _ := json.Marshal(manifest)
|
||||
if err := os.WriteFile(filepath.Join(directory, "manifest.json"), body, 0600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
command("-C", work, "add", ".")
|
||||
command("-C", work, "commit", "-m", version)
|
||||
command("-C", work, "push", "origin", "HEAD:main")
|
||||
}
|
||||
publish("1.0.0")
|
||||
var official, badMirror atomic.Int32
|
||||
backend := &cgi.Handler{Path: git, Args: []string{"http-backend"}, Env: []string{"GIT_PROJECT_ROOT=" + root, "GIT_HTTP_EXPORT_ALL=1"}}
|
||||
mirror := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
r.URL.Path = strings.TrimPrefix(r.URL.Path, "/https://github.com/gopeed-test")
|
||||
r.RequestURI = r.URL.RequestURI()
|
||||
backend.ServeHTTP(w, r)
|
||||
}))
|
||||
t.Cleanup(mirror.Close)
|
||||
failed := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { badMirror.Add(1); http.Error(w, "mirror offline", 502) }))
|
||||
t.Cleanup(failed.Close)
|
||||
direct := &http.Transport{}
|
||||
t.Cleanup(direct.CloseIdleConnections)
|
||||
proxy := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method == http.MethodConnect || r.URL.Hostname() == "github.com" {
|
||||
official.Add(1)
|
||||
http.Error(w, "official unavailable", 502)
|
||||
return
|
||||
}
|
||||
request := r.Clone(r.Context())
|
||||
request.RequestURI = ""
|
||||
response, err := direct.RoundTrip(request)
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), 502)
|
||||
return
|
||||
}
|
||||
defer response.Body.Close()
|
||||
for key, values := range response.Header {
|
||||
for _, value := range values {
|
||||
w.Header().Add(key, value)
|
||||
}
|
||||
}
|
||||
w.WriteHeader(response.StatusCode)
|
||||
_, _ = io.Copy(w, response.Body)
|
||||
}))
|
||||
t.Cleanup(proxy.Close)
|
||||
d := testBackendDownloader(t, &httpProtocol.FetcherManager{})
|
||||
config, _ := d.GetConfig()
|
||||
config.Proxy = &base.DownloaderProxyConfig{Enable: true, Scheme: "http", Host: strings.TrimPrefix(proxy.URL, "http://")}
|
||||
config.GithubMirror = &base.GithubMirrorConfig{Enabled: true, Mirrors: []base.GithubMirror{{Type: "ghProxy", URL: failed.URL}, {Type: "ghProxy", URL: mirror.URL}}}
|
||||
if err := d.PutConfig(config); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return d, publish, &official, &badMirror
|
||||
}
|
||||
func testInstallExtensionGitURL(t *testing.T, raw string) {
|
||||
d, _, official, badMirror := newExtensionGitFixture(t)
|
||||
extension, err := d.InstallExtensionByGit(raw)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if extension.Version != "1.0.0" || extension.Identity != "test@mirror-test" {
|
||||
t.Fatalf("%+v", extension)
|
||||
}
|
||||
if official.Load() == 0 || badMirror.Load() == 0 {
|
||||
t.Fatal("ordered fallback was not exercised")
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(d.ExtensionPath(extension), "manifest.json")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
func TestExtensionGitMirrorInstallAndUpdate(t *testing.T) {
|
||||
d, publish, official, badMirror := newExtensionGitFixture(t)
|
||||
extension, err := d.InstallExtensionByGit("https://github.com/gopeed-test/repo#nested/extension")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
publish("1.1.0")
|
||||
version, err := d.UpgradeCheckExtension(extension.Identity)
|
||||
if err != nil || version != "1.1.0" {
|
||||
t.Fatalf("%s %v", version, err)
|
||||
}
|
||||
if err = d.UpgradeExtension(extension.Identity); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
updated, err := d.GetExtension(extension.Identity)
|
||||
if err != nil || updated.Version != "1.1.0" {
|
||||
t.Fatalf("%+v %v", updated, err)
|
||||
}
|
||||
if updated.Repository.Url != "https://github.com/gopeed-test/repo" {
|
||||
t.Fatal("mirror URL replaced canonical repository")
|
||||
}
|
||||
if official.Load() != 3 || badMirror.Load() != 3 {
|
||||
t.Fatalf("attempts official=%d mirror=%d", official.Load(), badMirror.Load())
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtensionGitShutdownWaitsForHandler(t *testing.T) {
|
||||
d, _, _, _ := newExtensionGitFixture(t)
|
||||
started := make(chan string, 1)
|
||||
release := make(chan struct{})
|
||||
t.Cleanup(func() {
|
||||
select {
|
||||
case <-release:
|
||||
default:
|
||||
close(release)
|
||||
}
|
||||
})
|
||||
finished := make(chan error, 1)
|
||||
go func() {
|
||||
_, err := d.fetchExtensionByGit("https://github.com/gopeed-test/repo#nested/extension", func(path string, _ bool) (*Extension, error) {
|
||||
started <- path
|
||||
<-release
|
||||
return d.parseExtensionByPath(path)
|
||||
})
|
||||
finished <- err
|
||||
}()
|
||||
var path string
|
||||
select {
|
||||
case path = <-started:
|
||||
case err := <-finished:
|
||||
t.Fatalf("Git operation finished before handler: %v", err)
|
||||
case <-time.After(5 * time.Second):
|
||||
t.Fatal("Git handler did not start")
|
||||
}
|
||||
closed := make(chan error, 1)
|
||||
go func() { closed <- d.Close() }()
|
||||
select {
|
||||
case <-d.backgroundCtx.Done():
|
||||
case <-time.After(5 * time.Second):
|
||||
t.Fatal("shutdown did not cancel Git context")
|
||||
}
|
||||
select {
|
||||
case err := <-closed:
|
||||
t.Fatalf("shutdown returned before handler finished: %v", err)
|
||||
case <-time.After(100 * time.Millisecond):
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(path, "manifest.json")); err != nil {
|
||||
t.Fatalf("shutdown removed files still used by handler: %v", err)
|
||||
}
|
||||
if _, err := d.InstallExtensionByGit("https://github.com/gopeed-test/repo"); err == nil || !strings.Contains(err.Error(), "downloader is closed") {
|
||||
t.Fatalf("expected new Git operations to be rejected during shutdown: %v", err)
|
||||
}
|
||||
close(release)
|
||||
select {
|
||||
case err := <-finished:
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
case <-time.After(5 * time.Second):
|
||||
t.Fatal("Git handler did not finish")
|
||||
}
|
||||
select {
|
||||
case err := <-closed:
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
case <-time.After(5 * time.Second):
|
||||
t.Fatal("shutdown did not finish after Git handler")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
package download
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
gitclient "github.com/go-git/go-git/v5/plumbing/transport/client"
|
||||
githttp "github.com/go-git/go-git/v5/plumbing/transport/http"
|
||||
)
|
||||
|
||||
func init() {
|
||||
// go-git exposes a process-wide protocol registry. Register once during
|
||||
// initialization, rather than changing it during concurrent clones. Proxy
|
||||
// settings still come from each clone's ProxyOptions.
|
||||
transport := githttp.NewClient(newExtensionGitHTTPClient(10*time.Second, 60*time.Second))
|
||||
gitclient.InstallProtocol("http", transport)
|
||||
gitclient.InstallProtocol("https", transport)
|
||||
}
|
||||
|
||||
func newExtensionGitHTTPClient(connectTimeout, idleTimeout time.Duration) *http.Client {
|
||||
dialer := &net.Dialer{Timeout: connectTimeout, KeepAlive: 30 * time.Second}
|
||||
// A socket read deadline must apply to one transfer, rather than a shared
|
||||
// HTTP/2 connection carrying traffic from other requests.
|
||||
protocols := new(http.Protocols)
|
||||
protocols.SetHTTP1(true)
|
||||
return &http.Client{Transport: &http.Transport{
|
||||
Proxy: http.ProxyFromEnvironment,
|
||||
DialContext: func(ctx context.Context, network, address string) (net.Conn, error) {
|
||||
conn, err := dialer.DialContext(ctx, network, address)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &gitIdleConn{Conn: conn, timeout: idleTimeout}, nil
|
||||
},
|
||||
TLSHandshakeTimeout: connectTimeout,
|
||||
IdleConnTimeout: idleTimeout,
|
||||
Protocols: protocols,
|
||||
}}
|
||||
}
|
||||
|
||||
type gitIdleConn struct {
|
||||
net.Conn
|
||||
timeout time.Duration
|
||||
}
|
||||
|
||||
func (c *gitIdleConn) Read(p []byte) (int, error) {
|
||||
if err := c.Conn.SetReadDeadline(time.Now().Add(c.timeout)); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return c.Conn.Read(p)
|
||||
}
|
||||
|
||||
// Refresh an already-pending read when a pooled HTTP/1 connection starts the
|
||||
// next request; time spent idle in the pool must not consume its read allowance.
|
||||
func (c *gitIdleConn) Write(p []byte) (int, error) {
|
||||
if err := c.Conn.SetReadDeadline(time.Now().Add(c.timeout)); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return c.Conn.Write(p)
|
||||
}
|
||||
@@ -0,0 +1,189 @@
|
||||
package download
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"io"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/http/httptrace"
|
||||
"net/url"
|
||||
"reflect"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/GopeedLab/gopeed/internal/githubmirror"
|
||||
)
|
||||
|
||||
func TestExtensionGitHTTPTransportAllowsSlowStreaming(t *testing.T) {
|
||||
const chunks = 24
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
for range chunks {
|
||||
if _, err := io.WriteString(w, "x"); err != nil {
|
||||
return
|
||||
}
|
||||
w.(http.Flusher).Flush()
|
||||
select {
|
||||
case <-time.After(50 * time.Millisecond):
|
||||
case <-r.Context().Done():
|
||||
return
|
||||
}
|
||||
}
|
||||
}))
|
||||
defer server.Close()
|
||||
client := newExtensionGitHTTPClient(time.Second, time.Second)
|
||||
defer client.CloseIdleConnections()
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
defer cancel()
|
||||
request, _ := http.NewRequestWithContext(ctx, http.MethodGet, server.URL, nil)
|
||||
response, err := client.Do(request)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer response.Body.Close()
|
||||
// A regularly streaming response lasts longer than the idle allowance.
|
||||
body, err := io.ReadAll(response.Body)
|
||||
if err != nil || len(body) != chunks {
|
||||
t.Fatalf("received %d bytes, error=%v", len(body), err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtensionGitHTTPTransportTimesOutStalledReads(t *testing.T) {
|
||||
for _, bodyStarted := range []bool{false, true} {
|
||||
name := "headers"
|
||||
if bodyStarted {
|
||||
name = "body"
|
||||
}
|
||||
t.Run(name, func(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if bodyStarted {
|
||||
_, _ = io.WriteString(w, "partial")
|
||||
w.(http.Flusher).Flush()
|
||||
}
|
||||
<-r.Context().Done()
|
||||
}))
|
||||
defer server.Close()
|
||||
client := newExtensionGitHTTPClient(time.Second, 100*time.Millisecond)
|
||||
defer client.CloseIdleConnections()
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
request, _ := http.NewRequestWithContext(ctx, http.MethodGet, server.URL, nil)
|
||||
response, err := client.Do(request)
|
||||
if err == nil {
|
||||
defer response.Body.Close()
|
||||
_, err = io.ReadAll(response.Body)
|
||||
}
|
||||
var timeout net.Error
|
||||
if !errors.As(err, &timeout) || !timeout.Timeout() || ctx.Err() != nil {
|
||||
t.Fatalf("expected idle timeout before parent deadline: error=%v context=%v", err, ctx.Err())
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtensionGitIdleTimeoutFallsBackThroughProxy(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Hostname() == "github.com" {
|
||||
_, _ = io.WriteString(w, "partial")
|
||||
w.(http.Flusher).Flush()
|
||||
<-r.Context().Done()
|
||||
return
|
||||
}
|
||||
_, _ = io.WriteString(w, "complete")
|
||||
}))
|
||||
defer server.Close()
|
||||
client := newExtensionGitHTTPClient(time.Second, 100*time.Millisecond)
|
||||
defer client.CloseIdleConnections()
|
||||
proxy, _ := url.Parse(server.URL)
|
||||
client.Transport.(*http.Transport).Proxy = http.ProxyURL(proxy)
|
||||
config := &githubmirror.Config{Enabled: true, Mirrors: []githubmirror.Mirror{{Type: "ghProxy", URL: server.URL}}}
|
||||
source := "http://github.com/test/repo.git"
|
||||
var tried []string
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
result, err := githubmirror.Fallback(ctx, config, source, githubmirror.Options[string]{Kind: githubmirror.Repository}, func(ctx context.Context, candidate string) (string, error) {
|
||||
tried = append(tried, candidate)
|
||||
request, _ := http.NewRequestWithContext(ctx, http.MethodGet, candidate, nil)
|
||||
response, err := client.Do(request)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
defer response.Body.Close()
|
||||
body, err := io.ReadAll(response.Body)
|
||||
return string(body), err
|
||||
})
|
||||
if err != nil || result != "complete" || !reflect.DeepEqual(tried, []string{source, server.URL + "/" + source}) {
|
||||
t.Fatalf("result=%q tried=%v error=%v", result, tried, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtensionGitHTTPTransportCancelsStalledBody(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
w.(http.Flusher).Flush()
|
||||
<-r.Context().Done()
|
||||
}))
|
||||
defer server.Close()
|
||||
client := newExtensionGitHTTPClient(time.Second, time.Minute)
|
||||
defer client.CloseIdleConnections()
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
request, _ := http.NewRequestWithContext(ctx, http.MethodGet, server.URL, nil)
|
||||
response, err := client.Do(request)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer response.Body.Close()
|
||||
cancel()
|
||||
done := make(chan error, 1)
|
||||
go func() { _, err := io.ReadAll(response.Body); done <- err }()
|
||||
select {
|
||||
case err := <-done:
|
||||
if !errors.Is(err, context.Canceled) {
|
||||
t.Fatalf("error=%v", err)
|
||||
}
|
||||
case <-time.After(5 * time.Second):
|
||||
t.Fatal("parent cancellation did not interrupt the body")
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtensionGitHTTPTransportRefreshesReusedConnection(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path == "/slow" {
|
||||
select {
|
||||
case <-time.After(500 * time.Millisecond):
|
||||
case <-r.Context().Done():
|
||||
return
|
||||
}
|
||||
}
|
||||
_, _ = io.WriteString(w, "ok")
|
||||
}))
|
||||
defer server.Close()
|
||||
client := newExtensionGitHTTPClient(time.Second, time.Second)
|
||||
defer client.CloseIdleConnections()
|
||||
response, err := client.Get(server.URL)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_, err = io.Copy(io.Discard, response.Body)
|
||||
_ = response.Body.Close()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
time.Sleep(700 * time.Millisecond)
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
reused := false
|
||||
ctx = httptrace.WithClientTrace(ctx, &httptrace.ClientTrace{GotConn: func(info httptrace.GotConnInfo) { reused = info.Reused }})
|
||||
request, _ := http.NewRequestWithContext(ctx, http.MethodGet, server.URL+"/slow", nil)
|
||||
response, err = client.Do(request)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer response.Body.Close()
|
||||
body, err := io.ReadAll(response.Body)
|
||||
if err != nil || string(body) != "ok" || !reused {
|
||||
t.Fatalf("reused=%v body=%q error=%v", reused, body, err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,158 @@
|
||||
package download
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/GopeedLab/gopeed/pkg/base"
|
||||
)
|
||||
|
||||
func TestExtensionResolveSizeThroughTaskCreation(t *testing.T) {
|
||||
for _, mode := range []string{"http", "blob"} {
|
||||
for _, known := range []bool{false, true} {
|
||||
t.Run(fmt.Sprintf("%s/known=%v", mode, known), func(t *testing.T) {
|
||||
payload := []byte("0123456789")
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
body := payload
|
||||
if r.URL.Path == "/empty" {
|
||||
body = nil
|
||||
}
|
||||
if known {
|
||||
w.Header().Set("Content-Length", fmt.Sprint(len(body)))
|
||||
}
|
||||
w.(http.Flusher).Flush()
|
||||
w.Write(body)
|
||||
}))
|
||||
defer server.Close()
|
||||
d := NewDownloader(&DownloaderConfig{Storage: NewMemStorage(), StorageDir: t.TempDir(), TempDir: t.TempDir()})
|
||||
if err := d.Setup(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer d.Clear()
|
||||
d.cfg.MaxRunning = 0 // Inspect persisted queued tasks before HTTP resolution.
|
||||
extDir := t.TempDir()
|
||||
manifest := `{"name":"size-hint","author":"test","title":"Size hint","version":"1.0.0","scripts":[{"event":"onResolve","match":{"urls":["https://example.com/size-hint"]},"entry":"index.js"}]}`
|
||||
script := fmt.Sprintf(`gopeed.events.onResolve(async ctx=>{
|
||||
const mode=%q, origin=%q;
|
||||
const file=async(name,size,empty)=>({name,size,req:{url:mode==='http'?origin+(empty?'/empty':'/data'):
|
||||
await gopeed.runtime.blob.createObjectURL(()=>new ReadableStream({start(c){if(!empty)c.enqueue(new Uint8Array([48,49,50,51,52,53,54,55,56,57]));c.close();}}))}});
|
||||
ctx.res={files:[await file('under.bin',3,false),await file('over.bin',1048576,false),await file('empty.bin',99,true)]};
|
||||
});`, mode, server.URL)
|
||||
for name, contents := range map[string]string{"manifest.json": manifest, "index.js": script} {
|
||||
if err := os.WriteFile(filepath.Join(extDir, name), []byte(contents), 0600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
if _, err := d.InstallExtensionByFolder(extDir, true); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
rr, err := d.Resolve(&base.Request{URL: "https://example.com/size-hint"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
output := t.TempDir()
|
||||
batch := &base.CreateTaskBatch{}
|
||||
for _, file := range rr.Res.Files {
|
||||
batch.Reqs = append(batch.Reqs, &base.CreateTaskBatchItem{Req: file.Req, Opts: &base.Options{Size: file.Size, Name: file.Name, Path: output}})
|
||||
}
|
||||
wire, err := json.Marshal(batch)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var submitted base.CreateTaskBatch
|
||||
if err := json.Unmarshal(wire, &submitted); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
ids, err := d.CreateDirectBatch(&submitted)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for i, id := range ids {
|
||||
task := d.GetTask(id)
|
||||
if task.Meta.Res != nil {
|
||||
t.Fatal("a size estimate must not mark the task resolved")
|
||||
}
|
||||
status, err := d.RuntimeStatus(id)
|
||||
if err != nil || status.Total != rr.Res.Files[i].Size {
|
||||
t.Fatalf("queued size lost: status=%+v err=%v", status, err)
|
||||
}
|
||||
var saved Task
|
||||
if _, err := d.storage.Get(bucketTask, id, &saved); err != nil || saved.Meta.Opts.Size != status.Total {
|
||||
t.Fatal("queued estimate was not persisted", err)
|
||||
}
|
||||
if err := d.doStart(task); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
waitForTaskTerminal(t, d, id, 5*time.Second)
|
||||
body, err := os.ReadFile(filepath.Join(output, rr.Res.Files[i].Name))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
want := payload
|
||||
if i == 2 {
|
||||
want = nil
|
||||
}
|
||||
if !bytes.Equal(body, want) {
|
||||
t.Fatalf("estimate changed output: %q", body)
|
||||
}
|
||||
status, err = d.RuntimeStatus(id)
|
||||
if err != nil || status.Total != int64(len(want)) || status.Downloaded != int64(len(want)) || task.Meta.Res.Files[0].Size != int64(len(want)) {
|
||||
t.Fatalf("final size not updated: %+v err=%v", status, err)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolvedHTTPSizeEstimateAtCreation(t *testing.T) {
|
||||
for _, known := range []bool{false, true} {
|
||||
t.Run(fmt.Sprintf("known=%v", known), func(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
if known {
|
||||
w.Header().Set("Content-Length", "10")
|
||||
}
|
||||
w.(http.Flusher).Flush()
|
||||
w.Write([]byte("0123456789"))
|
||||
}))
|
||||
defer server.Close()
|
||||
d := NewDownloader(&DownloaderConfig{StorageDir: t.TempDir(), TempDir: t.TempDir()})
|
||||
if err := d.Setup(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer d.Clear()
|
||||
d.cfg.MaxRunning = 0
|
||||
rr, err := d.Resolve(&base.Request{URL: server.URL})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
id, err := d.CreateWithOptions(rr.ID, &base.Options{Size: 3, Path: t.TempDir(), Name: "output.bin"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
want := int64(3)
|
||||
if known {
|
||||
want = 10
|
||||
}
|
||||
status, err := d.RuntimeStatus(id)
|
||||
if err != nil || status.Total != want || d.GetTask(id).Meta.Res.Files[0].Size != want {
|
||||
t.Fatalf("resolve-before-create size=%+v want=%d err=%v", status, want, err)
|
||||
}
|
||||
if err := d.doStart(d.GetTask(id)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
waitForTaskTerminal(t, d, id, 5*time.Second)
|
||||
task := d.GetTask(id)
|
||||
if task.Status != base.DownloadStatusDone || task.Meta.Res.Size != 10 {
|
||||
t.Fatalf("estimate changed completion: status=%s size=%d", task.Status, task.Meta.Res.Size)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,212 @@
|
||||
package download
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/GopeedLab/gopeed/pkg/base"
|
||||
)
|
||||
|
||||
func TestExtensionFileSizeIsAdvisory(t *testing.T) {
|
||||
video, err := os.ReadFile("engine/testdata/ffmpeg/video.mp4")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
audio, err := os.ReadFile("engine/testdata/ffmpeg/audio.mp4")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, mode := range []string{"plain", "merge", "wrapped-merge"} {
|
||||
var expected []byte
|
||||
for _, size := range []int64{0, 3, 1 << 20} {
|
||||
t.Run(fmt.Sprintf("%s/size=%d", mode, size), func(t *testing.T) {
|
||||
d := NewDownloader(&DownloaderConfig{Storage: NewMemStorage(), StorageDir: t.TempDir(), TempDir: t.TempDir(), RefreshInterval: 50})
|
||||
if err := d.Setup(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer d.Clear()
|
||||
e, _ := d.newExtensionEngine()
|
||||
defer e.Close()
|
||||
e.Runtime.Set("videoBytes", e.Runtime.NewArrayBuffer(video))
|
||||
e.Runtime.Set("audioBytes", e.Runtime.NewArrayBuffer(audio))
|
||||
e.Runtime.Set("mode", mode)
|
||||
result, err := e.RunString(`__gopeed_blob_create_object_url(()=>{
|
||||
const source=b=>new ReadableStream({start(c){c.enqueue(new Uint8Array(b));c.close();}});
|
||||
if (mode==='plain') return source([0,1,2,3,4,5,6,7,8,9]);
|
||||
const output=__gopeed_ffmpeg.merge({inputs:()=>({video:source(videoBytes),audio:source(audioBytes)})});
|
||||
if (mode==='merge') return output;
|
||||
const reader=output.getReader();
|
||||
return new ReadableStream({async pull(c){const {done,value}=await reader.read();if(done)c.close();else c.enqueue(value);},cancel(r){return reader.cancel(r);}});
|
||||
},{contentType:'video/mp4',range:false})`)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
output := t.TempDir()
|
||||
id, err := d.CreateDirect(&base.Request{URL: fmt.Sprint(result)}, &base.Options{Size: size, Path: output, Name: "output.bin"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
waitForTaskTerminal(t, d, id, 30*time.Second)
|
||||
body, err := os.ReadFile(filepath.Join(output, "output.bin"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if size == 0 {
|
||||
expected = body
|
||||
if mode == "plain" && !bytes.Equal(body, []byte{0, 1, 2, 3, 4, 5, 6, 7, 8, 9}) {
|
||||
t.Fatalf("unexpected stream output: %v", body)
|
||||
}
|
||||
if mode != "plain" && !bytes.Contains(body, []byte("moof")) {
|
||||
t.Fatal("invalid merged output")
|
||||
}
|
||||
} else if !bytes.Equal(body, expected) {
|
||||
t.Fatalf("size hint changed output: got %d bytes, want %d", len(body), len(expected))
|
||||
}
|
||||
task := d.GetTask(id)
|
||||
task.lock.Lock()
|
||||
defer task.lock.Unlock()
|
||||
if task.Meta.Res.Size != int64(len(body)) || task.Meta.Res.Files[0].Size != int64(len(body)) {
|
||||
t.Fatalf("final metadata must use actual size: %#v", task.Meta.Res)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtensionFileSizeYieldsToBlobContentLength(t *testing.T) {
|
||||
for _, materialized := range []bool{false, true} {
|
||||
for _, size := range []int64{3, 1 << 20} {
|
||||
t.Run(fmt.Sprintf("materialized=%v/estimate=%d", materialized, size), func(t *testing.T) {
|
||||
d := NewDownloader(&DownloaderConfig{Storage: NewMemStorage(), StorageDir: t.TempDir(), TempDir: t.TempDir()})
|
||||
if err := d.Setup(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer d.Clear()
|
||||
e, _ := d.newExtensionEngine()
|
||||
defer e.Close()
|
||||
e.Runtime.Set("materialized", materialized)
|
||||
result, err := e.RunString(`(()=>{
|
||||
const payload=new Uint8Array([0,1,2,3,4,5,6,7,8,9]);
|
||||
const source=materialized?new Blob([payload]):()=>new ReadableStream({start(c){c.enqueue(payload);c.close();}});
|
||||
return __gopeed_blob_create_object_url(source,{size:10});
|
||||
})()`)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
output := t.TempDir()
|
||||
id, err := d.CreateDirect(&base.Request{URL: fmt.Sprint(result)}, &base.Options{Size: size, Path: output, Name: "output.bin"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
waitForTaskTerminal(t, d, id, 5*time.Second)
|
||||
body, err := os.ReadFile(filepath.Join(output, "output.bin"))
|
||||
if err != nil || !bytes.Equal(body, []byte{0, 1, 2, 3, 4, 5, 6, 7, 8, 9}) {
|
||||
t.Fatalf("unexpected body=%v err=%v", body, err)
|
||||
}
|
||||
task := d.GetTask(id)
|
||||
if task.Meta.Res.Size != 10 || task.Meta.Res.Files[0].Size != 10 {
|
||||
t.Fatalf("Blob Content-Length did not override estimate: %+v", task.Meta.Res)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtensionFileSizeHintWaitsForEOF(t *testing.T) {
|
||||
for _, fail := range []bool{false, true} {
|
||||
for _, size := range []int64{3, 1 << 20} {
|
||||
t.Run(fmt.Sprintf("fail=%v/size=%d", fail, size), func(t *testing.T) {
|
||||
d := NewDownloader(&DownloaderConfig{Storage: NewMemStorage(), StorageDir: t.TempDir(), TempDir: t.TempDir(), RefreshInterval: 50})
|
||||
if err := d.Setup(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer d.Clear()
|
||||
e, _ := d.newExtensionEngine()
|
||||
defer e.Close()
|
||||
e.Runtime.Set("fail", fail)
|
||||
result, err := e.RunString(`__gopeed_blob_create_object_url(()=>new ReadableStream({
|
||||
start(c){c.enqueue(new Uint8Array([0,1,2,3,4,5,6,7,8,9]));globalThis.finish=()=>{if(fail)c.error(new Error('upstream failed'));else c.close();};}
|
||||
}),{})`)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
output := t.TempDir()
|
||||
id, err := d.CreateDirect(&base.Request{URL: fmt.Sprint(result)}, &base.Options{Size: size, Path: output, Name: "output.bin"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
waitForTaskDownloadedBetween(t, d, id, 10, 10, 5*time.Second)
|
||||
task := d.GetTask(id)
|
||||
task.lock.Lock()
|
||||
if task.Meta.Res.Size != size || d.taskStatus(task) != base.DownloadStatusRunning {
|
||||
t.Errorf("hint must remain visible until EOF: size=%d status=%s", task.Meta.Res.Size, d.taskStatus(task))
|
||||
}
|
||||
task.lock.Unlock()
|
||||
stat, err := os.Stat(filepath.Join(output, "output.bin"))
|
||||
if err != nil || stat.Size() != 10 {
|
||||
t.Fatalf("hint must not preallocate or truncate output: stat=%v err=%v", stat, err)
|
||||
}
|
||||
if _, err := e.RunString(`finish()`); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if fail {
|
||||
waitForTaskStatus(t, d, id, base.DownloadStatusError, 5*time.Second)
|
||||
} else {
|
||||
waitForTaskTerminal(t, d, id, 5*time.Second)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtensionFileSizeHintPauseContinue(t *testing.T) {
|
||||
for _, size := range []int64{3, 1 << 20} {
|
||||
t.Run(fmt.Sprint(size), func(t *testing.T) {
|
||||
d := NewDownloader(&DownloaderConfig{Storage: NewMemStorage(), StorageDir: t.TempDir(), TempDir: t.TempDir(), RefreshInterval: 50})
|
||||
if err := d.Setup(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer d.Clear()
|
||||
e, _ := d.newExtensionEngine()
|
||||
defer e.Close()
|
||||
result, err := e.RunString(`(()=>{
|
||||
let opens=0;
|
||||
return __gopeed_blob_create_object_url(()=>new ReadableStream({start(c){
|
||||
if (++opens===1) c.enqueue(new Uint8Array([0,1,2,3,4,5,6,7,8,9]));
|
||||
else {c.enqueue(new Uint8Array([5,6]));c.close();}
|
||||
}}),{});
|
||||
})()`)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
output := t.TempDir()
|
||||
id, err := d.CreateDirect(&base.Request{URL: fmt.Sprint(result)}, &base.Options{Size: size, Path: output, Name: "output.bin"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
waitForTaskDownloadedBetween(t, d, id, 10, 10, 5*time.Second)
|
||||
filter := &TaskFilter{IDs: []string{id}}
|
||||
if err := d.Pause(filter); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := d.Continue(filter); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
waitForTaskTerminal(t, d, id, 5*time.Second)
|
||||
body, err := os.ReadFile(filepath.Join(output, "output.bin"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
task := d.GetTask(id)
|
||||
task.lock.Lock()
|
||||
defer task.lock.Unlock()
|
||||
if !bytes.Equal(body, []byte{5, 6}) || task.Meta.Res.Size != 2 {
|
||||
t.Fatalf("resumed stream kept an estimate or stale bytes: body=%v size=%d", body, task.Meta.Res.Size)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -32,7 +32,7 @@ func TestDownloader_InstallExtensionByFolder(t *testing.T) {
|
||||
URL: "https://github.com/test",
|
||||
Labels: map[string]string{"original": "true"},
|
||||
}
|
||||
rr, err := downloader.Resolve(req, nil)
|
||||
rr, err := downloader.Resolve(req)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -58,7 +58,7 @@ func TestDownloader_InstallExtensionByFolderDevMode(t *testing.T) {
|
||||
}
|
||||
rr, err := downloader.Resolve(&base.Request{
|
||||
URL: "https://github.com/test",
|
||||
}, nil)
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -76,7 +76,7 @@ func TestDownloader_Extension_BlobBlob(t *testing.T) {
|
||||
|
||||
rr, err := downloader.Resolve(&base.Request{
|
||||
URL: "https://example.com/blob",
|
||||
}, nil)
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -130,7 +130,7 @@ func TestDownloader_Extension_BlobFunctionOpener(t *testing.T) {
|
||||
|
||||
rr, err := downloader.Resolve(&base.Request{
|
||||
URL: "https://example.com/opener",
|
||||
}, nil)
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -166,7 +166,7 @@ func TestDownloader_Extension_BlobFunctionOpenerUnknownSize(t *testing.T) {
|
||||
|
||||
rr, err := downloader.Resolve(&base.Request{
|
||||
URL: "https://example.com/opener-unknown",
|
||||
}, nil)
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -222,7 +222,7 @@ func TestDownloader_Extension_BlobSourceSizePropagatesToCreatedTask(t *testing.T
|
||||
|
||||
rr, err := downloader.Resolve(&base.Request{
|
||||
URL: "https://example.com/opener",
|
||||
}, nil)
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -303,7 +303,7 @@ func TestDownloader_Extension_BlobFetchDrainedStreamReportsEarlyProgress(t *test
|
||||
|
||||
rr, err := downloader.Resolve(&base.Request{
|
||||
URL: fmt.Sprintf("%s/single-fetch-stream?size=%d", server.URL, len(payload)),
|
||||
}, nil)
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -419,7 +419,7 @@ func TestDownloader_Extension_BlobRangeFetchDrainedStreamReportsEarlyProgress(t
|
||||
|
||||
rr, err := downloader.Resolve(&base.Request{
|
||||
URL: fmt.Sprintf("%s/range-fetch-stream?size=%d", server.URL, len(payload)),
|
||||
}, nil)
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -494,7 +494,7 @@ func TestDownloader_Extension_BlobMultiplexFetchStreamsReportEarlyProgress(t *te
|
||||
|
||||
rr, err := downloader.Resolve(&base.Request{
|
||||
URL: server.URL + "/multiplex-fetch-stream",
|
||||
}, nil)
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -550,7 +550,7 @@ func TestDownloader_Extension_BlobFunctionOpenerRangeResume(t *testing.T) {
|
||||
|
||||
rr, err := downloader.Resolve(&base.Request{
|
||||
URL: "https://example.com/opener-range",
|
||||
}, nil)
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -683,7 +683,7 @@ func TestDownloader_Extension_BlobResumeAfterRestartViaOnError(t *testing.T) {
|
||||
|
||||
rr, err := downloader.Resolve(&base.Request{
|
||||
URL: server.URL + "/restart-range",
|
||||
}, nil)
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -855,7 +855,7 @@ func TestDownloader_Extension_BlobPauseContinueKeepsSource(t *testing.T) {
|
||||
|
||||
rr, err := downloader.Resolve(&base.Request{
|
||||
URL: tc.url,
|
||||
}, nil)
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -932,7 +932,7 @@ func TestDownloader_Extension_BlobUnknownSizePauseContinueKeepsSource(t *testing
|
||||
|
||||
rr, err := downloader.Resolve(&base.Request{
|
||||
URL: "https://example.com/opener-unknown",
|
||||
}, nil)
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -1009,7 +1009,7 @@ func TestDownloader_Extension_BlobRecoverOnError(t *testing.T) {
|
||||
path := path
|
||||
t.Run(path, func(t *testing.T) {
|
||||
rawURL := "https://example.com/" + path
|
||||
rr, err := downloader.Resolve(&base.Request{URL: rawURL}, nil)
|
||||
rr, err := downloader.Resolve(&base.Request{URL: rawURL})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -1070,54 +1070,15 @@ func TestDownloader_Extension_BlobRecoverOnError(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestDownloader_InstallExtensionByGit(t *testing.T) {
|
||||
setupDownloader(func(downloader *Downloader) {
|
||||
if _, err := downloader.InstallExtensionByGit("https://github.com/GopeedLab/gopeed-extension-samples#github-release-sample"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
rr, err := downloader.Resolve(&base.Request{
|
||||
URL: "https://github.com/GopeedLab/gopeed/releases",
|
||||
}, nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(rr.Res.Files) == 1 {
|
||||
t.Fatal("resolve error")
|
||||
}
|
||||
})
|
||||
testInstallExtensionGitURL(t, "https://github.com/gopeed-test/repo#nested/extension")
|
||||
}
|
||||
|
||||
func TestDownloader_InstallExtensionByGitSimple(t *testing.T) {
|
||||
setupDownloader(func(downloader *Downloader) {
|
||||
if _, err := downloader.InstallExtensionByGit("github.com/GopeedLab/gopeed-extension-samples#github-release-sample"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
rr, err := downloader.Resolve(&base.Request{
|
||||
URL: "https://github.com/GopeedLab/gopeed/releases",
|
||||
}, nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(rr.Res.Files) == 1 {
|
||||
t.Fatal("resolve error")
|
||||
}
|
||||
})
|
||||
testInstallExtensionGitURL(t, "github.com/gopeed-test/repo#nested/extension")
|
||||
}
|
||||
|
||||
func TestDownloader_InstallExtensionByGitFull(t *testing.T) {
|
||||
setupDownloader(func(downloader *Downloader) {
|
||||
if _, err := downloader.InstallExtensionByGit("https://github.com/GopeedLab/gopeed-extension-samples.git#github-release-sample"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
rr, err := downloader.Resolve(&base.Request{
|
||||
URL: "https://github.com/GopeedLab/gopeed/releases",
|
||||
}, nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(rr.Res.Files) == 1 {
|
||||
t.Fatal("resolve error")
|
||||
}
|
||||
})
|
||||
testInstallExtensionGitURL(t, "https://github.com/gopeed-test/repo.git#nested/extension")
|
||||
}
|
||||
|
||||
func TestDownloader_UpgradeExtension(t *testing.T) {
|
||||
@@ -1180,7 +1141,7 @@ func TestDownloader_UpgradeExtension(t *testing.T) {
|
||||
|
||||
rr, err := downloader.Resolve(&base.Request{
|
||||
URL: "https://test.com",
|
||||
}, nil)
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -1355,7 +1316,7 @@ func TestDownloader_Extension_Errors(t *testing.T) {
|
||||
}
|
||||
rr, err := downloader.Resolve(&base.Request{
|
||||
URL: "https://github.com/test",
|
||||
}, nil)
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -1370,7 +1331,7 @@ func TestDownloader_Extension_Errors(t *testing.T) {
|
||||
}
|
||||
rr, err := downloader.Resolve(&base.Request{
|
||||
URL: "https://github.com/test",
|
||||
}, nil)
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -1385,7 +1346,7 @@ func TestDownloader_Extension_Errors(t *testing.T) {
|
||||
}
|
||||
_, err := downloader.Resolve(&base.Request{
|
||||
URL: "https://github.com/test",
|
||||
}, nil)
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatalf("except error, but got nil")
|
||||
}
|
||||
@@ -1407,7 +1368,7 @@ func TestDownloader_Extension_Settings(t *testing.T) {
|
||||
}
|
||||
rr, err := downloader.Resolve(&base.Request{
|
||||
URL: "https://github.com/test",
|
||||
}, nil)
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -1428,7 +1389,7 @@ func TestDownloader_Extension_Settings(t *testing.T) {
|
||||
})
|
||||
rr, err := downloader.Resolve(&base.Request{
|
||||
URL: "https://github.com/test",
|
||||
}, nil)
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -1445,7 +1406,7 @@ func TestDownloader_ExtensionStorage(t *testing.T) {
|
||||
}
|
||||
rr, err := downloader.Resolve(&base.Request{
|
||||
URL: "https://github.com/test",
|
||||
}, nil)
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
@@ -24,7 +24,7 @@ func TestExtensionUnnamedResource(t *testing.T) {
|
||||
if _, err = d.InstallExtensionByFolder(dir, true); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
res, err := d.Resolve(&base.Request{URL: "https://example.com/unnamed"}, nil)
|
||||
res, err := d.Resolve(&base.Request{URL: "https://example.com/unnamed"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,151 @@
|
||||
package download
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/GopeedLab/gopeed/internal/githubmirror"
|
||||
"github.com/GopeedLab/gopeed/internal/httpclient"
|
||||
)
|
||||
|
||||
func (d *Downloader) resourceClient(publicOnly bool) (*http.Client, error) {
|
||||
cfg, err := d.GetConfig()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
options := httpclient.Options{
|
||||
Client: httpclient.ClientOptions{Timeout: 30 * time.Second},
|
||||
Transport: httpclient.TransportOptions{Proxy: cfg.Proxy.ToHandler(), TLSHandshakeTimeout: 10 * time.Second},
|
||||
}
|
||||
if publicOnly {
|
||||
// A configured upstream proxy is trusted, but target URLs are still checked.
|
||||
options.Client.CheckRedirect = func(req *http.Request, via []*http.Request) error {
|
||||
if len(via) >= 5 {
|
||||
return errors.New("too many redirects")
|
||||
}
|
||||
return validatePublicURL(req.Context(), req.URL.String())
|
||||
}
|
||||
if cfg.Proxy == nil || !cfg.Proxy.Enable {
|
||||
options.Transport.DialContext = func(ctx context.Context, network, address string) (net.Conn, error) {
|
||||
host, port, err := net.SplitHostPort(address)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
ips, err := publicIPs(ctx, host)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var last error
|
||||
for _, ip := range ips {
|
||||
conn, err := (&net.Dialer{Timeout: 10 * time.Second}).DialContext(ctx, network, net.JoinHostPort(ip.String(), port))
|
||||
if err == nil {
|
||||
return conn, nil
|
||||
}
|
||||
last = err
|
||||
}
|
||||
return nil, last
|
||||
}
|
||||
}
|
||||
}
|
||||
return httpclient.NewClient(options)
|
||||
}
|
||||
func publicIPs(ctx context.Context, host string) ([]net.IPAddr, error) {
|
||||
ips, err := net.DefaultResolver.LookupIPAddr(ctx, host)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(ips) == 0 {
|
||||
return nil, errors.New("target has no addresses")
|
||||
}
|
||||
for _, ip := range ips {
|
||||
if !ip.IP.IsGlobalUnicast() || ip.IP.IsPrivate() || ip.IP.IsLoopback() || ip.IP.IsLinkLocalUnicast() || ip.IP.IsUnspecified() {
|
||||
return nil, errors.New("private or local resource target is not allowed")
|
||||
}
|
||||
}
|
||||
return ips, nil
|
||||
}
|
||||
func validatePublicURL(ctx context.Context, raw string) error {
|
||||
u, err := url.Parse(raw)
|
||||
if err != nil || u == nil || (u.Scheme != "https" && u.Scheme != "http") || u.Host == "" || u.User != nil {
|
||||
return errors.New("invalid resource URL")
|
||||
}
|
||||
_, err = publicIPs(ctx, u.Hostname())
|
||||
return err
|
||||
}
|
||||
func (d *Downloader) fetchPublicResource(ctx context.Context, raw string, limit int64, validate func([]byte) error) ([]byte, error) {
|
||||
return d.fetchResource(ctx, raw, limit, true, validate)
|
||||
}
|
||||
|
||||
// fetchResource races complete, validated responses. A successful HEAD alone
|
||||
// cannot win, and failed candidates never discard a usable official response.
|
||||
func (d *Downloader) fetchResource(ctx context.Context, raw string, limit int64, publicOnly bool, validate func([]byte) error) ([]byte, error) {
|
||||
cfg, err := d.GetConfig()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
client, err := d.resourceClient(publicOnly)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer client.CloseIdleConnections()
|
||||
return githubmirror.Race(ctx, cfg.GithubMirror, raw,
|
||||
githubmirror.Options[[]byte]{Kind: githubmirror.Resource, Timeout: 30 * time.Second},
|
||||
func(ctx context.Context, candidate string) ([]byte, error) {
|
||||
if publicOnly {
|
||||
if err := validatePublicURL(ctx, candidate); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
request, err := http.NewRequestWithContext(ctx, http.MethodGet, candidate, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
response, err := client.Do(request)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer response.Body.Close()
|
||||
if response.StatusCode != http.StatusOK {
|
||||
return nil, fmt.Errorf("resource returned HTTP %d", response.StatusCode)
|
||||
}
|
||||
body, err := io.ReadAll(io.LimitReader(response.Body, limit+1))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if int64(len(body)) > limit {
|
||||
return nil, errors.New("resource exceeds size limit")
|
||||
}
|
||||
if len(body) == 0 {
|
||||
return nil, errors.New("empty resource")
|
||||
}
|
||||
if validate != nil {
|
||||
if err := validate(body); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
return body, nil
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
// githubResourceCandidates accepts official GitHub HTTPS resource URLs only.
|
||||
func githubResourceCandidates(config *githubmirror.Config, raw string) ([]string, error) {
|
||||
u, err := url.Parse(raw)
|
||||
if err != nil || u == nil || u.Scheme != "https" || u.User != nil || u.Port() != "" || u.Fragment != "" || strings.Contains(raw, "#") {
|
||||
return nil, ErrInvalidMirrorURL
|
||||
}
|
||||
u.Host = strings.ToLower(u.Host)
|
||||
switch u.Host {
|
||||
case "github.com", "raw.githubusercontent.com":
|
||||
default:
|
||||
return nil, ErrInvalidMirrorURL
|
||||
}
|
||||
return githubmirror.Candidates(config, u.String(), githubmirror.Resource), nil
|
||||
}
|
||||
@@ -40,7 +40,7 @@ func TestDownloader_HlsLiveSite(t *testing.T) {
|
||||
if referer != "" {
|
||||
req.Extra = map[string]any{"header": map[string]string{"Referer": referer}}
|
||||
}
|
||||
rr, err := downloader.Resolve(req, nil)
|
||||
rr, err := downloader.Resolve(req)
|
||||
if err != nil {
|
||||
t.Fatalf("resolve: %v", err)
|
||||
}
|
||||
|
||||
@@ -89,7 +89,7 @@ func TestDownloader_HlsProtocolEndToEnd(t *testing.T) {
|
||||
|
||||
req := hlsResolveReferer()
|
||||
req.URL = server.URL + "/master.m3u8"
|
||||
rr, err := downloader.Resolve(req, nil)
|
||||
rr, err := downloader.Resolve(req)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
package download
|
||||
|
||||
import (
|
||||
"errors"
|
||||
|
||||
"github.com/GopeedLab/gopeed/pkg/base"
|
||||
)
|
||||
|
||||
var (
|
||||
ErrMirrorProviderNotFound = errors.New("mirror provider not found")
|
||||
ErrInvalidMirrorURL = errors.New("invalid mirror resource URL")
|
||||
)
|
||||
|
||||
// Each provider owns URL validation and candidate generation using its own
|
||||
// configuration. Providers only generate URLs; they must not fetch resources.
|
||||
type mirrorCandidateProvider func(config *base.DownloaderStoreConfig, raw string) ([]string, error)
|
||||
|
||||
// Add providers here without changing the public API or its client transport.
|
||||
var mirrorCandidateProviders = map[string]mirrorCandidateProvider{
|
||||
"github": func(config *base.DownloaderStoreConfig, raw string) ([]string, error) {
|
||||
return githubResourceCandidates(config.GithubMirror, raw)
|
||||
},
|
||||
}
|
||||
|
||||
func (d *Downloader) GetMirrorCandidates(provider, raw string) ([]string, error) {
|
||||
candidates, ok := mirrorCandidateProviders[provider]
|
||||
if !ok {
|
||||
return nil, ErrMirrorProviderNotFound
|
||||
}
|
||||
config, err := d.GetConfig()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return candidates(config, raw)
|
||||
}
|
||||
@@ -21,6 +21,11 @@ import (
|
||||
type ResolveResult struct {
|
||||
ID string `json:"id"`
|
||||
Res *base.Resource `json:"res"`
|
||||
// SuggestedDownloadDir is the category directory the created task would be
|
||||
// routed to. It is only set when auto categorize is enabled, the resource is
|
||||
// a single file, a category matches and its directory passes the white list
|
||||
// check. It is a hint for the ui, the create call decides the final path.
|
||||
SuggestedDownloadDir string `json:"suggestedDownloadDir,omitempty"`
|
||||
}
|
||||
|
||||
// Task represents a download unit.
|
||||
@@ -39,6 +44,14 @@ type Task struct {
|
||||
// finished. A done task never leaves the done state, so the value is
|
||||
// written once and kept.
|
||||
DoneAt *time.Time `json:"doneAt"`
|
||||
// NeedsPreparation marks a task that has not run its first preparation
|
||||
// yet. The preparation decides the download directory of the task and
|
||||
// resolves the duplicate name in it. It runs once, after the resource is
|
||||
// resolved, and the flag is cleared afterwards, so a queued, paused or
|
||||
// retried task is still prepared while a prepared task is never
|
||||
// classified or renamed again. A task stored before this flag existed
|
||||
// does not have it and keeps its directory and name.
|
||||
NeedsPreparation bool `json:"needsPreparation,omitempty"`
|
||||
|
||||
producer production.Source
|
||||
producerReceived int64
|
||||
|
||||
@@ -0,0 +1,172 @@
|
||||
package download
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"reflect"
|
||||
"strings"
|
||||
|
||||
"github.com/GopeedLab/gopeed/internal/fetcher"
|
||||
"github.com/GopeedLab/gopeed/pkg/util"
|
||||
)
|
||||
|
||||
func (d *Downloader) protocolManager(name string) (fetcher.FetcherManager, error) {
|
||||
for _, fm := range d.cfg.FetchManagers {
|
||||
if fm.Name() == name {
|
||||
return fm, nil
|
||||
}
|
||||
}
|
||||
return nil, fmt.Errorf("%w: %s", ErrUnSupportedProtocol, name)
|
||||
}
|
||||
func jsonObject(v any) (map[string]json.RawMessage, error) {
|
||||
raw, err := json.Marshal(v)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var object map[string]json.RawMessage
|
||||
if err = json.Unmarshal(raw, &object); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if object == nil {
|
||||
object = map[string]json.RawMessage{}
|
||||
}
|
||||
return object, nil
|
||||
}
|
||||
func normalizeProtocol(fm fetcher.FetcherManager, v any) (any, error) {
|
||||
raw, err := json.Marshal(v)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
target := fm.DefaultConfig()
|
||||
if target == nil {
|
||||
return nil, errors.New("protocol does not expose configuration")
|
||||
}
|
||||
// Managers may return either typed pointers or value/map defaults.
|
||||
valueDefault := reflect.TypeOf(target).Kind() != reflect.Pointer
|
||||
if valueDefault {
|
||||
pointer := reflect.New(reflect.TypeOf(target))
|
||||
defaults, err := json.Marshal(target)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err = json.Unmarshal(defaults, pointer.Interface()); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
target = pointer.Interface()
|
||||
}
|
||||
if err = json.Unmarshal(raw, target); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if normalizer, ok := fm.(fetcher.ConfigNormalizer); ok {
|
||||
raw, err = json.Marshal(target)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return normalizer.NormalizeConfig(raw)
|
||||
}
|
||||
if valueDefault {
|
||||
return reflect.ValueOf(target).Elem().Interface(), nil
|
||||
}
|
||||
return target, nil
|
||||
}
|
||||
|
||||
// preserveProtocolState ignores all client-supplied state, including null or
|
||||
// malformed state values, before decoding/validating the writable config.
|
||||
func preserveProtocolState(next, previous any) (any, error) {
|
||||
object, err := jsonObject(next)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
before, err := jsonObject(previous)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// Match encoding/json field folding as well as the canonical spelling.
|
||||
for key := range object {
|
||||
if strings.EqualFold(key, "state") {
|
||||
delete(object, key)
|
||||
}
|
||||
}
|
||||
if state, ok := before["state"]; ok {
|
||||
object["state"] = state
|
||||
}
|
||||
return object, nil
|
||||
}
|
||||
func (d *Downloader) readProtocolConfig(name string) (any, error) {
|
||||
if _, err := d.protocolManager(name); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
d.configLock.RLock()
|
||||
defer d.configLock.RUnlock()
|
||||
value := d.cfg.ProtocolConfig[name]
|
||||
return *util.DeepClone(&value), nil
|
||||
}
|
||||
|
||||
// updateProtocolState is the only internal write path for backend-owned state.
|
||||
// It never publishes callback edits outside the reserved state section.
|
||||
func (d *Downloader) updateProtocolState(name string, change func(config, state json.RawMessage) (any, error)) error {
|
||||
fm, err := d.protocolManager(name)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
d.configLock.Lock()
|
||||
defer d.configLock.Unlock()
|
||||
if d.closed.Load() {
|
||||
return errors.New("downloader is closed")
|
||||
}
|
||||
next := util.DeepClone(d.cfg.DownloaderStoreConfig)
|
||||
object, err := jsonObject(next.ProtocolConfig[name])
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
state, ok := object["state"]
|
||||
if !ok {
|
||||
return fmt.Errorf("protocol %s does not expose persistent state", name)
|
||||
}
|
||||
raw, err := json.Marshal(object)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
value, err := change(raw, state)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
// Keep the transaction's original config isolated from callback mutations.
|
||||
object["state"], err = json.Marshal(value)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
normalized, err := normalizeProtocol(fm, object)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
next.ProtocolConfig[name] = normalized
|
||||
if err = d.storage.Put(bucketConfig, "config", next); err != nil {
|
||||
return err
|
||||
}
|
||||
d.cfg.DownloaderStoreConfig.ProtocolConfig = next.ProtocolConfig
|
||||
return nil
|
||||
}
|
||||
|
||||
// initProtocolConfigs loads the current schema and initializes missing defaults.
|
||||
// This release intentionally does not import settings from older config layouts.
|
||||
func (d *Downloader) initProtocolConfigs() error {
|
||||
cfg := d.cfg.DownloaderStoreConfig
|
||||
cfg.Init()
|
||||
if err := cfg.GithubMirror.Validate(); err != nil {
|
||||
return err
|
||||
}
|
||||
for _, fm := range d.cfg.FetchManagers {
|
||||
value, ok := cfg.ProtocolConfig[fm.Name()]
|
||||
if !ok {
|
||||
value = map[string]any{}
|
||||
}
|
||||
normalized, err := normalizeProtocol(fm, value)
|
||||
if err != nil {
|
||||
return fmt.Errorf("%s config: %w", fm.Name(), err)
|
||||
}
|
||||
cfg.ProtocolConfig[fm.Name()] = normalized
|
||||
}
|
||||
return d.storage.Put(bucketConfig, "config", cfg)
|
||||
}
|
||||
@@ -0,0 +1,250 @@
|
||||
package download
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"regexp"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
"github.com/GopeedLab/gopeed/internal/fetcher"
|
||||
"github.com/GopeedLab/gopeed/pkg/util"
|
||||
)
|
||||
|
||||
var ErrProtocolJobNotFound = errors.New("protocol job not found")
|
||||
var jobNamePattern = regexp.MustCompile(`^[a-z][a-z0-9-]*$`)
|
||||
|
||||
type protocolJobState struct {
|
||||
LastSuccess time.Time `json:"lastSuccess"`
|
||||
Failures int `json:"failures"`
|
||||
}
|
||||
type protocolJobRun struct {
|
||||
done chan struct{}
|
||||
err error
|
||||
source fetcher.TriggerSource
|
||||
}
|
||||
type registeredJob struct {
|
||||
protocol string
|
||||
spec fetcher.CronJob
|
||||
mu sync.Mutex
|
||||
state protocolJobState
|
||||
active *protocolJobRun
|
||||
next time.Time
|
||||
wake chan struct{}
|
||||
force atomic.Bool
|
||||
}
|
||||
type protocolScheduler struct {
|
||||
d *Downloader
|
||||
jobs map[string]*registeredJob
|
||||
wg sync.WaitGroup
|
||||
// gate prevents adding work while shutdown waits for it.
|
||||
gate sync.Mutex
|
||||
stopped bool
|
||||
}
|
||||
|
||||
func (d *Downloader) setupProtocolJobs() error {
|
||||
scheduler := &protocolScheduler{d: d, jobs: map[string]*registeredJob{}}
|
||||
for _, fm := range d.cfg.FetchManagers {
|
||||
provider, ok := fm.(fetcher.CronProvider)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
name := fm.Name()
|
||||
host := fetcher.ProtocolHost{
|
||||
Read: func(dst any) error {
|
||||
value, err := d.readProtocolConfig(name)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return util.MapToStruct(value, dst)
|
||||
},
|
||||
UpdateState: func(change func(config, state json.RawMessage) (any, error)) error {
|
||||
return d.updateProtocolState(name, change)
|
||||
},
|
||||
Fetch: func(ctx context.Context, raw string, limit int64, validate func([]byte) error) ([]byte, error) {
|
||||
return d.fetchPublicResource(ctx, raw, limit, validate)
|
||||
},
|
||||
}
|
||||
for _, spec := range provider.CronJobs(host) {
|
||||
if !jobNamePattern.MatchString(spec.Name) || spec.Every <= 0 || spec.Timeout <= 0 || spec.Run == nil {
|
||||
return fmt.Errorf("invalid protocol job: %s/%s", name, spec.Name)
|
||||
}
|
||||
key := name + "/" + spec.Name
|
||||
if _, ok := scheduler.jobs[key]; ok {
|
||||
return fmt.Errorf("duplicate protocol job: %s", key)
|
||||
}
|
||||
entry := ®isteredJob{protocol: name, spec: spec, wake: make(chan struct{}, 1)}
|
||||
if _, err := d.storage.Get(bucketProtocolJobs, key, &entry.state); err != nil {
|
||||
return err
|
||||
}
|
||||
scheduler.jobs[key] = entry
|
||||
}
|
||||
}
|
||||
d.protocolJobs = scheduler
|
||||
return nil
|
||||
}
|
||||
func (s *protocolScheduler) start() {
|
||||
for key, entry := range s.jobs {
|
||||
s.wg.Add(1)
|
||||
go s.loop(key, entry)
|
||||
}
|
||||
}
|
||||
func (s *protocolScheduler) signal(entry *registeredJob) {
|
||||
select {
|
||||
case entry.wake <- struct{}{}:
|
||||
default:
|
||||
}
|
||||
}
|
||||
func (s *protocolScheduler) changed(protocol string) {
|
||||
for _, entry := range s.jobs {
|
||||
if protocol == "" || entry.protocol == protocol {
|
||||
entry.force.Store(true)
|
||||
s.signal(entry)
|
||||
}
|
||||
}
|
||||
}
|
||||
func (s *protocolScheduler) loop(key string, entry *registeredJob) {
|
||||
defer s.wg.Done()
|
||||
entry.mu.Lock()
|
||||
if entry.next.IsZero() {
|
||||
entry.next = entry.state.LastSuccess.Add(entry.spec.Every)
|
||||
if entry.next.Before(time.Now()) {
|
||||
entry.next = time.Now().Add(time.Second)
|
||||
}
|
||||
}
|
||||
next := entry.next
|
||||
entry.mu.Unlock()
|
||||
timer := time.NewTimer(time.Until(next))
|
||||
defer timer.Stop()
|
||||
for {
|
||||
select {
|
||||
case <-s.d.backgroundCtx.Done():
|
||||
return
|
||||
case <-entry.wake:
|
||||
entry.mu.Lock()
|
||||
next = entry.next
|
||||
entry.mu.Unlock()
|
||||
if entry.force.Swap(false) {
|
||||
next = time.Now()
|
||||
}
|
||||
case <-timer.C:
|
||||
_ = s.trigger(s.d.backgroundCtx, key, fetcher.TriggerScheduled)
|
||||
entry.mu.Lock()
|
||||
next = entry.next
|
||||
entry.mu.Unlock()
|
||||
}
|
||||
|
||||
if !timer.Stop() {
|
||||
select {
|
||||
case <-timer.C:
|
||||
default:
|
||||
}
|
||||
}
|
||||
timer.Reset(max(time.Until(next), time.Millisecond))
|
||||
}
|
||||
}
|
||||
func (s *protocolScheduler) trigger(ctx context.Context, key string, source fetcher.TriggerSource) error {
|
||||
entry, ok := s.jobs[key]
|
||||
if !ok {
|
||||
return fmt.Errorf("%w: %s", ErrProtocolJobNotFound, key)
|
||||
}
|
||||
for {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return err
|
||||
}
|
||||
s.gate.Lock()
|
||||
if s.stopped || s.d.closed.Load() {
|
||||
s.gate.Unlock()
|
||||
return errors.New("downloader is closed")
|
||||
}
|
||||
entry.mu.Lock()
|
||||
run := entry.active
|
||||
if run == nil {
|
||||
run = &protocolJobRun{done: make(chan struct{}), source: source}
|
||||
entry.active = run
|
||||
s.wg.Add(1)
|
||||
go s.execute(key, entry, run, source)
|
||||
}
|
||||
entry.mu.Unlock()
|
||||
s.gate.Unlock()
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return ctx.Err()
|
||||
case <-run.done:
|
||||
// A manual request must still run when the scheduled attempt it
|
||||
// joined was skipped (for example, automatic refresh is disabled).
|
||||
if source == fetcher.TriggerManual && run.source == fetcher.TriggerScheduled && errors.Is(run.err, fetcher.ErrJobSkipped) {
|
||||
continue
|
||||
}
|
||||
return run.err
|
||||
}
|
||||
}
|
||||
}
|
||||
func (s *protocolScheduler) execute(key string, entry *registeredJob, run *protocolJobRun, source fetcher.TriggerSource) {
|
||||
defer s.wg.Done()
|
||||
ctx, cancel := context.WithTimeout(s.d.backgroundCtx, entry.spec.Timeout)
|
||||
defer cancel()
|
||||
err := func() (err error) {
|
||||
defer func() {
|
||||
if v := recover(); v != nil {
|
||||
err = fmt.Errorf("protocol job panic: %v", v)
|
||||
}
|
||||
}()
|
||||
return entry.spec.Run(ctx, source)
|
||||
}()
|
||||
if err == nil {
|
||||
err = ctx.Err()
|
||||
}
|
||||
entry.mu.Lock()
|
||||
switch {
|
||||
case err == nil:
|
||||
entry.state.LastSuccess = time.Now().UTC()
|
||||
entry.state.Failures = 0
|
||||
case errors.Is(err, fetcher.ErrJobSkipped), errors.Is(err, fetcher.ErrConfigChanged), errors.Is(err, context.Canceled):
|
||||
default:
|
||||
entry.state.Failures++
|
||||
}
|
||||
if storeErr := s.d.storage.Put(bucketProtocolJobs, key, entry.state); storeErr != nil {
|
||||
err = errors.Join(err, storeErr)
|
||||
}
|
||||
switch {
|
||||
case err == nil:
|
||||
entry.next = entry.state.LastSuccess.Add(entry.spec.Every)
|
||||
case errors.Is(err, fetcher.ErrJobSkipped):
|
||||
entry.next = time.Now().Add(entry.spec.Every)
|
||||
case errors.Is(err, fetcher.ErrConfigChanged):
|
||||
entry.next = time.Now().Add(time.Second)
|
||||
default:
|
||||
retry := time.Minute * time.Duration(1<<min(max(entry.state.Failures-1, 0), 6))
|
||||
entry.next = time.Now().Add(retry)
|
||||
}
|
||||
run.err = err
|
||||
entry.active = nil
|
||||
close(run.done)
|
||||
entry.mu.Unlock()
|
||||
if err != nil && !errors.Is(err, fetcher.ErrJobSkipped) && !errors.Is(err, context.Canceled) {
|
||||
s.d.Logger.Warn().Err(err).Str("job", key).Str("source", string(source)).Msg("protocol job failed")
|
||||
}
|
||||
if source == fetcher.TriggerManual {
|
||||
s.signal(entry)
|
||||
}
|
||||
}
|
||||
func (s *protocolScheduler) close() {
|
||||
s.gate.Lock()
|
||||
s.stopped = true
|
||||
s.gate.Unlock()
|
||||
s.wg.Wait()
|
||||
}
|
||||
func (d *Downloader) TriggerProtocolJob(ctx context.Context, protocol, name string) error {
|
||||
if d.protocolJobs == nil {
|
||||
return ErrProtocolJobNotFound
|
||||
}
|
||||
err := d.protocolJobs.trigger(ctx, protocol+"/"+name, fetcher.TriggerManual)
|
||||
if errors.Is(err, fetcher.ErrJobSkipped) {
|
||||
return nil
|
||||
}
|
||||
return err
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
package download
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/GopeedLab/gopeed/internal/fetcher"
|
||||
)
|
||||
|
||||
type jobWaitContext struct {
|
||||
context.Context
|
||||
waiting chan struct{}
|
||||
once sync.Once
|
||||
}
|
||||
|
||||
func (c *jobWaitContext) Done() <-chan struct{} {
|
||||
c.once.Do(func() { close(c.waiting) })
|
||||
return c.Context.Done()
|
||||
}
|
||||
|
||||
func TestManualProtocolJobRunsAfterJoinedScheduledSkip(t *testing.T) {
|
||||
entered := make(chan struct{})
|
||||
release := make(chan struct{})
|
||||
var manualCalls atomic.Int32
|
||||
manager := &testJobManager{job: fetcher.CronJob{Name: "refresh", Every: time.Hour, Timeout: time.Second,
|
||||
Run: func(ctx context.Context, source fetcher.TriggerSource) error {
|
||||
if source == fetcher.TriggerManual {
|
||||
manualCalls.Add(1)
|
||||
return nil
|
||||
}
|
||||
close(entered)
|
||||
select {
|
||||
case <-release:
|
||||
return fetcher.ErrJobSkipped
|
||||
case <-ctx.Done():
|
||||
return ctx.Err()
|
||||
}
|
||||
},
|
||||
}}
|
||||
d := testBackendDownloader(t, manager)
|
||||
scheduled := make(chan error, 1)
|
||||
go func() {
|
||||
scheduled <- d.protocolJobs.trigger(context.Background(), "test/refresh", fetcher.TriggerScheduled)
|
||||
}()
|
||||
<-entered
|
||||
ctx, cancel := context.WithTimeout(context.Background(), time.Second)
|
||||
defer cancel()
|
||||
waiter := &jobWaitContext{Context: ctx, waiting: make(chan struct{})}
|
||||
manual := make(chan error, 1)
|
||||
go func() { manual <- d.TriggerProtocolJob(waiter, "test", "refresh") }()
|
||||
<-waiter.waiting
|
||||
close(release)
|
||||
if err := <-scheduled; !errors.Is(err, fetcher.ErrJobSkipped) {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := <-manual; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if manualCalls.Load() != 1 {
|
||||
t.Fatal("manual refresh was swallowed by skipped automatic work")
|
||||
}
|
||||
}
|
||||
|
||||
func TestManualProtocolJobFailureKeepsScheduledBackoff(t *testing.T) {
|
||||
var calls atomic.Int32
|
||||
failure := errors.New("offline")
|
||||
manager := &testJobManager{job: fetcher.CronJob{Name: "refresh", Every: time.Hour, Timeout: time.Second,
|
||||
Run: func(context.Context, fetcher.TriggerSource) error { calls.Add(1); return failure },
|
||||
}}
|
||||
d := testBackendDownloader(t, manager)
|
||||
if err := d.TriggerProtocolJob(context.Background(), "test", "refresh"); !errors.Is(err, failure) {
|
||||
t.Fatal(err)
|
||||
}
|
||||
entry := d.protocolJobs.jobs["test/refresh"]
|
||||
entry.mu.Lock()
|
||||
next := entry.next
|
||||
entry.mu.Unlock()
|
||||
if time.Until(next) < 59*time.Second {
|
||||
t.Fatal("manual failure did not schedule retry backoff")
|
||||
}
|
||||
time.Sleep(50 * time.Millisecond)
|
||||
if calls.Load() != 1 {
|
||||
t.Fatalf("manual failure immediately retried %d times", calls.Load())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,340 @@
|
||||
package download
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"reflect"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/GopeedLab/gopeed/internal/fetcher"
|
||||
btProtocol "github.com/GopeedLab/gopeed/internal/protocol/bt"
|
||||
httpProtocol "github.com/GopeedLab/gopeed/internal/protocol/http"
|
||||
"github.com/GopeedLab/gopeed/pkg/base"
|
||||
)
|
||||
|
||||
// State tests initialize config without starting network clients or cron loops.
|
||||
func protocolConfigDownloader(t *testing.T, storage Storage, managers ...fetcher.FetcherManager) *Downloader {
|
||||
t.Helper()
|
||||
if storage == nil {
|
||||
storage = NewMemStorage()
|
||||
}
|
||||
d := NewDownloader(&DownloaderConfig{Storage: storage, StorageDir: t.TempDir(), FetchManagers: managers})
|
||||
d.cfg.DownloaderStoreConfig = &base.DownloaderStoreConfig{}
|
||||
if err := storage.Setup([]string{bucketConfig}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { d.cancelBackground(); _ = storage.Close() })
|
||||
if err := d.initProtocolConfigs(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return d
|
||||
}
|
||||
func protocolObject(t *testing.T, d *Downloader, name string) map[string]any {
|
||||
t.Helper()
|
||||
cfg, err := d.GetConfig()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
raw, err := json.Marshal(cfg.ProtocolConfig[name])
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var result map[string]any
|
||||
if err := json.Unmarshal(raw, &result); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return result
|
||||
}
|
||||
func configureBT(t *testing.T, d *Downloader, urls ...string) {
|
||||
t.Helper()
|
||||
cfg, _ := d.GetConfig()
|
||||
cfg.ProtocolConfig["bt"] = map[string]any{"autoUpdateTrackers": false, "trackerSubscribeUrls": urls, "customTrackers": []string{"udp://custom.example:80/announce"}}
|
||||
if err := d.PutConfig(cfg); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
func seedBTState(t *testing.T, d *Downloader) {
|
||||
t.Helper()
|
||||
if err := d.updateProtocolState("bt", func(_, _ json.RawMessage) (any, error) {
|
||||
return map[string]any{
|
||||
"trackerSources": map[string][]string{"https://example.com/list": {"udp://cached.example:80/announce"}},
|
||||
"trackerSubscriptionStatuses": map[string]any{"https://example.com/list": map[string]any{"success": true, "trackerCount": 1, "lastFetchTime": time.Date(2026, 10, 19, 1, 2, 3, 0, time.UTC)}},
|
||||
"lastTrackerUpdateTime": time.Date(2026, 10, 19, 1, 2, 3, 0, time.UTC),
|
||||
}, nil
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFullConfigValidatesProtocolsAndPreservesExplicitValues(t *testing.T) {
|
||||
d := protocolConfigDownloader(t, nil, &httpProtocol.FetcherManager{}, &btProtocol.FetcherManager{})
|
||||
configureBT(t, d)
|
||||
cfg, _ := d.GetConfig()
|
||||
cfg.ProtocolConfig["http"] = map[string]any{"connections": 0, "useServerCtime": false}
|
||||
if err := d.PutConfig(cfg); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got := protocolObject(t, d, "http")
|
||||
if got["connections"] != float64(0) || got["useServerCtime"] != false {
|
||||
t.Fatal(got)
|
||||
}
|
||||
for _, invalid := range []any{map[string]any{"connections": "bad"}, map[string]any{"useServerCtime": "bad"}} {
|
||||
cfg, _ := d.GetConfig()
|
||||
cfg.ProtocolConfig["http"] = invalid
|
||||
if err := d.PutConfig(cfg); err == nil {
|
||||
t.Fatalf("invalid protocol config accepted: %v", invalid)
|
||||
}
|
||||
}
|
||||
cfg, _ = d.GetConfig()
|
||||
cfg.DownloadDir = "must-not-publish"
|
||||
cfg.ProtocolConfig["bt"] = map[string]any{"listenPort": -1}
|
||||
if err := d.PutConfig(cfg); err == nil {
|
||||
t.Fatal("invalid BT config accepted")
|
||||
}
|
||||
saved, _ := d.GetConfig()
|
||||
if saved.DownloadDir == "must-not-publish" {
|
||||
t.Fatal("invalid full PUT partially published")
|
||||
}
|
||||
cfg, _ = d.GetConfig()
|
||||
cfg.ProtocolConfig["unknown"] = map[string]any{"state": map[string]any{"injected": true}}
|
||||
if err := d.PutConfig(cfg); err == nil {
|
||||
t.Fatal("unregistered protocol accepted")
|
||||
}
|
||||
// Returned configs are detached snapshots.
|
||||
snapshot, _ := d.GetConfig()
|
||||
snapshot.ProtocolConfig["http"] = map[string]any{"connections": 999}
|
||||
if !reflect.DeepEqual(protocolObject(t, d, "http"), got) {
|
||||
t.Fatal("snapshot changed live config")
|
||||
}
|
||||
}
|
||||
|
||||
func TestProtocolConfigIgnoresUnknownFields(t *testing.T) {
|
||||
for _, mode := range []string{"stored", "put"} {
|
||||
t.Run(mode, func(t *testing.T) {
|
||||
d := protocolConfigDownloader(t, nil, &btProtocol.FetcherManager{})
|
||||
cfg, _ := d.GetConfig()
|
||||
cfg.ProtocolConfig["bt"] = map[string]any{
|
||||
"listenPort": 6881,
|
||||
"autoUpdateTrackers": false,
|
||||
"trackers": []string{"udp://obsolete.example:80/announce"},
|
||||
"state": map[string]any{"obsolete": true},
|
||||
}
|
||||
if mode == "stored" {
|
||||
if err := d.storage.Put(bucketConfig, "config", cfg); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var restored base.DownloaderStoreConfig
|
||||
if exists, err := d.storage.Get(bucketConfig, "config", &restored); err != nil || !exists {
|
||||
t.Fatalf("load stored config: %v %v", exists, err)
|
||||
}
|
||||
d.cfg.DownloaderStoreConfig = &restored
|
||||
if err := d.initProtocolConfigs(); err != nil {
|
||||
t.Fatalf("load config containing removed fields: %v", err)
|
||||
}
|
||||
} else if err := d.PutConfig(cfg); err != nil {
|
||||
t.Fatalf("save config containing unknown fields: %v", err)
|
||||
}
|
||||
actual := protocolObject(t, d, "bt")
|
||||
if actual["listenPort"] != float64(6881) || actual["autoUpdateTrackers"] != false {
|
||||
t.Fatalf("known fields changed: %v", actual)
|
||||
}
|
||||
if _, exists := actual["trackers"]; exists {
|
||||
t.Fatal("removed field retained")
|
||||
}
|
||||
if len(actual["customTrackers"].([]any)) != 0 {
|
||||
t.Fatal("removed trackers migrated into custom trackers")
|
||||
}
|
||||
if _, exists := actual["state"].(map[string]any)["obsolete"]; exists {
|
||||
t.Fatal("unknown state field retained")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestBTStateSurvivesOmittedNullForgedAndStaleFullConfig(t *testing.T) {
|
||||
for _, mode := range []string{"omitted", "null", "forged", "wrong-type", "stale", "omitted-protocol", "omitted-timestamp", "case-variant", "unicode-variant"} {
|
||||
t.Run(mode, func(t *testing.T) {
|
||||
d := protocolConfigDownloader(t, nil, &btProtocol.FetcherManager{})
|
||||
configureBT(t, d, "https://example.com/list")
|
||||
stale, _ := d.GetConfig()
|
||||
seedBTState(t, d)
|
||||
expected := protocolObject(t, d, "bt")["state"]
|
||||
cfg, _ := d.GetConfig()
|
||||
bt := protocolObject(t, d, "bt")
|
||||
switch mode {
|
||||
case "omitted":
|
||||
delete(bt, "state")
|
||||
case "null":
|
||||
bt["state"] = nil
|
||||
case "forged":
|
||||
bt["state"] = map[string]any{"lastTrackerUpdateTime": "2000-01-01T00:00:00Z", "trackerSources": map[string]any{}, "unknown": true}
|
||||
case "wrong-type":
|
||||
bt["state"] = "ignore this before decoding"
|
||||
case "stale":
|
||||
cfg = stale
|
||||
bt = map[string]any{"autoUpdateTrackers": false, "trackerSubscribeUrls": []string{"https://example.com/list"}, "customTrackers": []string{"udp://custom.example:80/announce"}, "state": map[string]any{}}
|
||||
case "omitted-protocol":
|
||||
delete(cfg.ProtocolConfig, "bt")
|
||||
case "case-variant":
|
||||
bt["STATE"] = "ignore this alias"
|
||||
case "unicode-variant":
|
||||
bt["ſtate"] = "ignore Unicode case folding"
|
||||
case "omitted-timestamp":
|
||||
bt["state"] = map[string]any{"trackerSources": map[string]any{}}
|
||||
}
|
||||
if mode != "omitted-protocol" {
|
||||
bt["listenPort"] = 6881
|
||||
cfg.ProtocolConfig["bt"] = bt
|
||||
}
|
||||
if err := d.PutConfig(cfg); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
actual := protocolObject(t, d, "bt")
|
||||
if !reflect.DeepEqual(actual["state"], expected) {
|
||||
t.Fatalf("backend state replaced: %v", actual)
|
||||
}
|
||||
if _, exists := actual["trackers"]; exists {
|
||||
t.Fatal("derived tracker list persisted")
|
||||
}
|
||||
if mode != "omitted-protocol" && actual["listenPort"] != float64(6881) {
|
||||
t.Fatal("writable config did not change")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestBTConfigRemovalPrunesOnlyRemovedState(t *testing.T) {
|
||||
d := protocolConfigDownloader(t, nil, &btProtocol.FetcherManager{})
|
||||
configureBT(t, d, "https://example.com/list")
|
||||
seedBTState(t, d)
|
||||
configureBT(t, d, "https://new.example/list")
|
||||
state := protocolObject(t, d, "bt")["state"].(map[string]any)
|
||||
if len(state["trackerSources"].(map[string]any)) != 0 || len(state["trackerSubscriptionStatuses"].(map[string]any)) != 0 {
|
||||
t.Fatal("removed subscription retained state")
|
||||
}
|
||||
}
|
||||
|
||||
type counterState struct {
|
||||
Count int `json:"count"`
|
||||
Other int `json:"other"`
|
||||
}
|
||||
type counterConfig struct {
|
||||
fetcher.ConfigState[counterState]
|
||||
Writable int `json:"writable"`
|
||||
}
|
||||
type counterManager struct{ httpProtocol.FetcherManager }
|
||||
|
||||
func (*counterManager) Name() string { return "counter" }
|
||||
func (*counterManager) DefaultConfig() any { return &counterConfig{Writable: 7} }
|
||||
func counterHost(d *Downloader) fetcher.ProtocolHost {
|
||||
return fetcher.ProtocolHost{UpdateState: func(change func(config, state json.RawMessage) (any, error)) error {
|
||||
return d.updateProtocolState("counter", change)
|
||||
}}
|
||||
}
|
||||
|
||||
func TestProtocolUpdateStateCommitsOnlyStateAndUsesLatestConfig(t *testing.T) {
|
||||
d := protocolConfigDownloader(t, nil, &counterManager{})
|
||||
host := counterHost(d)
|
||||
var wg sync.WaitGroup
|
||||
for i := 0; i < 20; i++ {
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
if err := fetcher.UpdateState[counterConfig, counterState](host, func(cfg *counterConfig, state *counterState) error { cfg.Writable = 999; state.Count++; return nil }); err != nil {
|
||||
t.Error(err)
|
||||
}
|
||||
}()
|
||||
}
|
||||
wg.Wait()
|
||||
actual := protocolObject(t, d, "counter")
|
||||
if actual["writable"] != float64(7) || actual["state"].(map[string]any)["count"] != float64(20) {
|
||||
t.Fatal(actual)
|
||||
}
|
||||
// Rejecting a callback must not publish its partial mutation.
|
||||
err := fetcher.UpdateState[counterConfig, counterState](host, func(_ *counterConfig, state *counterState) error { state.Count = 1000; return errors.New("reject") })
|
||||
if err == nil || !reflect.DeepEqual(protocolObject(t, d, "counter"), actual) {
|
||||
t.Fatal("failed callback published state")
|
||||
}
|
||||
if err := d.updateProtocolState("counter", func(_, _ json.RawMessage) (any, error) { return map[string]any{"count": "bad"}, nil }); err == nil {
|
||||
t.Fatal("invalid state accepted")
|
||||
}
|
||||
}
|
||||
|
||||
func TestProtocolStateAndFullPutDoNotLoseConcurrentUpdates(t *testing.T) {
|
||||
d := protocolConfigDownloader(t, nil, &counterManager{})
|
||||
snapshot, _ := d.GetConfig()
|
||||
snapshot.ProtocolConfig["counter"] = map[string]any{"writable": 21}
|
||||
var wg sync.WaitGroup
|
||||
for i := 0; i < 20; i++ {
|
||||
wg.Add(2)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
if err := d.PutConfig(snapshot); err != nil {
|
||||
t.Error(err)
|
||||
}
|
||||
}()
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
if err := fetcher.UpdateState[counterConfig, counterState](counterHost(d), func(cfg *counterConfig, state *counterState) error { state.Count++; return nil }); err != nil {
|
||||
t.Error(err)
|
||||
}
|
||||
}()
|
||||
}
|
||||
wg.Wait()
|
||||
actual := protocolObject(t, d, "counter")
|
||||
if actual["writable"] != float64(21) || actual["state"].(map[string]any)["count"] != float64(20) {
|
||||
t.Fatal(actual)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBTSubscriptionStatusesSurviveStorageRestart(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
storage := NewBoltStorage(dir)
|
||||
d := protocolConfigDownloader(t, storage, &btProtocol.FetcherManager{})
|
||||
configureBT(t, d, "https://example.com/list")
|
||||
seedBTState(t, d)
|
||||
expected := protocolObject(t, d, "bt")["state"]
|
||||
if err := storage.Close(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
reopened := NewBoltStorage(dir)
|
||||
if err := reopened.Setup([]string{bucketConfig}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer reopened.Close()
|
||||
var persisted base.DownloaderStoreConfig
|
||||
if exists, err := reopened.Get(bucketConfig, "config", &persisted); err != nil || !exists {
|
||||
t.Fatalf("reload persisted config: %v %v", exists, err)
|
||||
}
|
||||
raw, _ := json.Marshal(persisted.ProtocolConfig["bt"])
|
||||
var cfg map[string]any
|
||||
if err := json.Unmarshal(raw, &cfg); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !reflect.DeepEqual(cfg["state"], expected) {
|
||||
t.Fatalf("state lost after reopening storage: %+v", cfg)
|
||||
}
|
||||
}
|
||||
|
||||
type rejectedConfigStorage struct{ Storage }
|
||||
|
||||
func (*rejectedConfigStorage) Put(string, string, any) error {
|
||||
return errors.New("storage unavailable")
|
||||
}
|
||||
|
||||
func TestProtocolStateAndPutStorageFailureDoNotPublish(t *testing.T) {
|
||||
d := protocolConfigDownloader(t, nil, &counterManager{})
|
||||
before := protocolObject(t, d, "counter")
|
||||
d.storage = &rejectedConfigStorage{Storage: d.storage}
|
||||
err := fetcher.UpdateState[counterConfig, counterState](counterHost(d), func(_ *counterConfig, state *counterState) error { state.Count = 99; return nil })
|
||||
if err == nil || !reflect.DeepEqual(protocolObject(t, d, "counter"), before) {
|
||||
t.Fatal("failed state persistence published a change")
|
||||
}
|
||||
cfg, _ := d.GetConfig()
|
||||
cfg.ProtocolConfig["counter"] = map[string]any{"writable": 99}
|
||||
if err := d.PutConfig(cfg); err == nil || !reflect.DeepEqual(protocolObject(t, d, "counter"), before) {
|
||||
t.Fatal("failed config persistence published a change")
|
||||
}
|
||||
}
|
||||
@@ -191,13 +191,16 @@ func ensureScriptExecutable(t *testing.T, scriptPath string) {
|
||||
}
|
||||
|
||||
func setupScriptTest(t *testing.T, fn func(downloader *Downloader)) {
|
||||
defaultDownloader.Setup()
|
||||
defaultDownloader.cfg.StorageDir = ".test_storage"
|
||||
defaultDownloader.cfg.DownloadDir = ".test_download"
|
||||
defer func() {
|
||||
defaultDownloader.Clear()
|
||||
os.RemoveAll(defaultDownloader.cfg.StorageDir)
|
||||
os.RemoveAll(defaultDownloader.cfg.DownloadDir)
|
||||
}()
|
||||
fn(defaultDownloader)
|
||||
t.Helper()
|
||||
// Each test owns its runtime; a closed Downloader cannot be reused.
|
||||
d := NewDownloader(&DownloaderConfig{StorageDir: t.TempDir(), TempDir: t.TempDir()})
|
||||
if err := d.Setup(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
if err := d.Close(); err != nil {
|
||||
t.Error(err)
|
||||
}
|
||||
})
|
||||
fn(d)
|
||||
}
|
||||
@@ -18,7 +18,7 @@ func TestCreateResolvedFileSelection(t *testing.T) {
|
||||
}{
|
||||
{"partial", []int{2, 0}, true, []int{2, 0}, 40},
|
||||
{"default all", nil, true, []int{0, 1, 2}, 60},
|
||||
{"legacy retains resolve options", nil, false, []int{1}, 20},
|
||||
{"default creation options", nil, false, []int{0, 1, 2}, 60},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
manager := &generationTestManager{}
|
||||
@@ -28,8 +28,8 @@ func TestCreateResolvedFileSelection(t *testing.T) {
|
||||
}
|
||||
defer d.Clear()
|
||||
d.cfg.MaxRunning = 0
|
||||
original := &base.Options{Path: t.TempDir(), SelectFiles: []int{1}}
|
||||
rr, err := d.Resolve(&base.Request{URL: "generation://selection"}, original)
|
||||
original := &base.Options{Path: t.TempDir()}
|
||||
rr, err := d.Resolve(&base.Request{URL: "generation://selection"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -40,7 +40,7 @@ func TestCreateResolvedFileSelection(t *testing.T) {
|
||||
if tc.submit {
|
||||
id, err = d.CreateWithOptions(rr.ID, &base.Options{Path: original.Path, SelectFiles: tc.selection})
|
||||
} else {
|
||||
id, err = d.Create(rr.ID)
|
||||
id, err = d.CreateWithOptions(rr.ID, original)
|
||||
}
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
{
|
||||
"normalizeExtensions": [
|
||||
{
|
||||
"input": [
|
||||
"exe, msi",
|
||||
" ZIP ",
|
||||
".tar.gz",
|
||||
"",
|
||||
"exe"
|
||||
],
|
||||
"expected": [
|
||||
"exe",
|
||||
"msi",
|
||||
"zip",
|
||||
"tar.gz"
|
||||
]
|
||||
},
|
||||
{
|
||||
"input": [],
|
||||
"expected": []
|
||||
},
|
||||
{
|
||||
"input": [
|
||||
" "
|
||||
],
|
||||
"expected": []
|
||||
},
|
||||
{
|
||||
"input": [
|
||||
"video/mp4"
|
||||
],
|
||||
"expected": [
|
||||
"video/mp4"
|
||||
]
|
||||
},
|
||||
{
|
||||
"input": [
|
||||
".exe",
|
||||
"..msi"
|
||||
],
|
||||
"expected": [
|
||||
"exe",
|
||||
"msi"
|
||||
]
|
||||
},
|
||||
{
|
||||
"input": [
|
||||
"EXE",
|
||||
"ſ",
|
||||
"Ä"
|
||||
],
|
||||
"expected": [
|
||||
"exe",
|
||||
"ſ",
|
||||
"Ä"
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -4,7 +4,6 @@ import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
@@ -858,13 +857,16 @@ func TestWebhook_TriggerOnPause(t *testing.T) {
|
||||
}
|
||||
|
||||
func setupWebhookTest(t *testing.T, fn func(downloader *Downloader)) {
|
||||
defaultDownloader.Setup()
|
||||
defaultDownloader.cfg.StorageDir = ".test_storage"
|
||||
defaultDownloader.cfg.DownloadDir = ".test_download"
|
||||
defer func() {
|
||||
defaultDownloader.Clear()
|
||||
os.RemoveAll(defaultDownloader.cfg.StorageDir)
|
||||
os.RemoveAll(defaultDownloader.cfg.DownloadDir)
|
||||
}()
|
||||
fn(defaultDownloader)
|
||||
t.Helper()
|
||||
// Each test owns its runtime; a closed Downloader cannot be reused.
|
||||
d := NewDownloader(&DownloaderConfig{StorageDir: t.TempDir(), TempDir: t.TempDir()})
|
||||
if err := d.Setup(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
if err := d.Close(); err != nil {
|
||||
t.Error(err)
|
||||
}
|
||||
})
|
||||
fn(d)
|
||||
}
|
||||
+22
-14
@@ -139,11 +139,12 @@ type checksumOption struct {
|
||||
// downloadOptions is the MCP-facing form of base.Options. Only HTTP currently
|
||||
// defines protocol-specific task options, so Extra has one concrete schema.
|
||||
type downloadOptions struct {
|
||||
Name string `json:"name,omitempty" jsonschema:"Optional output file or directory name"`
|
||||
Path string `json:"path,omitempty" jsonschema:"Optional download directory"`
|
||||
AsDefaultPath bool `json:"asDefaultPath,omitempty" jsonschema:"Save path as the default download directory after task creation"`
|
||||
SelectFiles []int `json:"selectFiles,omitempty" jsonschema:"Optional zero-based file indexes to download from a multi-file resource"`
|
||||
Extra *httpTaskExtra `json:"extra,omitempty" jsonschema:"Optional HTTP task settings"`
|
||||
Name string `json:"name,omitempty" jsonschema:"Optional output file or directory name"`
|
||||
Path string `json:"path,omitempty" jsonschema:"Optional base download directory"`
|
||||
AsDefaultPath bool `json:"asDefaultPath,omitempty" jsonschema:"Save path as the default download directory after task creation"`
|
||||
SkipAutoCategorize bool `json:"skipAutoCategorize,omitempty" jsonschema:"Keep the base download directory instead of routing the task to its category directory"`
|
||||
SelectFiles []int `json:"selectFiles,omitempty" jsonschema:"Optional zero-based file indexes to download from a multi-file resource"`
|
||||
Extra *httpTaskExtra `json:"extra,omitempty" jsonschema:"Optional HTTP task settings"`
|
||||
}
|
||||
|
||||
func requestExtraSchema() *jsonschema.Schema {
|
||||
@@ -213,22 +214,25 @@ func (o *downloadOptions) baseOptions() *base.Options {
|
||||
}
|
||||
}
|
||||
return &base.Options{
|
||||
Name: o.Name,
|
||||
Path: o.Path,
|
||||
AsDefaultPath: o.AsDefaultPath,
|
||||
SelectFiles: o.SelectFiles,
|
||||
Extra: extra,
|
||||
Name: o.Name,
|
||||
Path: o.Path,
|
||||
AsDefaultPath: o.AsDefaultPath,
|
||||
SkipAutoCategorize: o.SkipAutoCategorize,
|
||||
SelectFiles: o.SelectFiles,
|
||||
Extra: extra,
|
||||
}
|
||||
}
|
||||
|
||||
type resolveTaskInput struct {
|
||||
Req *downloadRequest `json:"req" jsonschema:"Download request to resolve"`
|
||||
Opts *downloadOptions `json:"opts,omitempty" jsonschema:"Optional download settings used while resolving"`
|
||||
Req *downloadRequest `json:"req" jsonschema:"Download request to resolve"`
|
||||
}
|
||||
|
||||
type resolveTaskOutput struct {
|
||||
ID string `json:"id,omitempty"`
|
||||
Res *base.Resource `json:"res"`
|
||||
// SuggestedDownloadDir is the category directory the created task would be
|
||||
// routed to, empty when auto categorize does not apply.
|
||||
SuggestedDownloadDir string `json:"suggestedDownloadDir,omitempty"`
|
||||
}
|
||||
|
||||
type createTaskInput struct {
|
||||
@@ -328,11 +332,15 @@ func registerTools(server *mcp.Server, downloader *download.Downloader) {
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
result, err := downloader.Resolve(req, input.Opts.baseOptions())
|
||||
result, err := downloader.Resolve(req)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
return nil, &resolveTaskOutput{ID: result.ID, Res: result.Res}, nil
|
||||
return nil, &resolveTaskOutput{
|
||||
ID: result.ID,
|
||||
Res: result.Res,
|
||||
SuggestedDownloadDir: result.SuggestedDownloadDir,
|
||||
}, nil
|
||||
})
|
||||
|
||||
mcp.AddTool(server, &mcp.Tool{
|
||||
|
||||
@@ -101,10 +101,32 @@ func TestStreamableHTTPTools(t *testing.T) {
|
||||
if reqExtra["type"] != "object" || !ok || len(branches) != 2 {
|
||||
t.Fatalf("%s req.extra schema = %#v, want an object with two oneOf branches", tool.Name, reqExtra)
|
||||
}
|
||||
if tool.Name == "resolve_task" {
|
||||
properties := schema["properties"].(map[string]any)
|
||||
if _, exists := properties["opts"]; exists || len(properties) != 1 {
|
||||
t.Fatalf("resolve_task must expose only req: %s", inputSchema)
|
||||
}
|
||||
continue
|
||||
}
|
||||
optsExtra := nestedSchemaProperty(t, schema, "opts", "extra")
|
||||
if !schemaIncludesType(optsExtra, "object") || optsExtra["additionalProperties"] != false {
|
||||
t.Fatalf("%s opts.extra schema = %#v, want concrete HTTP task settings", tool.Name, optsExtra)
|
||||
}
|
||||
opts := nestedSchemaProperty(t, schema, "opts")
|
||||
optsProperties, ok := opts["properties"].(map[string]any)
|
||||
if !ok {
|
||||
t.Fatalf("%s opts schema has no properties: %s", tool.Name, inputSchema)
|
||||
}
|
||||
if _, exists := optsProperties["skipAutoCategorize"]; !exists {
|
||||
t.Fatalf("%s opts schema is missing skipAutoCategorize: %s", tool.Name, inputSchema)
|
||||
}
|
||||
}
|
||||
resolveOutput, err := json.Marshal(resolveTaskTool.OutputSchema)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !bytes.Contains(resolveOutput, []byte(`"suggestedDownloadDir"`)) {
|
||||
t.Fatalf("resolve_task output schema is missing suggestedDownloadDir: %s", resolveOutput)
|
||||
}
|
||||
outputSchema, err := json.Marshal(getTaskTool.OutputSchema)
|
||||
if err != nil {
|
||||
|
||||
+2
-41
@@ -1,9 +1,7 @@
|
||||
package rest
|
||||
|
||||
import (
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"runtime"
|
||||
|
||||
"github.com/GopeedLab/gopeed/pkg/base"
|
||||
@@ -26,7 +24,7 @@ func Info(w http.ResponseWriter, r *http.Request) {
|
||||
func Resolve(w http.ResponseWriter, r *http.Request) {
|
||||
var req model.ResolveTask
|
||||
if ReadJson(r, w, &req) {
|
||||
rr, err := Downloader.Resolve(req.Req, req.Opts)
|
||||
rr, err := Downloader.Resolve(req.Req)
|
||||
if err != nil {
|
||||
WriteJson(w, model.NewErrorResult(err.Error()))
|
||||
return
|
||||
@@ -82,7 +80,7 @@ func PatchTask(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
var req model.ResolveTask
|
||||
var req model.PatchTask
|
||||
if ReadJson(r, w, &req) {
|
||||
if err := Downloader.Patch(taskId, req.Req, req.Opts); err != nil {
|
||||
if err == download.ErrTaskNotFound {
|
||||
@@ -337,43 +335,6 @@ func UpdateExtension(w http.ResponseWriter, r *http.Request) {
|
||||
WriteJson(w, model.NewNilResult())
|
||||
}
|
||||
|
||||
func DoProxy(w http.ResponseWriter, r *http.Request) {
|
||||
target := r.Header.Get("X-Target-Uri")
|
||||
if target == "" {
|
||||
writeError(w, "param invalid: X-Target-Uri")
|
||||
return
|
||||
}
|
||||
targetUrl, err := url.Parse(target)
|
||||
if err != nil {
|
||||
writeError(w, err.Error())
|
||||
return
|
||||
}
|
||||
r.RequestURI = ""
|
||||
r.URL = targetUrl
|
||||
r.Host = targetUrl.Host
|
||||
r.Header.Del("Cookie")
|
||||
r.Header.Del("X-Api-Token")
|
||||
r.Header.Del("X-Target-Uri")
|
||||
resp, err := http.DefaultClient.Do(r)
|
||||
if err != nil {
|
||||
writeError(w, err.Error())
|
||||
return
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
for k, vv := range resp.Header {
|
||||
for _, v := range vv {
|
||||
w.Header().Set(k, v)
|
||||
}
|
||||
}
|
||||
w.WriteHeader(resp.StatusCode)
|
||||
buf, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
writeError(w, err.Error())
|
||||
return
|
||||
}
|
||||
w.Write(buf)
|
||||
}
|
||||
|
||||
func GetStats(w http.ResponseWriter, r *http.Request) {
|
||||
vars := mux.Vars(r)
|
||||
taskId := vars["id"]
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
package model
|
||||
|
||||
type MirrorCandidatesReq struct {
|
||||
URL string `json:"url"`
|
||||
}
|
||||
|
||||
type MirrorCandidatesResp struct {
|
||||
URLs []string `json:"urls"`
|
||||
}
|
||||
@@ -3,6 +3,10 @@ package model
|
||||
import "github.com/GopeedLab/gopeed/pkg/base"
|
||||
|
||||
type ResolveTask struct {
|
||||
Req *base.Request `json:"req"`
|
||||
}
|
||||
|
||||
type PatchTask struct {
|
||||
Req *base.Request `json:"req"`
|
||||
Opts *base.Options `json:"opts"`
|
||||
}
|
||||
|
||||
+31
-28
@@ -216,7 +216,6 @@ func buildServer(startCfg *model.StartConfig, continueOnStartup bool) (*http.Ser
|
||||
WriteStatusJson(w, response.StatusCode, response.Body)
|
||||
})
|
||||
}
|
||||
r.Path("/api/web/proxy").HandlerFunc(DoProxy)
|
||||
if startCfg.MCPEnable {
|
||||
r.Path("/mcp").Handler(mcpserver.NewHandler(Downloader))
|
||||
} else {
|
||||
@@ -231,35 +230,39 @@ func buildServer(startCfg *model.StartConfig, continueOnStartup bool) (*http.Ser
|
||||
if enableWebAuth {
|
||||
webSessions = newWebSessionStore()
|
||||
}
|
||||
if startCfg.WebEnable {
|
||||
if enableWebAuth {
|
||||
r.Methods(http.MethodPost).Path("/api/web/login").HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
var loginReq model.WebAuth
|
||||
if ReadJson(r, w, &loginReq) {
|
||||
if loginReq.Username == startCfg.WebAuth.Username && loginReq.Password == startCfg.WebAuth.Password {
|
||||
sessionID, expiresAt, err := webSessions.create()
|
||||
if err != nil {
|
||||
WriteJson(w, model.NewErrorResult(err.Error()))
|
||||
return
|
||||
}
|
||||
http.SetCookie(w, &http.Cookie{
|
||||
Name: webAuthCookieName,
|
||||
Value: sessionID,
|
||||
Path: "/",
|
||||
MaxAge: int(webAuthSessionTTL.Seconds()),
|
||||
Expires: expiresAt,
|
||||
HttpOnly: true,
|
||||
Secure: requestUsesHTTPS(r),
|
||||
SameSite: http.SameSiteLaxMode,
|
||||
})
|
||||
|
||||
WriteJson(w, model.NewNilResult())
|
||||
if enableWebAuth {
|
||||
r.Methods(http.MethodPost).Path("/api/web/login").HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
var loginReq model.WebAuth
|
||||
if ReadJson(r, w, &loginReq) {
|
||||
if loginReq.Username == startCfg.WebAuth.Username && loginReq.Password == startCfg.WebAuth.Password {
|
||||
sessionID, expiresAt, err := webSessions.create()
|
||||
if err != nil {
|
||||
WriteJson(w, model.NewErrorResult(err.Error()))
|
||||
return
|
||||
}
|
||||
http.SetCookie(w, &http.Cookie{
|
||||
Name: webAuthCookieName,
|
||||
Value: sessionID,
|
||||
Path: "/",
|
||||
MaxAge: int(webAuthSessionTTL.Seconds()),
|
||||
Expires: expiresAt,
|
||||
HttpOnly: true,
|
||||
Secure: requestUsesHTTPS(r),
|
||||
SameSite: http.SameSiteLaxMode,
|
||||
})
|
||||
|
||||
WriteJson(w, model.NewNilResult())
|
||||
return
|
||||
}
|
||||
WriteStatusJson(w, http.StatusUnauthorized, model.NewErrorResult("unauthorized", model.CodeUnauthorized))
|
||||
})
|
||||
}
|
||||
}
|
||||
WriteStatusJson(w, http.StatusUnauthorized, model.NewErrorResult("unauthorized", model.CodeUnauthorized))
|
||||
})
|
||||
}
|
||||
// Reserve the API namespace before the web file handler, including unknown
|
||||
// endpoints and unsupported methods on otherwise known API paths.
|
||||
r.Path("/api").Handler(http.NotFoundHandler())
|
||||
r.PathPrefix("/api/").Handler(http.NotFoundHandler())
|
||||
if startCfg.WebEnable {
|
||||
r.PathPrefix("/fs/tasks").Handler(http.FileServer(new(taskFileSystem)))
|
||||
r.PathPrefix("/fs/extensions").Handler(http.FileServer(new(extensionFileSystem)))
|
||||
r.PathPrefix("/").Handler(gzipMiddleware(http.FileServer(newEmbedCacheFileSystem(http.FS(startCfg.WebFS)))))
|
||||
@@ -325,7 +328,7 @@ func buildServer(startCfg *model.StartConfig, continueOnStartup bool) (*http.Ser
|
||||
})
|
||||
|
||||
server := &http.Server{Handler: handlers.CORS(
|
||||
handlers.AllowedHeaders([]string{"Accept", "Content-Type", "Authorization", "X-Api-Token", "X-Target-Uri", "Mcp-Method", "Mcp-Name", "Mcp-Protocol-Version", "Mcp-Session-Id", "Last-Event-ID"}),
|
||||
handlers.AllowedHeaders([]string{"Accept", "Content-Type", "Authorization", "X-Api-Token", "Mcp-Method", "Mcp-Name", "Mcp-Protocol-Version", "Mcp-Session-Id", "Last-Event-ID"}),
|
||||
handlers.AllowedMethods([]string{"GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"}),
|
||||
handlers.AllowedOrigins([]string{"*"}),
|
||||
handlers.ExposedHeaders([]string{"Mcp-Session-Id"}),
|
||||
|
||||
+57
-29
@@ -2,7 +2,6 @@ package rest
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"crypto/md5"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
@@ -15,6 +14,7 @@ import (
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"testing/fstest"
|
||||
"time"
|
||||
|
||||
"github.com/GopeedLab/gopeed/internal/test"
|
||||
@@ -55,8 +55,7 @@ var (
|
||||
},
|
||||
}
|
||||
resolveReq = &model.ResolveTask{
|
||||
Req: taskReq,
|
||||
Opts: createOpts,
|
||||
Req: taskReq,
|
||||
}
|
||||
createReq = &model.CreateTask{
|
||||
Req: taskReq,
|
||||
@@ -105,7 +104,8 @@ func TestCreateTask(t *testing.T) {
|
||||
})
|
||||
|
||||
taskId := httpRequestCheckOk[string](http.MethodPost, "/api/v1/tasks", &model.CreateTask{
|
||||
Rid: resp.ID,
|
||||
Rid: resp.ID,
|
||||
Opts: createOpts,
|
||||
})
|
||||
if taskId == "" {
|
||||
t.Fatal("create task failed")
|
||||
@@ -295,7 +295,7 @@ func TestPatchTask(t *testing.T) {
|
||||
time.Sleep(time.Millisecond * 100)
|
||||
|
||||
// Patch the task with new labels
|
||||
patchReq := &model.ResolveTask{
|
||||
patchReq := &model.PatchTask{
|
||||
Req: &base.Request{
|
||||
Labels: map[string]string{
|
||||
"patched": "true",
|
||||
@@ -322,7 +322,7 @@ func TestPatchTask(t *testing.T) {
|
||||
func TestPatchTaskNotFound(t *testing.T) {
|
||||
doTest(func() {
|
||||
// Try to patch a non-existent task
|
||||
patchReq := &model.ResolveTask{
|
||||
patchReq := &model.PatchTask{
|
||||
Req: &base.Request{
|
||||
Labels: map[string]string{
|
||||
"test": "value",
|
||||
@@ -669,29 +669,49 @@ func TestWebFsEnhance(t *testing.T) {
|
||||
})
|
||||
}
|
||||
|
||||
func TestDoProxy(t *testing.T) {
|
||||
doTest(func() {
|
||||
code, respBody := doHttpRequest0(http.MethodGet, "/api/web/proxy", map[string]string{
|
||||
"X-Target-Uri": "https://github.com/GopeedLab/gopeed/raw/695da7ea87d2b455552b709d3cb4d7879484d4d1/README.md",
|
||||
}, nil)
|
||||
if code != http.StatusOK {
|
||||
t.Errorf("DoProxy() got = %v, want %v", code, http.StatusOK)
|
||||
}
|
||||
want := "4ee193b676f1ebb2ad810e016350d52a"
|
||||
got := fmt.Sprintf("%x", md5.Sum(respBody))
|
||||
if got != want {
|
||||
t.Errorf("DoProxy() got = %v, want %v", got, want)
|
||||
}
|
||||
})
|
||||
|
||||
doTest(func() {
|
||||
code, _ := doHttpRequest0(http.MethodGet, "/api/web/proxy", map[string]string{
|
||||
"X-Target-Uri": "https://github.com/GopeedLab/gopeed/raw/695da7ea87d2b455552b709d3cb4d7879484d4d1/NOT_FOUND",
|
||||
}, nil)
|
||||
if code != http.StatusNotFound {
|
||||
t.Errorf("DoProxy() got = %v, want %v", code, http.StatusNotFound)
|
||||
}
|
||||
})
|
||||
func TestUnknownAPIPathsReturn404(t *testing.T) {
|
||||
for _, webEnabled := range []bool{false, true} {
|
||||
t.Run(fmt.Sprintf("web=%t", webEnabled), func(t *testing.T) {
|
||||
doTest0(func(cfg *model.StartConfig) {
|
||||
cfg.WebEnable = webEnabled
|
||||
cfg.WebFS = fstest.MapFS{
|
||||
"index.html": &fstest.MapFile{Data: []byte("<html>app</html>")},
|
||||
"api/static.txt": &fstest.MapFile{Data: []byte("must not serve API paths as files")},
|
||||
"apiary": &fstest.MapFile{Data: []byte("namespace boundary")},
|
||||
}
|
||||
}, func() {
|
||||
paths := []string{"/api", "/api/", "/api/missing", "/api/v1/missing", "/api/web/missing", "/api/web/login", "/api/static.txt"}
|
||||
for _, path := range paths {
|
||||
for _, method := range []string{http.MethodGet, http.MethodPost, http.MethodPut, http.MethodPatch, http.MethodDelete, http.MethodHead} {
|
||||
code, body := doHttpRequest0(method, path, nil, nil)
|
||||
if code != http.StatusNotFound {
|
||||
t.Fatalf("%s %s status %d, want 404: %s", method, path, code, body)
|
||||
}
|
||||
if strings.Contains(string(body), "<html>app</html>") {
|
||||
t.Fatalf("%s %s fell through to web files", method, path)
|
||||
}
|
||||
}
|
||||
}
|
||||
// Known API routes still run, while unsupported methods use the API fallback.
|
||||
code, _ := doHttpRequest0(http.MethodGet, "/api/v1/info", nil, nil)
|
||||
if code != http.StatusOK {
|
||||
t.Fatalf("known API status %d, want 200", code)
|
||||
}
|
||||
code, _ = doHttpRequest0(http.MethodPost, "/api/v1/info", nil, nil)
|
||||
if code != http.StatusNotFound {
|
||||
t.Fatalf("unsupported API method status %d, want 404", code)
|
||||
}
|
||||
if webEnabled {
|
||||
for path, want := range map[string]string{"/": "<html>app</html>", "/apiary": "namespace boundary"} {
|
||||
code, body := doHttpRequest0(http.MethodGet, path, map[string]string{"Accept-Encoding": "identity"}, nil)
|
||||
if code != http.StatusOK || !strings.Contains(string(body), want) {
|
||||
t.Fatalf("web file %s status %d body %s", path, code, body)
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestTestWebhook(t *testing.T) {
|
||||
@@ -1175,6 +1195,14 @@ func doTest0(onStart func(cfg *model.StartConfig), handler func()) {
|
||||
onStart(cfg)
|
||||
}
|
||||
fileListener := doStart(cfg)
|
||||
// The server process configures the default download directory on first
|
||||
// load; a task without a base directory is rejected without it.
|
||||
if current, err := Downloader.GetConfig(); err == nil {
|
||||
current.DownloadDir = downloadDir
|
||||
if err := Downloader.PutConfig(current); err != nil {
|
||||
panic(err)
|
||||
}
|
||||
}
|
||||
defer func() {
|
||||
if err := fileListener.Close(); err != nil {
|
||||
panic(err)
|
||||
|
||||
@@ -126,7 +126,7 @@ Guidelines:
|
||||
Use the following desktop reference metrics unless a specific screen requires a documented exception:
|
||||
|
||||
- Reference window: `1024 x 768`
|
||||
- Primary rail width: `64px`
|
||||
- Primary rail width: `78px` (reserves space for native macOS traffic lights)
|
||||
- Secondary filter sidebar width: `256px`
|
||||
- Main content header height: `80px`
|
||||
- Window drag header height on Windows: `30px`
|
||||
|
||||
@@ -68,12 +68,11 @@ The following endpoints are web HTTP infrastructure and are not part of the FFI/
|
||||
|
||||
- `/fs/tasks/*`
|
||||
- `/fs/extensions/*`
|
||||
- `/api/web/proxy`
|
||||
- Web static file hosting at `/`
|
||||
|
||||
`/fs/tasks` returns files from the Go host machine to a browser over HTTP. Native clients can access local file paths directly and do not need an equivalent API.
|
||||
|
||||
`/api/web/proxy` is infrastructure used by the web transport to work around browser CORS restrictions. It is not part of the published core `/api/v1/**` API. Native clients can request the target URL directly with Dio and do not need the Go proxy.
|
||||
All unmatched `/api` and `/api/**` requests use the common 404 handler before the web file handler.
|
||||
|
||||
## 4. Backend Maintenance Model
|
||||
|
||||
@@ -182,7 +181,7 @@ The REST adapter is limited to transport responsibilities:
|
||||
- Convert `http.Request` into a Dispatcher request.
|
||||
- Write a Dispatcher response back to HTTP.
|
||||
- Handle API tokens, web login, CORS, compression, and panic recovery.
|
||||
- Register web-only file, proxy, and static resource routes.
|
||||
- Register web-only file and static resource routes.
|
||||
|
||||
The REST adapter no longer calls the global `rest.Downloader` directly.
|
||||
|
||||
@@ -198,7 +197,6 @@ Core API routes
|
||||
|
||||
Web-only routes
|
||||
├── /api/web/login
|
||||
├── /api/web/proxy
|
||||
├── /fs/tasks/*
|
||||
├── /fs/extensions/*
|
||||
└── /* static Web UI
|
||||
@@ -416,12 +414,7 @@ The following patterns are prohibited:
|
||||
|
||||
### 7.4 Non-business HTTP capabilities
|
||||
|
||||
`proxyRequest` is not a Gopeed Core API and belongs in a separate `ExternalHttpClient`:
|
||||
|
||||
```text
|
||||
Native → request the target directly with Dio
|
||||
Web → /api/web/proxy
|
||||
```
|
||||
Tracker subscriptions run in Go. Application update checks and extension store requests run directly in the client. Go supplies mirror candidate URLs through `/api/v1/mirrors/{provider}/candidates`; clients probe and download update packages locally with Dio to retain real download progress. There is no arbitrary external HTTP forwarding API.
|
||||
|
||||
Task file access does not belong in `GopeedClient`:
|
||||
|
||||
@@ -594,7 +587,7 @@ Flutter converts all of them into `GopeedException`. Components do not handle Di
|
||||
|
||||
- Windows, macOS, Linux: startup, InvokeAsync, events, shutdown, and repeated startup.
|
||||
- Android, iOS: gomobile InvokeAsync callbacks, reverse MethodChannel events, and background/foreground transitions.
|
||||
- Web: REST, login, proxy, and task-file downloads.
|
||||
- Web: REST, login, and task-file downloads.
|
||||
- Native: enabling and disabling REST does not interrupt download tasks.
|
||||
|
||||
## 12. Implementation Phases
|
||||
|
||||
@@ -10,6 +10,7 @@ import 'model/install_extension.dart';
|
||||
import 'model/login.dart';
|
||||
import 'model/resolve_result.dart';
|
||||
import 'model/resolve_task.dart';
|
||||
import 'model/patch_task.dart';
|
||||
import 'model/result.dart';
|
||||
import 'model/switch_extension.dart';
|
||||
import 'model/task.dart';
|
||||
@@ -91,7 +92,7 @@ Future<List<String>> createTaskBatch(CreateTaskBatch createTaskBatch) {
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> patchTask(String id, ResolveTask patchTask) {
|
||||
Future<void> patchTask(String id, PatchTask patchTask) {
|
||||
return _parse<void>(() => _request('api/v1/tasks/$id', method: 'PATCH', data: patchTask), null);
|
||||
}
|
||||
|
||||
@@ -207,11 +208,6 @@ Future<void> login(LoginReq loginReq) {
|
||||
return _parse<void>(() => _request('api/web/login', method: 'POST', data: loginReq.toJson()), null);
|
||||
}
|
||||
|
||||
Future<Response<String>> proxyRequest<T>(String uri, {dynamic data, Options? options}) async {
|
||||
initDefault();
|
||||
return _transport!.proxyRequest(uri, data: data, options: options);
|
||||
}
|
||||
|
||||
String join(String path) {
|
||||
initDefault();
|
||||
return _transport!.join(path);
|
||||
@@ -225,3 +221,16 @@ Future<dynamic> forward(
|
||||
}) async {
|
||||
return _request(path, method: method, data: data, queryParameters: queryParameters);
|
||||
}
|
||||
|
||||
Future<void> triggerProtocolJob(String protocol, String name) => _parse<void>(
|
||||
() => _request(
|
||||
'api/v1/protocols/${Uri.encodeComponent(protocol)}/jobs/${Uri.encodeComponent(name)}/trigger',
|
||||
method: 'POST',
|
||||
),
|
||||
null,
|
||||
);
|
||||
|
||||
Future<List<String>> getMirrorCandidates(String provider, String url) => _parse(
|
||||
() => _request('api/v1/mirrors/${Uri.encodeComponent(provider)}/candidates', method: 'POST', data: {'url': url}),
|
||||
(json) => List<String>.from(json['urls'] as List),
|
||||
);
|
||||
@@ -1,24 +1,17 @@
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:dio/dio.dart';
|
||||
|
||||
import 'api.dart';
|
||||
import 'model/store_extension.dart';
|
||||
|
||||
class GopeedSiteApi {
|
||||
GopeedSiteApi._();
|
||||
GopeedSiteApi({Dio? client})
|
||||
: _client =
|
||||
client ??
|
||||
Dio(BaseOptions(connectTimeout: const Duration(seconds: 10), receiveTimeout: const Duration(seconds: 10)));
|
||||
|
||||
static final instance = GopeedSiteApi._();
|
||||
|
||||
static const _host = 'gopeed.com';
|
||||
|
||||
Future<List<dynamic>> getReleases({int perPage = 10}) async {
|
||||
final json = await _getJson('/api/releases', queryParameters: {'per_page': perPage.clamp(1, 100).toString()});
|
||||
if (json is! List<dynamic>) {
|
||||
throw const FormatException('Invalid releases response');
|
||||
}
|
||||
return json;
|
||||
}
|
||||
static final instance = GopeedSiteApi();
|
||||
final Dio _client;
|
||||
final _storePages = <String, ({StoreExtensionPage page, DateTime expires})>{};
|
||||
final _storeRequests = <String, Future<StoreExtensionPage>>{};
|
||||
|
||||
Future<StoreExtensionPage> getExtensions({
|
||||
int page = 1,
|
||||
@@ -26,35 +19,64 @@ class GopeedSiteApi {
|
||||
StoreExtensionSort sort = StoreExtensionSort.stars,
|
||||
StoreSortOrder order = StoreSortOrder.desc,
|
||||
String? query,
|
||||
bool forceRefresh = false,
|
||||
}) async {
|
||||
final json = await _getJson(
|
||||
'/api/extensions',
|
||||
queryParameters: {
|
||||
'page': page.toString(),
|
||||
'limit': limit.clamp(1, 100).toString(),
|
||||
'sort': sort.name,
|
||||
'order': order.name,
|
||||
if (query != null && query.trim().isNotEmpty) 'q': query.trim(),
|
||||
},
|
||||
final params = {
|
||||
'view': 'summary',
|
||||
'page': page.toString(),
|
||||
'limit': limit.clamp(1, 100).toString(),
|
||||
'sort': sort.name,
|
||||
'order': order.name,
|
||||
if (query != null && query.trim().isNotEmpty) 'q': query.trim(),
|
||||
};
|
||||
final key = Uri(queryParameters: params).toString();
|
||||
final now = DateTime.now();
|
||||
_storePages.removeWhere((_, entry) => !now.isBefore(entry.expires));
|
||||
final cached = _storePages[key];
|
||||
if (!forceRefresh && cached != null) return cached.page;
|
||||
final pending = _storeRequests[key];
|
||||
if (pending != null) return pending;
|
||||
final request = _fetchStorePage(params);
|
||||
_storeRequests[key] = request;
|
||||
try {
|
||||
final result = await request;
|
||||
if (_storePages.length >= 32) _storePages.remove(_storePages.keys.first);
|
||||
_storePages[key] = (page: result, expires: DateTime.now().add(const Duration(minutes: 2)));
|
||||
return result;
|
||||
} finally {
|
||||
_storeRequests.remove(key);
|
||||
}
|
||||
}
|
||||
|
||||
Future<StoreExtensionPage> _fetchStorePage(Map<String, String> params) async {
|
||||
final response = await _client.get<Map<String, dynamic>>(
|
||||
'https://gopeed.com/api/extensions',
|
||||
queryParameters: params,
|
||||
);
|
||||
return StoreExtensionPage.fromJson(json as Map<String, dynamic>);
|
||||
return StoreExtensionPage.fromJson(response.data!);
|
||||
}
|
||||
|
||||
Future<StoreExtension> getExtension(String id, {String? version}) async {
|
||||
final uri = Uri(
|
||||
scheme: 'https',
|
||||
host: 'gopeed.com',
|
||||
pathSegments: ['api', 'extensions', id],
|
||||
queryParameters: {'version': ?version},
|
||||
);
|
||||
final response = await _client.get<Map<String, dynamic>>(uri.toString());
|
||||
return StoreExtension.fromJson(response.data!);
|
||||
}
|
||||
|
||||
Future<dynamic> getReleases() async {
|
||||
final response = await _client.get<dynamic>(
|
||||
'https://gopeed.com/api/releases',
|
||||
queryParameters: {'per_page': 10},
|
||||
options: Options(followRedirects: false),
|
||||
);
|
||||
return response.data;
|
||||
}
|
||||
|
||||
Future<void> reportExtensionInstall(String id) async {
|
||||
final uri = Uri.https(_host, '/api/extensions/install');
|
||||
await proxyRequest(
|
||||
uri.toString(),
|
||||
data: {'id': id},
|
||||
options: Options(method: 'POST', contentType: Headers.jsonContentType),
|
||||
);
|
||||
}
|
||||
|
||||
Future<dynamic> _getJson(String path, {Map<String, String>? queryParameters}) async {
|
||||
final uri = Uri.https(_host, path, queryParameters);
|
||||
final Response<String> response = await proxyRequest(uri.toString());
|
||||
if (response.data == null || response.data!.isEmpty) {
|
||||
throw Exception('Empty response from $uri');
|
||||
}
|
||||
return jsonDecode(response.data!);
|
||||
await _client.post<void>('https://gopeed.com/api/extensions/install', data: {'id': id});
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import 'package:json_annotation/json_annotation.dart';
|
||||
import 'package:path/path.dart' as path;
|
||||
|
||||
part 'downloader_config.g.dart';
|
||||
|
||||
@@ -8,6 +9,7 @@ class DownloaderConfig {
|
||||
int maxRunning;
|
||||
ProtocolConfig protocolConfig = ProtocolConfig();
|
||||
ExtraConfig extra = ExtraConfig();
|
||||
GithubMirrorConfig githubMirror = GithubMirrorConfig();
|
||||
ProxyConfig proxy = ProxyConfig();
|
||||
WebhookConfig webhook = WebhookConfig();
|
||||
ScriptConfig script = ScriptConfig();
|
||||
@@ -16,12 +18,16 @@ class DownloaderConfig {
|
||||
ApiServerConfig api = ApiServerConfig();
|
||||
bool autoStartTasks;
|
||||
bool autoDeleteMissingFileTasks;
|
||||
List<DownloadCategory> categories;
|
||||
bool autoCategorize;
|
||||
|
||||
DownloaderConfig({
|
||||
this.downloadDir = '',
|
||||
this.maxRunning = 0,
|
||||
this.autoStartTasks = false,
|
||||
this.autoDeleteMissingFileTasks = false,
|
||||
this.categories = const [],
|
||||
this.autoCategorize = false,
|
||||
});
|
||||
|
||||
factory DownloaderConfig.fromJson(Map<String, dynamic> json) => _$DownloaderConfigFromJson(json);
|
||||
@@ -98,20 +104,59 @@ class HttpConfig {
|
||||
Map<String, dynamic> toJson() => _$HttpConfigToJson(this);
|
||||
}
|
||||
|
||||
DateTime? _trackerUpdateTimeFromJson(String? value) =>
|
||||
value == null || value.startsWith('0001-') ? null : DateTime.parse(value);
|
||||
|
||||
@JsonSerializable()
|
||||
class TrackerSubscriptionStatus {
|
||||
final bool success;
|
||||
final int trackerCount;
|
||||
final DateTime lastFetchTime;
|
||||
|
||||
TrackerSubscriptionStatus({required this.success, required this.trackerCount, required this.lastFetchTime});
|
||||
|
||||
factory TrackerSubscriptionStatus.fromJson(Map<String, dynamic> json) => _$TrackerSubscriptionStatusFromJson(json);
|
||||
|
||||
Map<String, dynamic> toJson() => _$TrackerSubscriptionStatusToJson(this);
|
||||
}
|
||||
|
||||
@JsonSerializable(explicitToJson: true)
|
||||
class BtState {
|
||||
Map<String, List<String>> trackerSources;
|
||||
Map<String, TrackerSubscriptionStatus> trackerSubscriptionStatuses;
|
||||
@JsonKey(fromJson: _trackerUpdateTimeFromJson)
|
||||
DateTime? lastTrackerUpdateTime;
|
||||
|
||||
BtState({this.trackerSources = const {}, this.trackerSubscriptionStatuses = const {}, this.lastTrackerUpdateTime});
|
||||
|
||||
factory BtState.fromJson(Map<String, dynamic>? json) => json == null ? BtState() : _$BtStateFromJson(json);
|
||||
|
||||
Map<String, dynamic> toJson() => _$BtStateToJson(this);
|
||||
}
|
||||
|
||||
@JsonSerializable(explicitToJson: true)
|
||||
class BtConfig {
|
||||
BtState state = BtState();
|
||||
List<String> trackerSubscribeUrls;
|
||||
bool autoUpdateTrackers;
|
||||
List<String> customTrackers;
|
||||
int listenPort;
|
||||
List<String> trackers;
|
||||
bool seedKeep;
|
||||
double seedRatio;
|
||||
int seedTime;
|
||||
bool disableDht;
|
||||
bool disablePex;
|
||||
|
||||
BtConfig({
|
||||
this.trackerSubscribeUrls = const [],
|
||||
this.autoUpdateTrackers = true,
|
||||
this.customTrackers = const [],
|
||||
this.listenPort = 0,
|
||||
this.trackers = const [],
|
||||
this.seedKeep = false,
|
||||
this.seedRatio = 0,
|
||||
this.seedTime = 0,
|
||||
this.disableDht = false,
|
||||
this.disablePex = false,
|
||||
});
|
||||
|
||||
factory BtConfig.fromJson(Map<String, dynamic> json) => _$BtConfigFromJson(json);
|
||||
@@ -152,10 +197,6 @@ class ExtraConfig {
|
||||
bool runAsMenubarApp;
|
||||
bool analyticsEnabled;
|
||||
String analyticsClientId;
|
||||
List<DownloadCategory> downloadCategories;
|
||||
|
||||
ExtraConfigBt bt = ExtraConfigBt();
|
||||
ExtraConfigGithubMirror githubMirror = ExtraConfigGithubMirror();
|
||||
|
||||
ExtraConfig({
|
||||
this.themeMode = '',
|
||||
@@ -174,7 +215,6 @@ class ExtraConfig {
|
||||
this.runAsMenubarApp = false,
|
||||
this.analyticsEnabled = true,
|
||||
this.analyticsClientId = '',
|
||||
this.downloadCategories = const [],
|
||||
}) : windowState = windowState ?? WindowStateConfig();
|
||||
|
||||
factory ExtraConfig.fromJson(Map<String, dynamic>? json) =>
|
||||
@@ -204,6 +244,9 @@ class DownloadCategory {
|
||||
bool isBuiltIn;
|
||||
String? nameKey; // i18n key for built-in categories (e.g., 'categoryMusic')
|
||||
bool isDeleted; // Mark built-in categories as deleted instead of removing them
|
||||
// File extensions routed to this category, without leading dots, e.g. ['exe', 'msi'].
|
||||
// An empty list means the category never matches automatically.
|
||||
List<String> extensions;
|
||||
|
||||
DownloadCategory({
|
||||
required this.name,
|
||||
@@ -211,13 +254,93 @@ class DownloadCategory {
|
||||
this.isBuiltIn = false,
|
||||
this.nameKey,
|
||||
this.isDeleted = false,
|
||||
this.extensions = const [],
|
||||
});
|
||||
|
||||
factory DownloadCategory.fromJson(Map<String, dynamic> json) => _$DownloadCategoryFromJson(json);
|
||||
|
||||
Map<String, dynamic> toJson() => _$DownloadCategoryToJson(this);
|
||||
|
||||
/// The effective extension list. The result is always normalized: split on
|
||||
/// commas/whitespace, trimmed, lowercased, without leading dots and without
|
||||
/// duplicates; the original order is preserved. An empty list means the
|
||||
/// category never matches automatically.
|
||||
List<String> effectiveExtensions() => normalizeCategoryExtensions(extensions);
|
||||
}
|
||||
|
||||
/// Lowercases the ascii letters of [value] and leaves the rest alone. The
|
||||
/// backend lowercases the same way, because the unicode case tables of the two
|
||||
/// languages disagree for some code points and would let the two sides pick a
|
||||
/// different category for the same file.
|
||||
String asciiLower(String value) {
|
||||
final lowered = StringBuffer();
|
||||
for (final unit in value.codeUnits) {
|
||||
lowered.writeCharCode(unit >= 0x41 && unit <= 0x5A ? unit + 0x20 : unit);
|
||||
}
|
||||
return lowered.toString();
|
||||
}
|
||||
|
||||
/// Normalizes free form extension input into tokens: splits on commas and
|
||||
/// whitespace, trims, lowercases, strips leading dots, drops empties and
|
||||
/// deduplicates while keeping the original input order.
|
||||
List<String> normalizeCategoryExtensions(Iterable<String> raw) {
|
||||
final result = <String>{};
|
||||
for (final item in raw) {
|
||||
for (final part in item.split(RegExp(r'[,\s]+'))) {
|
||||
var extension = asciiLower(part.trim());
|
||||
while (extension.startsWith('.')) {
|
||||
extension = extension.substring(1);
|
||||
}
|
||||
if (extension.isNotEmpty) {
|
||||
result.add(extension);
|
||||
}
|
||||
}
|
||||
}
|
||||
return result.toList();
|
||||
}
|
||||
|
||||
/// Default extension lists of the built-in categories, keyed by the i18n
|
||||
/// nameKey. Used to seed the built-in categories on first launch and to offer
|
||||
/// the restore-defaults action in the category dialog.
|
||||
const kDefaultCategoryExtensions = <String, List<String>>{
|
||||
'categoryProgram': ['exe', 'msi', 'msix', 'apk', 'dmg', 'deb', 'rpm', 'pkg', 'appimage'],
|
||||
'categoryVideo': ['mp4', 'mkv', 'avi', 'mov', 'wmv', 'flv', 'webm', 'm4v', 'mpg', 'mpeg', '3gp'],
|
||||
'categoryMusic': ['mp3', 'flac', 'wav', 'aac', 'ogg', 'm4a', 'wma', 'ape'],
|
||||
'categoryDocument': ['pdf', 'doc', 'docx', 'xls', 'xlsx', 'ppt', 'pptx', 'txt', 'md', 'csv', 'epub', 'rtf'],
|
||||
};
|
||||
|
||||
/// Rebases built-in category paths that still live under [oldDir] onto
|
||||
/// [newDir] after the default download directory changed. Categories the
|
||||
/// user moved elsewhere, and user created categories, are left alone.
|
||||
void rebaseBuiltinCategoryPaths(DownloaderConfig config, {required String oldDir, required String newDir}) {
|
||||
if (oldDir.trim().isEmpty || newDir.trim().isEmpty) {
|
||||
return;
|
||||
}
|
||||
// The backend seeds the categories with the separator of its platform, which
|
||||
// the path package of the client does not always understand, so both sides
|
||||
// are compared in the forward slash form.
|
||||
final from = _forwardSlash(oldDir);
|
||||
final to = _forwardSlash(newDir);
|
||||
if (path.equals(from, to)) {
|
||||
return;
|
||||
}
|
||||
for (final category in config.categories) {
|
||||
if (!category.isBuiltIn || category.isDeleted || category.path.trim().isEmpty) {
|
||||
continue;
|
||||
}
|
||||
final current = _forwardSlash(category.path);
|
||||
if (path.equals(current, from)) {
|
||||
category.path = to;
|
||||
} else if (path.isWithin(from, current)) {
|
||||
category.path = path.join(to, path.relative(current, from: from));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Rewrites the windows separator of [value] to the forward slash the path
|
||||
/// package uses.
|
||||
String _forwardSlash(String value) => value.replaceAll('\\', '/');
|
||||
|
||||
@JsonSerializable()
|
||||
class WebhookConfig {
|
||||
bool enable;
|
||||
@@ -267,22 +390,6 @@ class ProxyConfig {
|
||||
Map<String, dynamic> toJson() => _$ProxyConfigToJson(this);
|
||||
}
|
||||
|
||||
@JsonSerializable()
|
||||
class ExtraConfigBt {
|
||||
List<String> trackerSubscribeUrls = [];
|
||||
List<String> subscribeTrackers = [];
|
||||
bool autoUpdateTrackers = true;
|
||||
DateTime? lastTrackerUpdateTime;
|
||||
|
||||
List<String> customTrackers = [];
|
||||
|
||||
ExtraConfigBt();
|
||||
|
||||
factory ExtraConfigBt.fromJson(Map<String, dynamic> json) => _$ExtraConfigBtFromJson(json);
|
||||
|
||||
Map<String, dynamic> toJson() => _$ExtraConfigBtToJson(this);
|
||||
}
|
||||
|
||||
enum GithubMirrorType { jsdelivr, ghProxy }
|
||||
|
||||
@JsonSerializable()
|
||||
@@ -300,16 +407,16 @@ class GithubMirror {
|
||||
}
|
||||
|
||||
@JsonSerializable(explicitToJson: true)
|
||||
class ExtraConfigGithubMirror {
|
||||
class GithubMirrorConfig {
|
||||
bool enabled;
|
||||
List<GithubMirror> mirrors;
|
||||
|
||||
ExtraConfigGithubMirror({this.enabled = true, this.mirrors = const []});
|
||||
GithubMirrorConfig({this.enabled = true, this.mirrors = const []});
|
||||
|
||||
factory ExtraConfigGithubMirror.fromJson(Map<String, dynamic>? json) =>
|
||||
json == null ? ExtraConfigGithubMirror() : _$ExtraConfigGithubMirrorFromJson(json);
|
||||
factory GithubMirrorConfig.fromJson(Map<String, dynamic>? json) =>
|
||||
json == null ? GithubMirrorConfig() : _$GithubMirrorConfigFromJson(json);
|
||||
|
||||
Map<String, dynamic> toJson() => _$ExtraConfigGithubMirrorToJson(this);
|
||||
Map<String, dynamic> toJson() => _$GithubMirrorConfigToJson(this);
|
||||
}
|
||||
|
||||
@JsonSerializable()
|
||||
|
||||
@@ -12,9 +12,16 @@ DownloaderConfig _$DownloaderConfigFromJson(Map<String, dynamic> json) =>
|
||||
maxRunning: (json['maxRunning'] as num?)?.toInt() ?? 0,
|
||||
autoStartTasks: json['autoStartTasks'] as bool? ?? false,
|
||||
autoDeleteMissingFileTasks: json['autoDeleteMissingFileTasks'] as bool? ?? false,
|
||||
categories:
|
||||
(json['categories'] as List<dynamic>?)
|
||||
?.map((e) => DownloadCategory.fromJson(e as Map<String, dynamic>))
|
||||
.toList() ??
|
||||
const [],
|
||||
autoCategorize: json['autoCategorize'] as bool? ?? false,
|
||||
)
|
||||
..protocolConfig = ProtocolConfig.fromJson(json['protocolConfig'] as Map<String, dynamic>?)
|
||||
..extra = ExtraConfig.fromJson(json['extra'] as Map<String, dynamic>?)
|
||||
..githubMirror = GithubMirrorConfig.fromJson(json['githubMirror'] as Map<String, dynamic>?)
|
||||
..proxy = ProxyConfig.fromJson(json['proxy'] as Map<String, dynamic>)
|
||||
..webhook = WebhookConfig.fromJson(json['webhook'] as Map<String, dynamic>?)
|
||||
..script = ScriptConfig.fromJson(json['script'] as Map<String, dynamic>?)
|
||||
@@ -27,6 +34,7 @@ Map<String, dynamic> _$DownloaderConfigToJson(DownloaderConfig instance) => <Str
|
||||
'maxRunning': instance.maxRunning,
|
||||
'protocolConfig': instance.protocolConfig.toJson(),
|
||||
'extra': instance.extra.toJson(),
|
||||
'githubMirror': instance.githubMirror.toJson(),
|
||||
'proxy': instance.proxy.toJson(),
|
||||
'webhook': instance.webhook.toJson(),
|
||||
'script': instance.script.toJson(),
|
||||
@@ -35,6 +43,8 @@ Map<String, dynamic> _$DownloaderConfigToJson(DownloaderConfig instance) => <Str
|
||||
'api': instance.api.toJson(),
|
||||
'autoStartTasks': instance.autoStartTasks,
|
||||
'autoDeleteMissingFileTasks': instance.autoDeleteMissingFileTasks,
|
||||
'categories': instance.categories.map((e) => e.toJson()).toList(),
|
||||
'autoCategorize': instance.autoCategorize,
|
||||
};
|
||||
|
||||
ApiServerConfig _$ApiServerConfigFromJson(Map<String, dynamic> json) => ApiServerConfig(
|
||||
@@ -92,20 +102,61 @@ Map<String, dynamic> _$HttpConfigToJson(HttpConfig instance) => <String, dynamic
|
||||
'useServerCtime': instance.useServerCtime,
|
||||
};
|
||||
|
||||
TrackerSubscriptionStatus _$TrackerSubscriptionStatusFromJson(Map<String, dynamic> json) => TrackerSubscriptionStatus(
|
||||
success: json['success'] as bool,
|
||||
trackerCount: (json['trackerCount'] as num).toInt(),
|
||||
lastFetchTime: DateTime.parse(json['lastFetchTime'] as String),
|
||||
);
|
||||
|
||||
Map<String, dynamic> _$TrackerSubscriptionStatusToJson(TrackerSubscriptionStatus instance) => <String, dynamic>{
|
||||
'success': instance.success,
|
||||
'trackerCount': instance.trackerCount,
|
||||
'lastFetchTime': instance.lastFetchTime.toIso8601String(),
|
||||
};
|
||||
|
||||
BtState _$BtStateFromJson(Map<String, dynamic> json) => BtState(
|
||||
trackerSources:
|
||||
(json['trackerSources'] as Map<String, dynamic>?)?.map(
|
||||
(k, e) => MapEntry(k, (e as List<dynamic>).map((e) => e as String).toList()),
|
||||
) ??
|
||||
const {},
|
||||
trackerSubscriptionStatuses:
|
||||
(json['trackerSubscriptionStatuses'] as Map<String, dynamic>?)?.map(
|
||||
(k, e) => MapEntry(k, TrackerSubscriptionStatus.fromJson(e as Map<String, dynamic>)),
|
||||
) ??
|
||||
const {},
|
||||
lastTrackerUpdateTime: _trackerUpdateTimeFromJson(json['lastTrackerUpdateTime'] as String?),
|
||||
);
|
||||
|
||||
Map<String, dynamic> _$BtStateToJson(BtState instance) => <String, dynamic>{
|
||||
'trackerSources': instance.trackerSources,
|
||||
'trackerSubscriptionStatuses': instance.trackerSubscriptionStatuses.map((k, e) => MapEntry(k, e.toJson())),
|
||||
'lastTrackerUpdateTime': ?instance.lastTrackerUpdateTime?.toIso8601String(),
|
||||
};
|
||||
|
||||
BtConfig _$BtConfigFromJson(Map<String, dynamic> json) => BtConfig(
|
||||
trackerSubscribeUrls: (json['trackerSubscribeUrls'] as List<dynamic>?)?.map((e) => e as String).toList() ?? const [],
|
||||
autoUpdateTrackers: json['autoUpdateTrackers'] as bool? ?? true,
|
||||
customTrackers: (json['customTrackers'] as List<dynamic>?)?.map((e) => e as String).toList() ?? const [],
|
||||
listenPort: (json['listenPort'] as num?)?.toInt() ?? 0,
|
||||
trackers: (json['trackers'] as List<dynamic>?)?.map((e) => e as String).toList() ?? const [],
|
||||
seedKeep: json['seedKeep'] as bool? ?? false,
|
||||
seedRatio: (json['seedRatio'] as num?)?.toDouble() ?? 0,
|
||||
seedTime: (json['seedTime'] as num?)?.toInt() ?? 0,
|
||||
);
|
||||
disableDht: json['disableDht'] as bool? ?? false,
|
||||
disablePex: json['disablePex'] as bool? ?? false,
|
||||
)..state = BtState.fromJson(json['state'] as Map<String, dynamic>?);
|
||||
|
||||
Map<String, dynamic> _$BtConfigToJson(BtConfig instance) => <String, dynamic>{
|
||||
'state': instance.state.toJson(),
|
||||
'trackerSubscribeUrls': instance.trackerSubscribeUrls,
|
||||
'autoUpdateTrackers': instance.autoUpdateTrackers,
|
||||
'customTrackers': instance.customTrackers,
|
||||
'listenPort': instance.listenPort,
|
||||
'trackers': instance.trackers,
|
||||
'seedKeep': instance.seedKeep,
|
||||
'seedRatio': instance.seedRatio,
|
||||
'seedTime': instance.seedTime,
|
||||
'disableDht': instance.disableDht,
|
||||
'disablePex': instance.disablePex,
|
||||
};
|
||||
|
||||
Ed2kConfig _$Ed2kConfigFromJson(Map<String, dynamic> json) => Ed2kConfig(
|
||||
@@ -124,37 +175,26 @@ Map<String, dynamic> _$Ed2kConfigToJson(Ed2kConfig instance) => <String, dynamic
|
||||
'nodesDat': instance.nodesDat,
|
||||
};
|
||||
|
||||
ExtraConfig _$ExtraConfigFromJson(Map<String, dynamic> json) =>
|
||||
ExtraConfig(
|
||||
themeMode: json['themeMode'] as String? ?? '',
|
||||
themeColor: json['themeColor'] as String? ?? 'green',
|
||||
locale: json['locale'] as String? ?? '',
|
||||
lastDeleteTaskKeep: json['lastDeleteTaskKeep'] as bool? ?? false,
|
||||
defaultDirectDownload: json['defaultDirectDownload'] as bool? ?? false,
|
||||
defaultBtClient: json['defaultBtClient'] as bool? ?? true,
|
||||
notifyWhenNewVersion: json['notifyWhenNewVersion'] as bool? ?? true,
|
||||
desktopNotification: json['desktopNotification'] as bool? ?? true,
|
||||
backgroundLocationKeepAlive: json['backgroundLocationKeepAlive'] as bool? ?? false,
|
||||
backgroundContinuedProcessing:
|
||||
json['backgroundContinuedProcessing']
|
||||
as bool? ??
|
||||
false,
|
||||
windowState: json['windowState'] == null
|
||||
? null
|
||||
: WindowStateConfig.fromJson(json['windowState'] as Map<String, dynamic>?),
|
||||
bookmarks: (json['bookmarks'] as Map<String, dynamic>?)?.map((k, e) => MapEntry(k, e as String)) ?? const {},
|
||||
createHistory: (json['createHistory'] as List<dynamic>?)?.map((e) => e as String).toList() ?? const [],
|
||||
runAsMenubarApp: json['runAsMenubarApp'] as bool? ?? false,
|
||||
analyticsEnabled: json['analyticsEnabled'] as bool? ?? true,
|
||||
analyticsClientId: json['analyticsClientId'] as String? ?? '',
|
||||
downloadCategories:
|
||||
(json['downloadCategories'] as List<dynamic>?)
|
||||
?.map((e) => DownloadCategory.fromJson(e as Map<String, dynamic>))
|
||||
.toList() ??
|
||||
const [],
|
||||
)
|
||||
..bt = ExtraConfigBt.fromJson(json['bt'] as Map<String, dynamic>)
|
||||
..githubMirror = ExtraConfigGithubMirror.fromJson(json['githubMirror'] as Map<String, dynamic>?);
|
||||
ExtraConfig _$ExtraConfigFromJson(Map<String, dynamic> json) => ExtraConfig(
|
||||
themeMode: json['themeMode'] as String? ?? '',
|
||||
themeColor: json['themeColor'] as String? ?? 'green',
|
||||
locale: json['locale'] as String? ?? '',
|
||||
lastDeleteTaskKeep: json['lastDeleteTaskKeep'] as bool? ?? false,
|
||||
defaultDirectDownload: json['defaultDirectDownload'] as bool? ?? false,
|
||||
defaultBtClient: json['defaultBtClient'] as bool? ?? true,
|
||||
notifyWhenNewVersion: json['notifyWhenNewVersion'] as bool? ?? true,
|
||||
desktopNotification: json['desktopNotification'] as bool? ?? true,
|
||||
backgroundLocationKeepAlive: json['backgroundLocationKeepAlive'] as bool? ?? false,
|
||||
backgroundContinuedProcessing: json['backgroundContinuedProcessing'] as bool? ?? false,
|
||||
windowState: json['windowState'] == null
|
||||
? null
|
||||
: WindowStateConfig.fromJson(json['windowState'] as Map<String, dynamic>?),
|
||||
bookmarks: (json['bookmarks'] as Map<String, dynamic>?)?.map((k, e) => MapEntry(k, e as String)) ?? const {},
|
||||
createHistory: (json['createHistory'] as List<dynamic>?)?.map((e) => e as String).toList() ?? const [],
|
||||
runAsMenubarApp: json['runAsMenubarApp'] as bool? ?? false,
|
||||
analyticsEnabled: json['analyticsEnabled'] as bool? ?? true,
|
||||
analyticsClientId: json['analyticsClientId'] as String? ?? '',
|
||||
);
|
||||
|
||||
Map<String, dynamic> _$ExtraConfigToJson(ExtraConfig instance) => <String, dynamic>{
|
||||
'themeMode': instance.themeMode,
|
||||
@@ -166,17 +206,13 @@ Map<String, dynamic> _$ExtraConfigToJson(ExtraConfig instance) => <String, dynam
|
||||
'notifyWhenNewVersion': instance.notifyWhenNewVersion,
|
||||
'desktopNotification': instance.desktopNotification,
|
||||
'backgroundLocationKeepAlive': instance.backgroundLocationKeepAlive,
|
||||
'backgroundContinuedProcessing':
|
||||
instance.backgroundContinuedProcessing,
|
||||
'backgroundContinuedProcessing': instance.backgroundContinuedProcessing,
|
||||
'windowState': instance.windowState.toJson(),
|
||||
'bookmarks': instance.bookmarks,
|
||||
'createHistory': instance.createHistory,
|
||||
'runAsMenubarApp': instance.runAsMenubarApp,
|
||||
'analyticsEnabled': instance.analyticsEnabled,
|
||||
'analyticsClientId': instance.analyticsClientId,
|
||||
'downloadCategories': instance.downloadCategories.map((e) => e.toJson()).toList(),
|
||||
'bt': instance.bt.toJson(),
|
||||
'githubMirror': instance.githubMirror.toJson(),
|
||||
};
|
||||
|
||||
WindowStateConfig _$WindowStateConfigFromJson(Map<String, dynamic> json) => WindowStateConfig(
|
||||
@@ -197,6 +233,7 @@ DownloadCategory _$DownloadCategoryFromJson(Map<String, dynamic> json) => Downlo
|
||||
isBuiltIn: json['isBuiltIn'] as bool? ?? false,
|
||||
nameKey: json['nameKey'] as String?,
|
||||
isDeleted: json['isDeleted'] as bool? ?? false,
|
||||
extensions: (json['extensions'] as List<dynamic>?)?.map((e) => e as String).toList() ?? const [],
|
||||
);
|
||||
|
||||
Map<String, dynamic> _$DownloadCategoryToJson(DownloadCategory instance) => <String, dynamic>{
|
||||
@@ -205,6 +242,7 @@ Map<String, dynamic> _$DownloadCategoryToJson(DownloadCategory instance) => <Str
|
||||
'isBuiltIn': instance.isBuiltIn,
|
||||
'nameKey': ?instance.nameKey,
|
||||
'isDeleted': instance.isDeleted,
|
||||
'extensions': instance.extensions,
|
||||
};
|
||||
|
||||
WebhookConfig _$WebhookConfigFromJson(Map<String, dynamic> json) => WebhookConfig(
|
||||
@@ -245,23 +283,6 @@ Map<String, dynamic> _$ProxyConfigToJson(ProxyConfig instance) => <String, dynam
|
||||
'pwd': instance.pwd,
|
||||
};
|
||||
|
||||
ExtraConfigBt _$ExtraConfigBtFromJson(Map<String, dynamic> json) => ExtraConfigBt()
|
||||
..trackerSubscribeUrls = (json['trackerSubscribeUrls'] as List<dynamic>).map((e) => e as String).toList()
|
||||
..subscribeTrackers = (json['subscribeTrackers'] as List<dynamic>).map((e) => e as String).toList()
|
||||
..autoUpdateTrackers = json['autoUpdateTrackers'] as bool
|
||||
..lastTrackerUpdateTime = json['lastTrackerUpdateTime'] == null
|
||||
? null
|
||||
: DateTime.parse(json['lastTrackerUpdateTime'] as String)
|
||||
..customTrackers = (json['customTrackers'] as List<dynamic>).map((e) => e as String).toList();
|
||||
|
||||
Map<String, dynamic> _$ExtraConfigBtToJson(ExtraConfigBt instance) => <String, dynamic>{
|
||||
'trackerSubscribeUrls': instance.trackerSubscribeUrls,
|
||||
'subscribeTrackers': instance.subscribeTrackers,
|
||||
'autoUpdateTrackers': instance.autoUpdateTrackers,
|
||||
'lastTrackerUpdateTime': ?instance.lastTrackerUpdateTime?.toIso8601String(),
|
||||
'customTrackers': instance.customTrackers,
|
||||
};
|
||||
|
||||
GithubMirror _$GithubMirrorFromJson(Map<String, dynamic> json) => GithubMirror(
|
||||
type: $enumDecode(_$GithubMirrorTypeEnumMap, json['type']),
|
||||
url: json['url'] as String,
|
||||
@@ -278,14 +299,14 @@ Map<String, dynamic> _$GithubMirrorToJson(GithubMirror instance) => <String, dyn
|
||||
|
||||
const _$GithubMirrorTypeEnumMap = {GithubMirrorType.jsdelivr: 'jsdelivr', GithubMirrorType.ghProxy: 'ghProxy'};
|
||||
|
||||
ExtraConfigGithubMirror _$ExtraConfigGithubMirrorFromJson(Map<String, dynamic> json) => ExtraConfigGithubMirror(
|
||||
GithubMirrorConfig _$GithubMirrorConfigFromJson(Map<String, dynamic> json) => GithubMirrorConfig(
|
||||
enabled: json['enabled'] as bool? ?? true,
|
||||
mirrors:
|
||||
(json['mirrors'] as List<dynamic>?)?.map((e) => GithubMirror.fromJson(e as Map<String, dynamic>)).toList() ??
|
||||
const [],
|
||||
);
|
||||
|
||||
Map<String, dynamic> _$ExtraConfigGithubMirrorToJson(ExtraConfigGithubMirror instance) => <String, dynamic>{
|
||||
Map<String, dynamic> _$GithubMirrorConfigToJson(GithubMirrorConfig instance) => <String, dynamic>{
|
||||
'enabled': instance.enabled,
|
||||
'mirrors': instance.mirrors.map((e) => e.toJson()).toList(),
|
||||
};
|
||||
|
||||
@@ -4,13 +4,27 @@ part 'options.g.dart';
|
||||
|
||||
@JsonSerializable(explicitToJson: true)
|
||||
class Options {
|
||||
/// HTTP-only size estimate, superseded by HTTP metadata or completion.
|
||||
int size;
|
||||
String name;
|
||||
String path;
|
||||
bool asDefaultPath;
|
||||
|
||||
/// Keep the base download directory instead of routing the task to the
|
||||
/// directory of the category its resolved file belongs to.
|
||||
bool skipAutoCategorize;
|
||||
List<int> selectFiles;
|
||||
Object? extra;
|
||||
|
||||
Options({this.name = '', this.path = '', this.asDefaultPath = false, this.selectFiles = const [], this.extra});
|
||||
Options({
|
||||
this.size = 0,
|
||||
this.name = '',
|
||||
this.path = '',
|
||||
this.asDefaultPath = false,
|
||||
this.skipAutoCategorize = false,
|
||||
this.selectFiles = const [],
|
||||
this.extra,
|
||||
});
|
||||
|
||||
factory Options.fromJson(Map<String, dynamic> json) => _$OptionsFromJson(json);
|
||||
|
||||
@@ -22,17 +36,14 @@ class ChecksumOption {
|
||||
String algorithm;
|
||||
String expected;
|
||||
|
||||
ChecksumOption({
|
||||
this.algorithm = '',
|
||||
this.expected = '',
|
||||
});
|
||||
ChecksumOption({this.algorithm = '', this.expected = ''});
|
||||
|
||||
factory ChecksumOption.fromJson(Map<String, dynamic> json) => _$ChecksumOptionFromJson(json);
|
||||
|
||||
Map<String, dynamic> toJson() => _$ChecksumOptionToJson(this);
|
||||
}
|
||||
|
||||
@JsonSerializable()
|
||||
@JsonSerializable(explicitToJson: true)
|
||||
class OptsExtraHttp {
|
||||
int connections;
|
||||
bool? autoTorrent;
|
||||
|
||||
@@ -7,49 +7,61 @@ part of 'options.dart';
|
||||
// **************************************************************************
|
||||
|
||||
Options _$OptionsFromJson(Map<String, dynamic> json) => Options(
|
||||
size: (json['size'] as num?)?.toInt() ?? 0,
|
||||
name: json['name'] as String? ?? '',
|
||||
path: json['path'] as String? ?? '',
|
||||
asDefaultPath: json['asDefaultPath'] as bool? ?? false,
|
||||
selectFiles: (json['selectFiles'] as List<dynamic>?)?.map((e) => (e as num).toInt()).toList() ?? const [],
|
||||
skipAutoCategorize: json['skipAutoCategorize'] as bool? ?? false,
|
||||
selectFiles:
|
||||
(json['selectFiles'] as List<dynamic>?)
|
||||
?.map((e) => (e as num).toInt())
|
||||
.toList() ??
|
||||
const [],
|
||||
extra: json['extra'],
|
||||
);
|
||||
|
||||
Map<String, dynamic> _$OptionsToJson(Options instance) => <String, dynamic>{
|
||||
'size': instance.size,
|
||||
'name': instance.name,
|
||||
'path': instance.path,
|
||||
'asDefaultPath': instance.asDefaultPath,
|
||||
'skipAutoCategorize': instance.skipAutoCategorize,
|
||||
'selectFiles': instance.selectFiles,
|
||||
'extra': ?instance.extra,
|
||||
};
|
||||
|
||||
ChecksumOption _$ChecksumOptionFromJson(Map<String, dynamic> json) => ChecksumOption(
|
||||
algorithm: json['algorithm'] as String? ?? '',
|
||||
expected: json['expected'] as String? ?? '',
|
||||
);
|
||||
ChecksumOption _$ChecksumOptionFromJson(Map<String, dynamic> json) =>
|
||||
ChecksumOption(
|
||||
algorithm: json['algorithm'] as String? ?? '',
|
||||
expected: json['expected'] as String? ?? '',
|
||||
);
|
||||
|
||||
Map<String, dynamic> _$ChecksumOptionToJson(ChecksumOption instance) => <String, dynamic>{
|
||||
'algorithm': instance.algorithm,
|
||||
'expected': instance.expected,
|
||||
};
|
||||
Map<String, dynamic> _$ChecksumOptionToJson(ChecksumOption instance) =>
|
||||
<String, dynamic>{
|
||||
'algorithm': instance.algorithm,
|
||||
'expected': instance.expected,
|
||||
};
|
||||
|
||||
OptsExtraHttp _$OptsExtraHttpFromJson(Map<String, dynamic> json) => OptsExtraHttp(
|
||||
connections: (json['connections'] as num?)?.toInt() ?? 0,
|
||||
autoTorrent: json['autoTorrent'] as bool?,
|
||||
deleteTorrentAfterDownload: json['deleteTorrentAfterDownload'] as bool?,
|
||||
autoExtract: json['autoExtract'] as bool?,
|
||||
archivePassword: json['archivePassword'] as String? ?? '',
|
||||
deleteAfterExtract: json['deleteAfterExtract'] as bool? ?? false,
|
||||
checksum: json['checksum'] == null
|
||||
? null
|
||||
: ChecksumOption.fromJson(json['checksum'] as Map<String, dynamic>),
|
||||
);
|
||||
OptsExtraHttp _$OptsExtraHttpFromJson(Map<String, dynamic> json) =>
|
||||
OptsExtraHttp(
|
||||
connections: (json['connections'] as num?)?.toInt() ?? 0,
|
||||
autoTorrent: json['autoTorrent'] as bool?,
|
||||
deleteTorrentAfterDownload: json['deleteTorrentAfterDownload'] as bool?,
|
||||
autoExtract: json['autoExtract'] as bool?,
|
||||
archivePassword: json['archivePassword'] as String? ?? '',
|
||||
deleteAfterExtract: json['deleteAfterExtract'] as bool? ?? false,
|
||||
checksum: json['checksum'] == null
|
||||
? null
|
||||
: ChecksumOption.fromJson(json['checksum'] as Map<String, dynamic>),
|
||||
);
|
||||
|
||||
Map<String, dynamic> _$OptsExtraHttpToJson(OptsExtraHttp instance) => <String, dynamic>{
|
||||
'connections': instance.connections,
|
||||
'autoTorrent': ?instance.autoTorrent,
|
||||
'deleteTorrentAfterDownload': ?instance.deleteTorrentAfterDownload,
|
||||
'autoExtract': ?instance.autoExtract,
|
||||
'archivePassword': instance.archivePassword,
|
||||
'deleteAfterExtract': instance.deleteAfterExtract,
|
||||
'checksum': ?instance.checksum?.toJson(),
|
||||
};
|
||||
Map<String, dynamic> _$OptsExtraHttpToJson(OptsExtraHttp instance) =>
|
||||
<String, dynamic>{
|
||||
'connections': instance.connections,
|
||||
'autoTorrent': ?instance.autoTorrent,
|
||||
'deleteTorrentAfterDownload': ?instance.deleteTorrentAfterDownload,
|
||||
'autoExtract': ?instance.autoExtract,
|
||||
'archivePassword': instance.archivePassword,
|
||||
'deleteAfterExtract': instance.deleteAfterExtract,
|
||||
'checksum': ?instance.checksum?.toJson(),
|
||||
};
|
||||
@@ -0,0 +1,16 @@
|
||||
import 'options.dart';
|
||||
import 'request.dart';
|
||||
|
||||
class PatchTask {
|
||||
final Request? req;
|
||||
final Options? opts;
|
||||
|
||||
PatchTask({this.req, this.opts});
|
||||
|
||||
factory PatchTask.fromJson(Map<String, dynamic> json) => PatchTask(
|
||||
req: json['req'] == null ? null : Request.fromJson(json['req'] as Map<String, dynamic>),
|
||||
opts: json['opts'] == null ? null : Options.fromJson(json['opts'] as Map<String, dynamic>),
|
||||
);
|
||||
|
||||
Map<String, dynamic> toJson() => {'req': req?.toJson(), 'opts': opts?.toJson()};
|
||||
}
|
||||
@@ -9,7 +9,12 @@ class ResolveResult {
|
||||
String id;
|
||||
Resource res;
|
||||
|
||||
ResolveResult({this.id = "", required this.res});
|
||||
/// The category directory the created task would be routed to. Empty when
|
||||
/// auto categorize does not apply. It is a hint for the ui, the create call
|
||||
/// decides the final path.
|
||||
String suggestedDownloadDir;
|
||||
|
||||
ResolveResult({this.id = "", required this.res, this.suggestedDownloadDir = ""});
|
||||
|
||||
factory ResolveResult.fromJson(Map<String, dynamic> json) => _$ResolveResultFromJson(json);
|
||||
Map<String, dynamic> toJson() => _$ResolveResultToJson(this);
|
||||
|
||||
@@ -6,10 +6,14 @@ part of 'resolve_result.dart';
|
||||
// JsonSerializableGenerator
|
||||
// **************************************************************************
|
||||
|
||||
ResolveResult _$ResolveResultFromJson(Map<String, dynamic> json) =>
|
||||
ResolveResult(id: json['id'] as String? ?? "", res: Resource.fromJson(json['res'] as Map<String, dynamic>));
|
||||
ResolveResult _$ResolveResultFromJson(Map<String, dynamic> json) => ResolveResult(
|
||||
id: json['id'] as String? ?? "",
|
||||
res: Resource.fromJson(json['res'] as Map<String, dynamic>),
|
||||
suggestedDownloadDir: json['suggestedDownloadDir'] as String? ?? "",
|
||||
);
|
||||
|
||||
Map<String, dynamic> _$ResolveResultToJson(ResolveResult instance) => <String, dynamic>{
|
||||
'id': instance.id,
|
||||
'res': instance.res.toJson(),
|
||||
'suggestedDownloadDir': instance.suggestedDownloadDir,
|
||||
};
|
||||
@@ -1,6 +1,5 @@
|
||||
import 'package:json_annotation/json_annotation.dart';
|
||||
|
||||
import 'options.dart';
|
||||
import 'request.dart';
|
||||
|
||||
part 'resolve_task.g.dart';
|
||||
@@ -8,9 +7,8 @@ part 'resolve_task.g.dart';
|
||||
@JsonSerializable()
|
||||
class ResolveTask {
|
||||
Request? req;
|
||||
Options? opts;
|
||||
|
||||
ResolveTask({this.req, this.opts});
|
||||
ResolveTask({this.req});
|
||||
|
||||
factory ResolveTask.fromJson(Map<String, dynamic> json) => _$ResolveTaskFromJson(json);
|
||||
Map<String, dynamic> toJson() => _$ResolveTaskToJson(this);
|
||||
|
||||
@@ -7,11 +7,10 @@ part of 'resolve_task.dart';
|
||||
// **************************************************************************
|
||||
|
||||
ResolveTask _$ResolveTaskFromJson(Map<String, dynamic> json) => ResolveTask(
|
||||
req: json['req'] == null ? null : Request.fromJson(json['req'] as Map<String, dynamic>),
|
||||
opts: json['opts'] == null ? null : Options.fromJson(json['opts'] as Map<String, dynamic>),
|
||||
req: json['req'] == null
|
||||
? null
|
||||
: Request.fromJson(json['req'] as Map<String, dynamic>),
|
||||
);
|
||||
|
||||
Map<String, dynamic> _$ResolveTaskToJson(ResolveTask instance) => <String, dynamic>{
|
||||
'req': ?instance.req,
|
||||
'opts': ?instance.opts,
|
||||
};
|
||||
Map<String, dynamic> _$ResolveTaskToJson(ResolveTask instance) =>
|
||||
<String, dynamic>{'req': ?instance.req};
|
||||
@@ -64,6 +64,7 @@ class StoreExtension {
|
||||
final List<String> topics;
|
||||
final DateTime? createdAt;
|
||||
final DateTime? updatedAt;
|
||||
final bool hasDetails;
|
||||
|
||||
StoreExtension({
|
||||
required this.id,
|
||||
@@ -84,6 +85,7 @@ class StoreExtension {
|
||||
required this.topics,
|
||||
this.createdAt,
|
||||
this.updatedAt,
|
||||
this.hasDetails = true,
|
||||
});
|
||||
|
||||
factory StoreExtension.fromJson(Map<String, dynamic> json) {
|
||||
@@ -106,6 +108,7 @@ class StoreExtension {
|
||||
topics: _parseTopics(json['topics']),
|
||||
createdAt: _parseDate(json['createdAt']),
|
||||
updatedAt: _parseDate(json['updatedAt']),
|
||||
hasDetails: json.containsKey('readme'),
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -317,12 +317,7 @@ class AppPlatformController extends AsyncNotifier<AppPlatformState> with WindowL
|
||||
}
|
||||
|
||||
Future<void> startUpdate(VersionInfo versionInfo, UpdateProgressCallback onProgress) async {
|
||||
final runtime = ref.read(appRuntimeControllerProvider).value;
|
||||
await updateApp(
|
||||
versionInfo,
|
||||
githubMirror: runtime?.downloaderConfig.extra.githubMirror ?? ExtraConfigGithubMirror(enabled: false),
|
||||
onProgress: onProgress,
|
||||
);
|
||||
await updateApp(versionInfo, service: ref.read(gopeedServiceProvider), onProgress: onProgress);
|
||||
}
|
||||
|
||||
AppPlatformState _snapshot() => AppPlatformState(
|
||||
|
||||
@@ -1,12 +1,11 @@
|
||||
import 'dart:async';
|
||||
import 'dart:convert';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:path/path.dart' as path;
|
||||
import 'package:path_provider/path_provider.dart';
|
||||
|
||||
import '../../api/api.dart' as api;
|
||||
import '../../api/model/downloader_config.dart';
|
||||
import 'downloader_config_init.dart';
|
||||
import '../../core/capabilities/app_capabilities.dart';
|
||||
import '../../core/common/api_server_state.dart';
|
||||
import '../../core/common/start_config.dart';
|
||||
@@ -106,14 +105,9 @@ class AppRuntimeController extends AsyncNotifier<AppRuntimeState> {
|
||||
),
|
||||
);
|
||||
unawaited(LocationKeepAliveCoordinator.instance.reconcile(enabled: config.extra.backgroundLocationKeepAlive));
|
||||
unawaited(
|
||||
ContinuedProcessing.setEnabled(
|
||||
config.extra.backgroundContinuedProcessing,
|
||||
),
|
||||
);
|
||||
unawaited(ContinuedProcessing.setEnabled(config.extra.backgroundContinuedProcessing));
|
||||
}
|
||||
|
||||
|
||||
Future<AppRuntimeState> _init() async {
|
||||
await AppInitializer.ensureStorageInitialized();
|
||||
try {
|
||||
@@ -175,7 +169,6 @@ class AppRuntimeController extends AsyncNotifier<AppRuntimeState> {
|
||||
..address = config.api.address
|
||||
..apiToken = config.api.token;
|
||||
}
|
||||
unawaited(_initTrackerUpdate(config));
|
||||
unawaited(_initAndroidForegroundService(appLocalizationsFor(config.extra.locale)));
|
||||
await _initAnalytics(config);
|
||||
|
||||
@@ -190,9 +183,7 @@ class AppRuntimeController extends AsyncNotifier<AppRuntimeState> {
|
||||
// Make sure the native Continued Processing manager has the
|
||||
// persisted setting before app initialization completes. This
|
||||
// avoids the first download racing ahead of setEnabled(true).
|
||||
await ContinuedProcessing.setEnabled(
|
||||
config.extra.backgroundContinuedProcessing,
|
||||
);
|
||||
await ContinuedProcessing.setEnabled(config.extra.backgroundContinuedProcessing);
|
||||
|
||||
LocationKeepAliveCoordinator.instance.start(
|
||||
() =>
|
||||
@@ -251,83 +242,14 @@ class AppRuntimeController extends AsyncNotifier<AppRuntimeState> {
|
||||
return cfg;
|
||||
}
|
||||
|
||||
Future<DownloaderConfig> _loadDownloaderConfig() async {
|
||||
DownloaderConfig config;
|
||||
try {
|
||||
config = await ref.read(gopeedServiceProvider).getConfig();
|
||||
} catch (error, stackTrace) {
|
||||
logger.w('load downloader config failed', error, stackTrace);
|
||||
config = DownloaderConfig();
|
||||
}
|
||||
await _initDownloaderConfig(config);
|
||||
return config;
|
||||
}
|
||||
|
||||
Future<void> _initDownloaderConfig(DownloaderConfig config) async {
|
||||
final extra = config.extra;
|
||||
if (extra.themeMode.isEmpty) {
|
||||
extra.themeMode = 'system';
|
||||
}
|
||||
if (extra.themeColor.isEmpty) {
|
||||
extra.themeColor = 'green';
|
||||
}
|
||||
if (extra.bt.trackerSubscribeUrls.isEmpty) {
|
||||
extra.bt.trackerSubscribeUrls.addAll(allTrackerSubscribeUrls);
|
||||
}
|
||||
if (config.proxy.scheme.isEmpty) {
|
||||
config.proxy.scheme = 'http';
|
||||
}
|
||||
if (config.downloadDir.isEmpty) {
|
||||
config.downloadDir = await _defaultDownloadDir();
|
||||
}
|
||||
if (extra.downloadCategories.isEmpty) {
|
||||
extra.downloadCategories = [
|
||||
DownloadCategory(
|
||||
name: '',
|
||||
path: path.join(config.downloadDir, 'Music'),
|
||||
isBuiltIn: true,
|
||||
nameKey: 'categoryMusic',
|
||||
),
|
||||
DownloadCategory(
|
||||
name: '',
|
||||
path: path.join(config.downloadDir, 'Video'),
|
||||
isBuiltIn: true,
|
||||
nameKey: 'categoryVideo',
|
||||
),
|
||||
DownloadCategory(
|
||||
name: '',
|
||||
path: path.join(config.downloadDir, 'Document'),
|
||||
isBuiltIn: true,
|
||||
nameKey: 'categoryDocument',
|
||||
),
|
||||
DownloadCategory(
|
||||
name: '',
|
||||
path: path.join(config.downloadDir, 'Program'),
|
||||
isBuiltIn: true,
|
||||
nameKey: 'categoryProgram',
|
||||
),
|
||||
];
|
||||
}
|
||||
if (extra.githubMirror.mirrors.isEmpty) {
|
||||
extra.githubMirror.mirrors = [
|
||||
GithubMirror(type: GithubMirrorType.jsdelivr, url: 'https://fastly.jsdelivr.net/gh', isBuiltIn: true),
|
||||
GithubMirror(type: GithubMirrorType.ghProxy, url: 'https://fastgit.cc', isBuiltIn: true),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _initTrackerUpdate(DownloaderConfig config) async {
|
||||
final btExtra = config.extra.bt;
|
||||
final lastUpdateTime = btExtra.lastTrackerUpdateTime;
|
||||
if (!btExtra.autoUpdateTrackers ||
|
||||
(lastUpdateTime != null && !lastUpdateTime.isBefore(DateTime.now().subtract(const Duration(days: 1))))) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await updateTrackers(config);
|
||||
} catch (error, stackTrace) {
|
||||
logger.w('tracker update failed', error, stackTrace);
|
||||
}
|
||||
Future<DownloaderConfig> _loadDownloaderConfig() {
|
||||
return loadDownloaderConfig(
|
||||
load: () => ref.read(gopeedServiceProvider).getConfig(),
|
||||
save: (config) => ref.read(gopeedServiceProvider).putConfig(config),
|
||||
defaultDownloadDir: _defaultDownloadDir,
|
||||
onLoadError: (error, stackTrace) => logger.w('load downloader config failed', error, stackTrace),
|
||||
onSaveError: (error, stackTrace) => logger.w('persist downloader config failed', error, stackTrace),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _initAnalytics(DownloaderConfig config) async {
|
||||
@@ -344,41 +266,23 @@ class AppRuntimeController extends AsyncNotifier<AppRuntimeState> {
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> updateTrackers(DownloaderConfig config) async {
|
||||
final btExtra = config.extra.bt;
|
||||
final trackers = <String>[];
|
||||
for (final url in btExtra.trackerSubscribeUrls) {
|
||||
final resp = await api.proxyRequest(url);
|
||||
final data = resp.data;
|
||||
if (resp.statusCode == 200 && data != null && data.isNotEmpty) {
|
||||
trackers.addAll(const LineSplitter().convert(data).where((line) => line.trim().isNotEmpty));
|
||||
}
|
||||
}
|
||||
btExtra.subscribeTrackers = trackers;
|
||||
btExtra.lastTrackerUpdateTime = DateTime.now();
|
||||
config.protocolConfig.bt.trackers = {...btExtra.subscribeTrackers, ...btExtra.customTrackers}.toList();
|
||||
await ref.read(gopeedServiceProvider).putConfig(config);
|
||||
|
||||
final current = state.value;
|
||||
if (current != null) {
|
||||
state = AsyncValue.data(
|
||||
AppRuntimeState(
|
||||
startConfig: current.startConfig,
|
||||
apiServerState: current.apiServerState,
|
||||
downloaderConfig: config,
|
||||
localBackendStarted: current.localBackendStarted,
|
||||
startupError: current.startupError,
|
||||
),
|
||||
);
|
||||
Future<void> updateTrackers() async {
|
||||
final service = ref.read(gopeedServiceProvider);
|
||||
try {
|
||||
await service.triggerProtocolJob('bt', 'update-trackers');
|
||||
} finally {
|
||||
replaceDownloaderConfig(await service.getConfig());
|
||||
}
|
||||
}
|
||||
|
||||
Future<String> _defaultDownloadDir() async {
|
||||
// An unknown directory stays empty: the config is sent to the backend, and
|
||||
// a relative path would point at the backend working directory.
|
||||
if (kIsWeb) {
|
||||
return './';
|
||||
return '';
|
||||
}
|
||||
if (Util.isDesktop()) {
|
||||
return (await getDownloadsDirectory())?.path ?? './';
|
||||
return (await getDownloadsDirectory())?.path ?? '';
|
||||
}
|
||||
if (Util.isAndroid()) {
|
||||
return (await getExternalStorageDirectory())?.path ?? (await getApplicationDocumentsDirectory()).path;
|
||||
@@ -386,7 +290,7 @@ class AppRuntimeController extends AsyncNotifier<AppRuntimeState> {
|
||||
if (Util.isIOS()) {
|
||||
return (await getApplicationDocumentsDirectory()).path;
|
||||
}
|
||||
return './';
|
||||
return '';
|
||||
}
|
||||
|
||||
String _runningAddress(StartConfig config, int runningPort) {
|
||||
@@ -509,17 +413,3 @@ class AppRuntimeController extends AsyncNotifier<AppRuntimeState> {
|
||||
return operation;
|
||||
}
|
||||
}
|
||||
|
||||
const allTrackerSubscribeUrls = [
|
||||
'https://raw.githubusercontent.com/ngosang/trackerslist/master/trackers_all.txt',
|
||||
'https://raw.githubusercontent.com/ngosang/trackerslist/master/trackers_all_http.txt',
|
||||
'https://raw.githubusercontent.com/ngosang/trackerslist/master/trackers_all_https.txt',
|
||||
'https://raw.githubusercontent.com/ngosang/trackerslist/master/trackers_all_ip.txt',
|
||||
'https://raw.githubusercontent.com/ngosang/trackerslist/master/trackers_all_udp.txt',
|
||||
'https://raw.githubusercontent.com/ngosang/trackerslist/master/trackers_all_ws.txt',
|
||||
'https://raw.githubusercontent.com/ngosang/trackerslist/master/trackers_best.txt',
|
||||
'https://raw.githubusercontent.com/ngosang/trackerslist/master/trackers_best_ip.txt',
|
||||
'https://raw.githubusercontent.com/XIU2/TrackersListCollection/master/all.txt',
|
||||
'https://raw.githubusercontent.com/XIU2/TrackersListCollection/master/best.txt',
|
||||
'https://raw.githubusercontent.com/XIU2/TrackersListCollection/master/http.txt',
|
||||
];
|
||||
@@ -0,0 +1,92 @@
|
||||
import 'package:path/path.dart' as path;
|
||||
|
||||
import '../../api/model/downloader_config.dart';
|
||||
|
||||
/// Reads the downloader config, applies first-launch defaults and persists
|
||||
/// the result when the init changed it. Persisting is skipped when the
|
||||
/// initial load failed: writing defaults over an unreadable store would
|
||||
/// wipe the user's configuration.
|
||||
Future<DownloaderConfig> loadDownloaderConfig({
|
||||
required Future<DownloaderConfig> Function() load,
|
||||
required Future<void> Function(DownloaderConfig config) save,
|
||||
required Future<String> Function() defaultDownloadDir,
|
||||
void Function(Object error, StackTrace stackTrace)? onLoadError,
|
||||
void Function(Object error, StackTrace stackTrace)? onSaveError,
|
||||
}) async {
|
||||
DownloaderConfig config;
|
||||
var loaded = true;
|
||||
try {
|
||||
config = await load();
|
||||
} catch (error, stackTrace) {
|
||||
onLoadError?.call(error, stackTrace);
|
||||
config = DownloaderConfig();
|
||||
loaded = false;
|
||||
}
|
||||
final changed = await initDownloaderConfig(config, defaultDownloadDir: defaultDownloadDir);
|
||||
if (loaded && changed) {
|
||||
// Persist the seeded categories so the backend can route
|
||||
// downloads to them right away instead of waiting for the settings page
|
||||
// to save the config.
|
||||
try {
|
||||
await save(config);
|
||||
} catch (error, stackTrace) {
|
||||
onSaveError?.call(error, stackTrace);
|
||||
}
|
||||
}
|
||||
return config;
|
||||
}
|
||||
|
||||
/// Applies defaults to a freshly loaded config: theme/tracker/proxy basics,
|
||||
/// the default download directory, and seeding the built-in categories when
|
||||
/// nothing exists yet. Returns whether the config changed and should be
|
||||
/// persisted.
|
||||
Future<bool> initDownloaderConfig(
|
||||
DownloaderConfig config, {
|
||||
required Future<String> Function() defaultDownloadDir,
|
||||
}) async {
|
||||
var changed = false;
|
||||
final extra = config.extra;
|
||||
if (extra.themeMode.isEmpty) {
|
||||
extra.themeMode = 'system';
|
||||
}
|
||||
if (extra.themeColor.isEmpty) {
|
||||
extra.themeColor = 'green';
|
||||
}
|
||||
if (config.proxy.scheme.isEmpty) {
|
||||
config.proxy.scheme = 'http';
|
||||
}
|
||||
if (config.downloadDir.isEmpty) {
|
||||
final downloadDir = await defaultDownloadDir();
|
||||
// An unknown directory stays empty, the backend resolves its own.
|
||||
if (downloadDir.isNotEmpty) {
|
||||
config.downloadDir = downloadDir;
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
if (config.categories.isEmpty && config.downloadDir.isNotEmpty) {
|
||||
config.categories = [
|
||||
_builtinCategory('categoryMusic', path.join(config.downloadDir, 'Music')),
|
||||
_builtinCategory('categoryVideo', path.join(config.downloadDir, 'Video')),
|
||||
_builtinCategory('categoryDocument', path.join(config.downloadDir, 'Document')),
|
||||
_builtinCategory('categoryProgram', path.join(config.downloadDir, 'Program')),
|
||||
];
|
||||
changed = true;
|
||||
}
|
||||
if (config.githubMirror.mirrors.isEmpty) {
|
||||
config.githubMirror.mirrors = [
|
||||
GithubMirror(type: GithubMirrorType.jsdelivr, url: 'https://fastly.jsdelivr.net/gh', isBuiltIn: true),
|
||||
GithubMirror(type: GithubMirrorType.ghProxy, url: 'https://fastgit.cc', isBuiltIn: true),
|
||||
];
|
||||
}
|
||||
return changed;
|
||||
}
|
||||
|
||||
DownloadCategory _builtinCategory(String nameKey, String categoryPath) {
|
||||
return DownloadCategory(
|
||||
name: '',
|
||||
path: categoryPath,
|
||||
isBuiltIn: true,
|
||||
nameKey: nameKey,
|
||||
extensions: List<String>.from(kDefaultCategoryExtensions[nameKey] ?? const []),
|
||||
);
|
||||
}
|
||||
@@ -8,6 +8,7 @@ import '../../api/model/extension.dart';
|
||||
import '../../api/model/install_extension.dart';
|
||||
import '../../api/model/resolve_result.dart';
|
||||
import '../../api/model/resolve_task.dart';
|
||||
import '../../api/model/patch_task.dart';
|
||||
import '../../api/model/switch_extension.dart';
|
||||
import '../../api/model/task.dart';
|
||||
import '../../api/model/update_check_extension_resp.dart';
|
||||
@@ -81,11 +82,19 @@ RpcCodecRegistry createAppCapabilityCodecs() {
|
||||
|
||||
void _bindGopeed(CapabilityRegistry registry) {
|
||||
registry
|
||||
..bind(GopeedMethods.triggerProtocolJob, (p) async {
|
||||
await api.triggerProtocolJob(p['protocol'] as String, p['name'] as String);
|
||||
return const RpcUnit();
|
||||
})
|
||||
..bind(
|
||||
GopeedMethods.getMirrorCandidates,
|
||||
(p) => api.getMirrorCandidates(p['provider'] as String, p['url'] as String),
|
||||
)
|
||||
..bind(GopeedMethods.resolve, api.resolve)
|
||||
..bind(GopeedMethods.createTask, api.createTask)
|
||||
..bind(GopeedMethods.createTaskBatch, api.createTaskBatch)
|
||||
..bind(GopeedMethods.patchTask, (params) async {
|
||||
await api.patchTask(params['id'] as String, ResolveTask.fromJson(params['request'] as Map<String, dynamic>));
|
||||
await api.patchTask(params['id'] as String, PatchTask.fromJson(params['request'] as Map<String, dynamic>));
|
||||
return const RpcUnit();
|
||||
})
|
||||
..bind(
|
||||
|
||||
@@ -5,6 +5,7 @@ import '../../api/model/extension.dart';
|
||||
import '../../api/model/install_extension.dart';
|
||||
import '../../api/model/resolve_result.dart';
|
||||
import '../../api/model/resolve_task.dart';
|
||||
import '../../api/model/patch_task.dart';
|
||||
import '../../api/model/switch_extension.dart';
|
||||
import '../../api/model/task.dart';
|
||||
import '../../api/model/update_check_extension_resp.dart';
|
||||
@@ -12,6 +13,8 @@ import '../../api/model/update_extension_settings.dart';
|
||||
import 'capability_rpc.dart';
|
||||
|
||||
abstract final class GopeedMethods {
|
||||
static const triggerProtocolJob = RpcMethod<Map<String, dynamic>, RpcUnit>('gopeed.protocol.job.trigger');
|
||||
static const getMirrorCandidates = RpcMethod<Map<String, dynamic>, List<String>>('gopeed.mirrors.candidates');
|
||||
static const resolve = RpcMethod<ResolveTask, ResolveResult>('gopeed.resolve');
|
||||
static const createTask = RpcMethod<CreateTask, String>('gopeed.task.create');
|
||||
static const createTaskBatch = RpcMethod<CreateTaskBatch, List<String>>('gopeed.task.createBatch');
|
||||
@@ -42,6 +45,10 @@ class GopeedService {
|
||||
|
||||
final CapabilityInvoker _invoker;
|
||||
|
||||
Future<void> triggerProtocolJob(String protocol, String name) async =>
|
||||
_invoker.invoke(GopeedMethods.triggerProtocolJob, {'protocol': protocol, 'name': name});
|
||||
Future<List<String>> getMirrorCandidates(String provider, String url) =>
|
||||
_invoker.invoke(GopeedMethods.getMirrorCandidates, {'provider': provider, 'url': url});
|
||||
Future<ResolveResult> resolve(ResolveTask request) => _invoker.invoke(GopeedMethods.resolve, request);
|
||||
|
||||
Future<String> createTask(CreateTask request) => _invoker.invoke(GopeedMethods.createTask, request);
|
||||
@@ -49,7 +56,7 @@ class GopeedService {
|
||||
Future<List<String>> createTaskBatch(CreateTaskBatch request) =>
|
||||
_invoker.invoke(GopeedMethods.createTaskBatch, request);
|
||||
|
||||
Future<void> patchTask(String id, ResolveTask request) async {
|
||||
Future<void> patchTask(String id, PatchTask request) async {
|
||||
await _invoker.invoke(GopeedMethods.patchTask, {'id': id, 'request': request.toJson()});
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,147 @@
|
||||
import 'package:dio/dio.dart';
|
||||
import 'package:pub_semver/pub_semver.dart';
|
||||
|
||||
import '../../api/gopeed_site_api.dart';
|
||||
|
||||
class VersionInfo {
|
||||
const VersionInfo({
|
||||
required this.version,
|
||||
required this.changeLog,
|
||||
required this.releaseUrl,
|
||||
this.assetSha256 = const {},
|
||||
});
|
||||
|
||||
final String version;
|
||||
final String changeLog;
|
||||
final String releaseUrl;
|
||||
// Populated only from official release metadata, never from a download mirror.
|
||||
final Map<String, String> assetSha256;
|
||||
}
|
||||
|
||||
/// Query the official GitHub API directly, then the independent website API
|
||||
/// only if the GitHub request or its response validation fails.
|
||||
class AppUpdateClient {
|
||||
AppUpdateClient({Dio? client, GopeedSiteApi? site})
|
||||
: _client =
|
||||
client ??
|
||||
Dio(BaseOptions(connectTimeout: const Duration(seconds: 10), receiveTimeout: const Duration(seconds: 30))),
|
||||
_site = site ?? GopeedSiteApi.instance;
|
||||
|
||||
static const releasesUrl = 'https://api.github.com/repos/GopeedLab/gopeed/releases?per_page=10';
|
||||
final Dio _client;
|
||||
final GopeedSiteApi _site;
|
||||
|
||||
Future<List<_Release>> _fetchGithubReleases() async {
|
||||
final cancel = CancelToken();
|
||||
try {
|
||||
final response = await _client
|
||||
.get<dynamic>(releasesUrl, cancelToken: cancel, options: Options(followRedirects: false))
|
||||
.timeout(const Duration(seconds: 30));
|
||||
return _decodeReleases(response.data);
|
||||
} finally {
|
||||
cancel.cancel('GitHub release request completed');
|
||||
}
|
||||
}
|
||||
|
||||
Future<VersionInfo?> check(String currentVersion) async {
|
||||
final current = _parseVersion(currentVersion);
|
||||
if (current == null) throw FormatException('Invalid current application version', currentVersion);
|
||||
List<_Release> releases;
|
||||
try {
|
||||
releases = await _fetchGithubReleases();
|
||||
} catch (_) {
|
||||
releases = _decodeReleases(await _site.getReleases());
|
||||
}
|
||||
_Release? selected;
|
||||
for (final release in releases) {
|
||||
final version = release.version;
|
||||
if (release.draft || version.compareTo(current) <= 0) continue;
|
||||
final preview = release.prerelease || version.isPreRelease;
|
||||
if (!current.isPreRelease && preview) continue;
|
||||
if (current.isPreRelease &&
|
||||
preview &&
|
||||
(version.major != current.major || version.minor != current.minor || version.patch != current.patch)) {
|
||||
continue;
|
||||
}
|
||||
if (selected == null || version.compareTo(selected.version) > 0) selected = release;
|
||||
}
|
||||
if (selected == null) return null;
|
||||
return VersionInfo(
|
||||
version: selected.tag.replaceFirst(RegExp(r'^v'), ''),
|
||||
changeLog: selected.body,
|
||||
assetSha256: Map.unmodifiable(selected.assetSha256),
|
||||
releaseUrl: selected.url.isEmpty
|
||||
? 'https://github.com/GopeedLab/gopeed/releases/tag/${selected.tag}'
|
||||
: selected.url,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Build metadata does not affect SemVer precedence.
|
||||
Version? _parseVersion(String raw) {
|
||||
try {
|
||||
final version = Version.parse(raw.replaceFirst(RegExp(r'^v'), ''));
|
||||
return Version(version.major, version.minor, version.patch, pre: version.preRelease.join('.'));
|
||||
} on FormatException {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
List<_Release> _decodeReleases(dynamic data) {
|
||||
if (data is! List) throw const FormatException('Invalid release list');
|
||||
final releases = <_Release>[];
|
||||
for (final entry in data) {
|
||||
if (entry is! Map || entry['tag_name'] is! String) continue;
|
||||
final tag = entry['tag_name'] as String;
|
||||
final version = _parseVersion(tag);
|
||||
if (version == null) continue;
|
||||
releases.add(
|
||||
_Release(
|
||||
tag: tag,
|
||||
version: version,
|
||||
draft: entry['draft'] == true,
|
||||
prerelease: entry['prerelease'] == true,
|
||||
body: entry['body'] as String? ?? '',
|
||||
url: entry['html_url'] as String? ?? '',
|
||||
assetSha256: _decodeAssetSha256(entry['assets']),
|
||||
),
|
||||
);
|
||||
}
|
||||
if (data.isNotEmpty && releases.isEmpty) throw const FormatException('Response contains no valid releases');
|
||||
return releases;
|
||||
}
|
||||
|
||||
Map<String, String> _decodeAssetSha256(dynamic assets) {
|
||||
if (assets == null) return {};
|
||||
if (assets is! List) throw const FormatException('Invalid release assets');
|
||||
final result = <String, String>{};
|
||||
for (final asset in assets) {
|
||||
if (asset is! Map || asset['name'] is! String || asset['digest'] is! String) continue;
|
||||
final digest = asset['digest'] as String;
|
||||
final match = RegExp(r'^sha256:([a-fA-F0-9]{64})$').firstMatch(digest);
|
||||
if (match == null || match.end != digest.length) continue;
|
||||
final name = asset['name'] as String;
|
||||
if (result.containsKey(name)) throw const FormatException('Duplicate release asset');
|
||||
result[name] = match.group(1)!.toLowerCase();
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
class _Release {
|
||||
const _Release({
|
||||
required this.tag,
|
||||
required this.version,
|
||||
required this.draft,
|
||||
required this.prerelease,
|
||||
required this.body,
|
||||
required this.url,
|
||||
required this.assetSha256,
|
||||
});
|
||||
final String tag;
|
||||
final Version version;
|
||||
final bool draft;
|
||||
final bool prerelease;
|
||||
final String body;
|
||||
final String url;
|
||||
final Map<String, String> assetSha256;
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:crypto/crypto.dart';
|
||||
import 'package:dio/dio.dart';
|
||||
|
||||
class DownloadChecksumException implements Exception {
|
||||
const DownloadChecksumException();
|
||||
@override
|
||||
String toString() => 'Update package checksum verification failed';
|
||||
}
|
||||
|
||||
/// Probes backend-provided URLs concurrently, then downloads the fastest usable
|
||||
/// candidate. Other candidates remain available if HEAD is unsupported or the
|
||||
/// actual download fails. This layer has no GitHub URL or mirror-config rules.
|
||||
Future<void> downloadFromCandidates(
|
||||
List<String> candidates,
|
||||
String savePath, {
|
||||
required ProgressCallback onReceiveProgress,
|
||||
required String expectedSha256,
|
||||
Dio? client,
|
||||
Duration probeTimeout = const Duration(seconds: 3),
|
||||
}) async {
|
||||
// Reject missing or malformed trusted metadata before any network request.
|
||||
if (expectedSha256.length != 64 || !RegExp(r'^[a-fA-F0-9]{64}$').hasMatch(expectedSha256)) {
|
||||
throw const DownloadChecksumException();
|
||||
}
|
||||
final urls = candidates.toSet().toList(growable: false);
|
||||
if (urls.isEmpty) throw ArgumentError.value(candidates, 'candidates', 'No download candidates');
|
||||
final dio = client ?? Dio(BaseOptions(connectTimeout: const Duration(seconds: 10)));
|
||||
try {
|
||||
final probes = await Future.wait(
|
||||
urls.map((url) async {
|
||||
final watch = Stopwatch()..start();
|
||||
final cancel = CancelToken();
|
||||
try {
|
||||
final response = await dio
|
||||
.head<void>(
|
||||
url,
|
||||
cancelToken: cancel,
|
||||
options: Options(receiveTimeout: probeTimeout, validateStatus: (status) => status == 200),
|
||||
)
|
||||
.timeout(probeTimeout);
|
||||
if (_isPackageResponse(response.headers)) return (url: url, latency: watch.elapsed);
|
||||
} catch (_) {
|
||||
// HEAD failure does not prove that a normal GET will fail.
|
||||
} finally {
|
||||
cancel.cancel();
|
||||
}
|
||||
return (url: url, latency: null);
|
||||
}),
|
||||
);
|
||||
final available = probes.where((probe) => probe.latency != null).toList()
|
||||
..sort((a, b) => a.latency!.compareTo(b.latency!));
|
||||
final ordered = [
|
||||
...available.map((probe) => probe.url),
|
||||
...probes.where((p) => p.latency == null).map((p) => p.url),
|
||||
];
|
||||
Object? lastError;
|
||||
StackTrace? lastStack;
|
||||
for (final url in ordered) {
|
||||
try {
|
||||
onReceiveProgress(0, 0);
|
||||
final response = await dio.download(
|
||||
url,
|
||||
savePath,
|
||||
onReceiveProgress: onReceiveProgress,
|
||||
deleteOnError: true,
|
||||
options: Options(receiveTimeout: const Duration(seconds: 30), validateStatus: (status) => status == 200),
|
||||
);
|
||||
if (!_isPackageResponse(response.headers) || await File(savePath).length() == 0) {
|
||||
throw StateError('Invalid download response from $url');
|
||||
}
|
||||
final actual = await sha256.bind(File(savePath).openRead()).first;
|
||||
if (actual.toString() != expectedSha256.toLowerCase()) throw const DownloadChecksumException();
|
||||
return;
|
||||
} catch (error, stack) {
|
||||
lastError = error;
|
||||
lastStack = stack;
|
||||
final file = File(savePath);
|
||||
if (await file.exists()) await file.delete();
|
||||
}
|
||||
}
|
||||
Error.throwWithStackTrace(lastError!, lastStack!);
|
||||
} finally {
|
||||
if (client == null) dio.close(force: true);
|
||||
}
|
||||
}
|
||||
|
||||
bool _isPackageResponse(Headers headers) {
|
||||
final contentType = headers.value(Headers.contentTypeHeader)?.split(';').first.trim().toLowerCase();
|
||||
return contentType != 'text/html' && contentType != 'application/xhtml+xml' && contentType != 'application/json';
|
||||
}
|
||||
@@ -1,7 +1,5 @@
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:dio/dio.dart';
|
||||
|
||||
import '../../../../util/util.dart';
|
||||
import '../../../ffi/libgopeed_worker.dart';
|
||||
import '../../../libgopeed_boot.dart';
|
||||
@@ -10,17 +8,6 @@ import '../gopeed_transport.dart';
|
||||
GopeedTransport createTransport(GopeedTransportConfig config) => NativeGopeedTransport();
|
||||
|
||||
class NativeGopeedTransport implements GopeedTransport {
|
||||
NativeGopeedTransport()
|
||||
: _externalDio = Dio(
|
||||
BaseOptions(
|
||||
contentType: Headers.jsonContentType,
|
||||
sendTimeout: const Duration(seconds: 5),
|
||||
connectTimeout: const Duration(seconds: 5),
|
||||
receiveTimeout: const Duration(seconds: 120),
|
||||
),
|
||||
);
|
||||
|
||||
final Dio _externalDio;
|
||||
final LibgopeedWorker? _ffiWorker = Util.isDesktop() ? LibgopeedWorker() : null;
|
||||
|
||||
@override
|
||||
@@ -45,15 +32,6 @@ class NativeGopeedTransport implements GopeedTransport {
|
||||
return jsonDecode(payload);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<Response<String>> proxyRequest(String uri, {dynamic data, Options? options}) {
|
||||
return _externalDio.request<String>(
|
||||
uri,
|
||||
data: data,
|
||||
options: (options ?? Options()).copyWith(responseType: ResponseType.plain),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
String join(String path) => path;
|
||||
}
|
||||
|
||||
@@ -8,8 +8,7 @@ class RestGopeedTransport implements GopeedTransport {
|
||||
RestGopeedTransport(GopeedTransportConfig config)
|
||||
: _dio = Dio(
|
||||
BaseOptions(
|
||||
// Keep release requests relative so deployments under a reverse
|
||||
// proxy path continue to work, matching the legacy Web client.
|
||||
// Keep requests relative for deployments under a reverse proxy path.
|
||||
baseUrl: webTransportBaseUrl(config.address, Uri.base),
|
||||
contentType: Headers.jsonContentType,
|
||||
sendTimeout: const Duration(seconds: 5),
|
||||
@@ -53,18 +52,6 @@ class RestGopeedTransport implements GopeedTransport {
|
||||
return response.data;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<Response<String>> proxyRequest(String uri, {dynamic data, Options? options}) {
|
||||
options ??= Options();
|
||||
options.headers ??= {};
|
||||
options.headers!['X-Target-Uri'] = uri;
|
||||
return _dio.request<String>(
|
||||
'/api/web/proxy?t=${DateTime.now().millisecondsSinceEpoch}',
|
||||
data: data,
|
||||
options: options.copyWith(responseType: ResponseType.plain),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
String join(String path) {
|
||||
final baseUrl = _dio.options.baseUrl;
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import 'package:dio/dio.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
|
||||
import 'entry/gopeed_transport_stub.dart'
|
||||
@@ -60,8 +59,6 @@ String normalizeWebApiBaseUrl(String address, Uri pageUri) {
|
||||
abstract interface class GopeedTransport {
|
||||
Future<dynamic> request(String path, {String method = 'GET', dynamic data, Map<String, dynamic>? queryParameters});
|
||||
|
||||
Future<Response<String>> proxyRequest(String uri, {dynamic data, Options? options});
|
||||
|
||||
String join(String path);
|
||||
}
|
||||
|
||||
|
||||
Loaded 100 of 176 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user