diff --git a/internal/protocol/bt/selection_test.go b/internal/protocol/bt/selection_test.go new file mode 100644 index 00000000..519a8a4f --- /dev/null +++ b/internal/protocol/bt/selection_test.go @@ -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") + } +} diff --git a/internal/protocol/http/fetcher.go b/internal/protocol/http/fetcher.go index cb168cf3..4fb75ede 100644 --- a/internal/protocol/http/fetcher.go +++ b/internal/protocol/http/fetcher.go @@ -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 { diff --git a/pkg/api/service.go b/pkg/api/service.go index b69a9fe9..db888ca9 100644 --- a/pkg/api/service.go +++ b/pkg/api/service.go @@ -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 { diff --git a/pkg/download/downloader.go b/pkg/download/downloader.go index 72953a8e..4d98bdb6 100644 --- a/pkg/download/downloader.go +++ b/pkg/download/downloader.go @@ -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) diff --git a/pkg/download/selection_test.go b/pkg/download/selection_test.go new file mode 100644 index 00000000..e97a65b1 --- /dev/null +++ b/pkg/download/selection_test.go @@ -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) + } + }) + } +} diff --git a/pkg/mcpserver/server.go b/pkg/mcpserver/server.go index 62ca0a0c..1c6a954e 100644 --- a/pkg/mcpserver/server.go +++ b/pkg/mcpserver/server.go @@ -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() diff --git a/pkg/rest/api.go b/pkg/rest/api.go index cd65b194..7e6d2e3c 100644 --- a/pkg/rest/api.go +++ b/pkg/rest/api.go @@ -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 { diff --git a/pkg/rest/server_test.go b/pkg/rest/server_test.go index 65dd55b2..553dd381 100644 --- a/pkg/rest/server_test.go +++ b/pkg/rest/server_test.go @@ -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 diff --git a/ui/flutter/lib/features/tasks/domain/task_record.dart b/ui/flutter/lib/features/tasks/domain/task_record.dart index 44a5a426..d5c15b12 100644 --- a/ui/flutter/lib/features/tasks/domain/task_record.dart +++ b/ui/flutter/lib/features/tasks/domain/task_record.dart @@ -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 _fileNodes(List? files) { +List _fileNodes(List? files, List 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); } diff --git a/ui/flutter/lib/features/tasks/presentation/widgets/task_file_tree.dart b/ui/flutter/lib/features/tasks/presentation/widgets/task_file_tree.dart index 88745fc6..489e2aa8 100644 --- a/ui/flutter/lib/features/tasks/presentation/widgets/task_file_tree.dart +++ b/ui/flutter/lib/features/tasks/presentation/widgets/task_file_tree.dart @@ -77,7 +77,7 @@ class _TaskFileTreeState extends State { .entries .map( (entry) => FileTreeItem( - key: entry.key.toString(), + key: (entry.value.resourceIndex ?? entry.key).toString(), path: entry.value.path, name: entry.value.name, size: entry.value.sizeBytes, diff --git a/ui/flutter/test/features/tasks/domain/task_record_test.dart b/ui/flutter/test/features/tasks/domain/task_record_test.dart new file mode 100644 index 00000000..a1abb037 --- /dev/null +++ b/ui/flutter/test/features/tasks/domain/task_record_test.dart @@ -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 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); + }); +}