Compare commits

..
176 changed files with 14649 additions and 2178 deletions

No files matched your search

+19 -4
View File
@@ -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
+17
View File
@@ -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
View File
@@ -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
View File
@@ -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()
+1 -1
View File
@@ -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
+1 -1
View File
@@ -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.)
+72
View File
@@ -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)
}
+73
View File
@@ -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.
+90
View File
@@ -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
}
+38
View File
@@ -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)
}
}
+160
View File
@@ -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...))
}
+214
View File
@@ -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)
}
}
+27 -2
View File
@@ -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"`
}
+464
View File
@@ -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
View File
@@ -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) {
+68 -17
View File
@@ -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
}
+212
View File
@@ -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()
}
+70 -10
View File
@@ -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)
}
+116
View File
@@ -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")
}
+234
View File
@@ -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)
}
}
+217
View File
@@ -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)
}
}
+2 -5
View File
@@ -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
}
+2 -1
View File
@@ -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)
}
+2 -6
View File
@@ -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,
+16 -8
View File
@@ -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
+6 -3
View File
@@ -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 {
+15 -7
View File
@@ -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
View File
@@ -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)
}
+38 -15
View File
@@ -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)
}
})
}
+10 -3
View File
@@ -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
+112 -71
View File
@@ -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)
}
+4 -2
View File
@@ -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 {
+245
View File
@@ -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
View File
@@ -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
View File
@@ -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 {
+269
View File
@@ -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)
}
}
+11
View File
@@ -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()
}
+109
View File
@@ -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
View File
@@ -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)
+245
View File
@@ -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):
}
}
+186
View File
@@ -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
}
+566
View File
@@ -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)
}
}
+695
View File
@@ -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
View File
@@ -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()
+89 -26
View File
@@ -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)
}
+17 -1
View File
@@ -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
View File
@@ -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
+2 -2
View File
@@ -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)
}
+210
View File
@@ -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")
}
}
+62
View File
@@ -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)
}
}
+158
View File
@@ -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)
}
})
}
}
+212
View File
@@ -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)
}
})
}
}
+24 -63
View File
@@ -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)
}
+151
View File
@@ -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
}
+1 -1
View File
@@ -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)
}
+1 -1
View File
@@ -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)
}
+35
View File
@@ -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)
}
+13
View File
@@ -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
+172
View File
@@ -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)
}
+250
View File
@@ -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 := &registeredJob{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
}
+89
View File
@@ -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())
}
}
+340
View File
@@ -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")
}
}
+12 -9
View File
@@ -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)
}
+4 -4
View File
@@ -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)
+59
View File
@@ -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",
"ſ",
"Ä"
]
}
]
}
+12 -10
View File
@@ -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
View File
@@ -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{
+22
View File
@@ -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
View File
@@ -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"]
+9
View File
@@ -0,0 +1,9 @@
package model
type MirrorCandidatesReq struct {
URL string `json:"url"`
}
type MirrorCandidatesResp struct {
URLs []string `json:"urls"`
}
+4
View File
@@ -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
View File
@@ -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
View File
@@ -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)
+1 -1
View File
@@ -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
+15 -6
View File
@@ -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),
);
+62 -40
View File
@@ -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});
}
}
+135 -28
View File
@@ -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(),
};
+17 -6
View File
@@ -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;
+41 -29
View File
@@ -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(),
};
+16
View File
@@ -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()};
}
+6 -1
View File
@@ -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 -3
View File
@@ -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);
+5 -6
View File
@@ -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