mirror of
https://github.com/GopeedLab/gopeed.git
synced 2026-11-06 00:25:43 +00:00
fix(bt): honor selected files when creating resolved tasks
This commit is contained in:
1 parent
16f2d30476
commit
5bb040eaaf
11 files changed
+265
-20
No files matched your search
@@ -0,0 +1,54 @@
|
||||
package bt
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/GopeedLab/gopeed/internal/fetcher"
|
||||
"github.com/GopeedLab/gopeed/pkg/base"
|
||||
"github.com/anacrolix/torrent"
|
||||
)
|
||||
|
||||
func TestStartUsesSelectionSubmittedAfterResolve(t *testing.T) {
|
||||
config := torrent.TestingConfig(t)
|
||||
config.DataDir = t.TempDir()
|
||||
config.DisableTCP = true
|
||||
config.DisableUTP = true
|
||||
c, err := torrent.NewClient(config)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer c.Close()
|
||||
tor, err := c.AddTorrentFromFile("testdata/test.torrent")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
f := &Fetcher{torrent: tor, meta: &fetcher.FetcherMeta{Opts: &base.Options{}}, data: &fetcherData{}}
|
||||
f.torrentReady.Store(true)
|
||||
f.updateRes()
|
||||
// Resolve initializes all files; creation replaces the selection before Start.
|
||||
f.meta.Opts = &base.Options{SelectFiles: []int{2}}
|
||||
if err := f.Start(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for i, file := range tor.Files() {
|
||||
want := torrent.PiecePriorityNone
|
||||
if i == 2 {
|
||||
want = torrent.PiecePriorityNormal
|
||||
}
|
||||
if file.Priority() != want {
|
||||
t.Errorf("file %d priority = %v, want %v", i, file.Priority(), want)
|
||||
}
|
||||
}
|
||||
if len(f.data.Progress) != 1 {
|
||||
t.Fatalf("progress tracks %d files, want 1", len(f.data.Progress))
|
||||
}
|
||||
if err := f.Pause(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := f.Start(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if tor.Files()[0].Priority() != torrent.PiecePriorityNone {
|
||||
t.Fatal("resume selected an unselected file")
|
||||
}
|
||||
}
|
||||
@@ -363,20 +363,9 @@ func (f *Fetcher) Resolve(req *base.Request, opts *base.Options) error {
|
||||
f.meta.Opts = &base.Options{}
|
||||
}
|
||||
|
||||
// Parse options
|
||||
if err := base.ParseOptExtra[fhttp.OptsExtra](opts); err != nil {
|
||||
if err := f.initOptions(); err != nil {
|
||||
return err
|
||||
}
|
||||
if opts.Extra == nil {
|
||||
opts.Extra = &fhttp.OptsExtra{}
|
||||
}
|
||||
extra := opts.Extra.(*fhttp.OptsExtra)
|
||||
if extra.Connections <= 0 {
|
||||
extra.Connections = f.config.Connections
|
||||
if extra.Connections <= 0 {
|
||||
extra.Connections = 1
|
||||
}
|
||||
}
|
||||
|
||||
f.setState(stateResolving)
|
||||
|
||||
@@ -626,7 +615,31 @@ func (f *Fetcher) cleanupPrefetchFile() {
|
||||
f.resolveFallback.Store(false)
|
||||
}
|
||||
|
||||
// Options may be replaced between Resolve and Start when creating a resolved task.
|
||||
func (f *Fetcher) initOptions() error {
|
||||
opts := f.meta.Opts
|
||||
// Parse options
|
||||
if err := base.ParseOptExtra[fhttp.OptsExtra](opts); err != nil {
|
||||
return err
|
||||
}
|
||||
if opts.Extra == nil {
|
||||
opts.Extra = &fhttp.OptsExtra{}
|
||||
}
|
||||
extra := opts.Extra.(*fhttp.OptsExtra)
|
||||
if extra.Connections <= 0 {
|
||||
extra.Connections = f.config.Connections
|
||||
if extra.Connections <= 0 {
|
||||
extra.Connections = 1
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (f *Fetcher) Start() error {
|
||||
if err := f.initOptions(); err != nil {
|
||||
return err
|
||||
}
|
||||
state := f.getState()
|
||||
|
||||
switch state {
|
||||
|
||||
+1
-1
@@ -156,7 +156,7 @@ func (s *Service) createTask(ctx *Context) *Response {
|
||||
err error
|
||||
)
|
||||
if request.Rid != "" {
|
||||
taskID, err = s.Downloader.Create(request.Rid)
|
||||
taskID, err = s.Downloader.CreateWithOptions(request.Rid, request.Opts)
|
||||
} else if request.Req != nil {
|
||||
taskID, err = s.Downloader.CreateDirect(request.Req, request.Opts)
|
||||
} else {
|
||||
|
||||
@@ -582,12 +582,36 @@ func (d *Downloader) CreateDirectBatch(req *base.CreateTaskBatch) (taskId []stri
|
||||
}
|
||||
|
||||
func (d *Downloader) Create(rrId string) (taskId string, err error) {
|
||||
return d.CreateWithOptions(rrId, nil)
|
||||
}
|
||||
|
||||
// CreateWithOptions creates a resolved task using the submitted options, or the
|
||||
// resolve-time 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]
|
||||
d.fetcherMapLock.RUnlock()
|
||||
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 res := fetcher.Meta().Res; res != nil {
|
||||
res.CalcSize(fetcher.Meta().Opts.SelectFiles)
|
||||
}
|
||||
defer func() {
|
||||
d.fetcherMapLock.Lock()
|
||||
delete(d.fetcherCache, rrId)
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
package download
|
||||
|
||||
import (
|
||||
"reflect"
|
||||
"testing"
|
||||
|
||||
"github.com/GopeedLab/gopeed/internal/fetcher"
|
||||
"github.com/GopeedLab/gopeed/pkg/base"
|
||||
)
|
||||
|
||||
func TestCreateResolvedFileSelection(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
selection []int
|
||||
submit bool
|
||||
want []int
|
||||
size int64
|
||||
}{
|
||||
{"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},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
manager := &generationTestManager{}
|
||||
d := NewDownloader(&DownloaderConfig{FetchManagers: []fetcher.FetcherManager{manager}})
|
||||
if err := d.Setup(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
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)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
d.fetcherCache[rr.ID].Meta().Res = &base.Resource{Name: "bundle", Files: []*base.FileInfo{
|
||||
{Name: "a", Size: 10}, {Name: "b", Size: 20}, {Name: "c", Size: 30},
|
||||
}}
|
||||
var id string
|
||||
if tc.submit {
|
||||
id, err = d.CreateWithOptions(rr.ID, &base.Options{Path: original.Path, SelectFiles: tc.selection})
|
||||
} else {
|
||||
id, err = d.Create(rr.ID)
|
||||
}
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
task := d.GetTask(id)
|
||||
if !reflect.DeepEqual(task.Meta.Opts.SelectFiles, tc.want) {
|
||||
t.Fatalf("selection = %v, want %v", task.Meta.Opts.SelectFiles, tc.want)
|
||||
}
|
||||
if task.Meta.Res.Size != tc.size {
|
||||
t.Fatalf("queued size = %d, want %d", task.Meta.Res.Size, tc.size)
|
||||
}
|
||||
var saved Task
|
||||
if _, err := d.storage.Get(bucketTask, id, &saved); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if saved.Meta.Res.Size != tc.size || !reflect.DeepEqual(saved.Meta.Opts.SelectFiles, tc.want) {
|
||||
t.Fatalf("persisted metadata = %+v", saved.Meta)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -315,7 +315,7 @@ func registerTools(server *mcp.Server, downloader *download.Downloader) {
|
||||
)
|
||||
switch {
|
||||
case input.RID != "":
|
||||
id, err = downloader.Create(input.RID)
|
||||
id, err = downloader.CreateWithOptions(input.RID, input.Opts.baseOptions())
|
||||
case input.Req != nil:
|
||||
var req *base.Request
|
||||
req, err = input.Req.baseRequest()
|
||||
|
||||
+1
-1
@@ -43,7 +43,7 @@ func CreateTask(w http.ResponseWriter, r *http.Request) {
|
||||
err error
|
||||
)
|
||||
if req.Rid != "" {
|
||||
taskId, err = Downloader.Create(req.Rid)
|
||||
taskId, err = Downloader.CreateWithOptions(req.Rid, req.Opts)
|
||||
} else if req.Req != nil {
|
||||
taskId, err = Downloader.CreateDirect(req.Req, req.Opts)
|
||||
} else {
|
||||
|
||||
@@ -120,6 +120,34 @@ func TestCreateTask(t *testing.T) {
|
||||
})
|
||||
}
|
||||
|
||||
func TestCreateResolvedTaskUsesSubmittedOptions(t *testing.T) {
|
||||
doTest(func() {
|
||||
done := make(chan *download.Event, 1)
|
||||
Downloader.Listener(func(event *download.Event) {
|
||||
if event.Key == download.EventKeyFinally {
|
||||
done <- event
|
||||
}
|
||||
})
|
||||
resolved := httpRequestCheckOk[*download.ResolveResult](http.MethodPost, "/api/v1/resolve", resolveReq)
|
||||
opts := createOpts.Clone()
|
||||
opts.Name = "selected-options.bin"
|
||||
opts.SelectFiles = []int{0}
|
||||
id := httpRequestCheckOk[string](http.MethodPost, "/api/v1/tasks", &model.CreateTask{Rid: resolved.ID, Opts: opts})
|
||||
select {
|
||||
case <-done:
|
||||
case <-time.After(10 * time.Second):
|
||||
t.Fatal("resolved task did not finish")
|
||||
}
|
||||
task := Downloader.GetTask(id)
|
||||
if task.Meta.Opts.Name != opts.Name || !reflect.DeepEqual(task.Meta.Opts.SelectFiles, opts.SelectFiles) {
|
||||
t.Fatalf("submitted options were lost: %+v", task.Meta.Opts)
|
||||
}
|
||||
if got := test.FileMd5(filepath.Join(opts.Path, opts.Name)); got != test.FileMd5(test.BuildFile) {
|
||||
t.Fatalf("download using submitted options has wrong checksum: %s", got)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestCreateDirectTask(t *testing.T) {
|
||||
doTest(func() {
|
||||
var wg sync.WaitGroup
|
||||
|
||||
@@ -36,8 +36,15 @@ enum TaskAssetType {
|
||||
}
|
||||
|
||||
class TaskFileNode {
|
||||
const TaskFileNode({required this.path, required this.name, required this.sizeBytes, this.downloadedBytes});
|
||||
const TaskFileNode({
|
||||
required this.path,
|
||||
required this.name,
|
||||
required this.sizeBytes,
|
||||
this.downloadedBytes,
|
||||
this.resourceIndex,
|
||||
});
|
||||
|
||||
final int? resourceIndex;
|
||||
final String path;
|
||||
final String name;
|
||||
final int sizeBytes;
|
||||
@@ -146,7 +153,7 @@ class TaskRecord {
|
||||
totalBytes: totalBytes > 0 ? totalBytes : null,
|
||||
speedBytes: task.progress.speed,
|
||||
uploadedBytes: task.progress.uploaded,
|
||||
files: _fileNodes(task.meta.res?.files),
|
||||
files: _fileNodes(task.meta.res?.files, task.meta.opts.selectFiles),
|
||||
uploading: task.uploading,
|
||||
isFolder: task.meta.res?.name.isNotEmpty ?? false,
|
||||
protocol: task.protocol,
|
||||
@@ -313,12 +320,23 @@ int? _remainingSeconds(api_task.Task task, int totalBytes, int downloadedBytes)
|
||||
return ((totalBytes - downloadedBytes) / speed).ceil();
|
||||
}
|
||||
|
||||
List<TaskFileNode> _fileNodes(List<api_resource.FileInfo>? files) {
|
||||
List<TaskFileNode> _fileNodes(List<api_resource.FileInfo>? files, List<int> selectedIndexes) {
|
||||
if (files == null || files.isEmpty) {
|
||||
return const [];
|
||||
}
|
||||
final selected = selectedIndexes.toSet();
|
||||
return files
|
||||
.map((file) => TaskFileNode(path: file.path, name: file.name, sizeBytes: file.size))
|
||||
.asMap()
|
||||
.entries
|
||||
.where((entry) => selected.isEmpty || selected.contains(entry.key))
|
||||
.map(
|
||||
(entry) => TaskFileNode(
|
||||
resourceIndex: entry.key,
|
||||
path: entry.value.path,
|
||||
name: entry.value.name,
|
||||
sizeBytes: entry.value.size,
|
||||
),
|
||||
)
|
||||
.toList(growable: false);
|
||||
}
|
||||
|
||||
|
||||
@@ -77,7 +77,7 @@ class _TaskFileTreeState extends State<TaskFileTree> {
|
||||
.entries
|
||||
.map(
|
||||
(entry) => FileTreeItem<TaskFileNode>(
|
||||
key: entry.key.toString(),
|
||||
key: (entry.value.resourceIndex ?? entry.key).toString(),
|
||||
path: entry.value.path,
|
||||
name: entry.value.name,
|
||||
size: entry.value.sizeBytes,
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:gopeed/api/model/task.dart' as api;
|
||||
import 'package:gopeed/features/tasks/domain/task_record.dart';
|
||||
|
||||
void main() {
|
||||
api.Task buildTask(List<int> selection, int size) => api.Task.fromJson({
|
||||
'id': 'bt-selection',
|
||||
'name': 'bundle',
|
||||
'protocol': 'bt',
|
||||
'status': 'running',
|
||||
'uploading': false,
|
||||
'createdAt': '2026-01-01T00:00:00Z',
|
||||
'updatedAt': '2026-01-01T00:00:00Z',
|
||||
'meta': {
|
||||
'req': {'url': 'magnet:?xt=urn:btih:test'},
|
||||
'opts': {'path': '/downloads', 'selectFiles': selection},
|
||||
'res': {
|
||||
'name': 'bundle',
|
||||
'size': size,
|
||||
'files': [
|
||||
{'name': 'a.txt', 'size': 10},
|
||||
{'name': 'b.txt', 'size': 20},
|
||||
{'name': 'c.txt', 'path': 'nested', 'size': 30},
|
||||
],
|
||||
},
|
||||
},
|
||||
'progress': {'used': 0, 'speed': 0, 'downloaded': 10, 'uploadSpeed': 0, 'uploaded': 0},
|
||||
});
|
||||
|
||||
test('selected files retain original indexes for runtime progress', () {
|
||||
final record = TaskRecord.fromApi(buildTask([2, 0], 40));
|
||||
expect(record.files.map((file) => file.name), ['a.txt', 'c.txt']);
|
||||
expect(record.files.map((file) => file.resourceIndex), [0, 2]);
|
||||
expect(record.files.last.path, 'nested');
|
||||
expect(record.totalBytes, 40);
|
||||
expect(record.progress, 0.25);
|
||||
});
|
||||
|
||||
test('empty selection preserves default all files', () {
|
||||
final record = TaskRecord.fromApi(buildTask([], 60));
|
||||
expect(record.files.length, 3);
|
||||
expect(record.totalBytes, 60);
|
||||
});
|
||||
}
|
||||
Reference in new issue
Block a user