From 1ab452ced8bbe144f2e952e4ed74570ca934756b Mon Sep 17 00:00:00 2001 From: Levi Date: Fri, 18 Sep 2026 22:32:09 +0800 Subject: [PATCH] fix(bt): honor selected files when creating resolved tasks (#1523) --- internal/protocol/bt/fetcher.go | 65 +++- internal/protocol/bt/selection_test.go | 302 ++++++++++++++++++ internal/protocol/http/fetcher.go | 37 ++- pkg/api/service.go | 2 +- pkg/download/downloader.go | 24 ++ pkg/download/selection_test.go | 64 ++++ pkg/mcpserver/server.go | 2 +- pkg/rest/api.go | 2 +- pkg/rest/server_test.go | 28 ++ .../features/tasks/domain/task_record.dart | 26 +- .../presentation/widgets/task_file_tree.dart | 2 +- .../tasks/domain/task_record_test.dart | 40 +++ 12 files changed, 573 insertions(+), 21 deletions(-) create mode 100644 internal/protocol/bt/selection_test.go create mode 100644 pkg/download/selection_test.go diff --git a/internal/protocol/bt/fetcher.go b/internal/protocol/bt/fetcher.go index 46be76c6..73b5175b 100644 --- a/internal/protocol/bt/fetcher.go +++ b/internal/protocol/bt/fetcher.go @@ -399,7 +399,10 @@ func (f *Fetcher) Wait() (err error) { } } if !selected { - util.SafeRemove(filepath.Join(f.meta.Opts.Path, f.meta.Res.Name, file.Path())) + root, name := torrentFileLayout(f.torrent.Info(), file.FileInfo()) + if err := removeUnselectedFile(f.meta.Opts.Path, root, name); err != nil { + return err + } } } return @@ -409,6 +412,62 @@ func (f *Fetcher) Wait() (err error) { } } +// 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) { + parts := file.BestPath() + if info.BestName() != metainfo.NoName { + if len(parts) > 0 { + root = info.BestName() + } + parts = append([]string{info.BestName()}, parts...) + } + // A v2 file tree can itself have a named root without an extra name wrapper. + if root == "" && info.HasV2() && len(info.FileTree.Dir) == 1 { + for dir, tree := range info.FileTree.Dir { + if tree.IsDir() { + root = dir + } + } + } + return root, filepath.Join(parts...) +} + +// Preserve the actual torrent root, or the download directory for rootless +// torrents. Only prune empty directories strictly below that boundary. +func removeUnselectedFile(downloadDir, torrentRoot, torrentPath string) error { + if !filepath.IsLocal(torrentPath) || (torrentRoot != "" && !filepath.IsLocal(torrentRoot)) { + return fmt.Errorf("invalid torrent file path %q", torrentPath) + } + root := filepath.Join(downloadDir, torrentRoot) + name := filepath.Join(downloadDir, torrentPath) + rel, err := filepath.Rel(root, name) + if err != nil || rel == "." || !filepath.IsLocal(rel) { + return fmt.Errorf("torrent file %q is outside cleanup root %q", name, root) + } + for _, candidate := range []string{name, name + ".part"} { + if err := util.SafeRemove(candidate); err != nil { + return fmt.Errorf("remove unselected torrent file %q: %w", candidate, err) + } + } + for dir := filepath.Dir(name); dir != root && dir != filepath.Dir(dir); dir = filepath.Dir(dir) { + entries, err := os.ReadDir(dir) + if os.IsNotExist(err) { + continue + } + if err != nil { + return fmt.Errorf("read torrent directory %q: %w", dir, err) + } + if len(entries) != 0 { + break + } + if err := util.SafeRemove(dir); err != nil { + return fmt.Errorf("remove empty torrent directory %q: %w", dir, err) + } + } + return nil +} + func (f *Fetcher) isDone() bool { if f.meta.Opts == nil { return false @@ -638,6 +697,10 @@ func (f *Fetcher) addTorrent(req *base.Request, fromUpload bool) (err error) { } 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 }, diff --git a/internal/protocol/bt/selection_test.go b/internal/protocol/bt/selection_test.go new file mode 100644 index 00000000..8c2f9773 --- /dev/null +++ b/internal/protocol/bt/selection_test.go @@ -0,0 +1,302 @@ +package bt + +import ( + "context" + "crypto/sha1" + "fmt" + "os" + "path/filepath" + "testing" + "time" + + "github.com/GopeedLab/gopeed/internal/fetcher" + "github.com/GopeedLab/gopeed/pkg/base" + "github.com/anacrolix/torrent" + "github.com/anacrolix/torrent/bencode" + "github.com/anacrolix/torrent/metainfo" + "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) + 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") + } +} + +func TestWaitCleansUnselectedFilesFromSharedPiece(t *testing.T) { + for _, mode := range []string{"partial", "part only", "both paths", "all", "already removed", "remove error"} { + t.Run(mode, func(t *testing.T) { + dir := t.TempDir() + keepPath := filepath.Join(dir, "bundle", "keep.txt") + skipPath := filepath.Join(dir, "bundle", "nested", "skip.txt") + if err := os.MkdirAll(filepath.Dir(skipPath), 0755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(keepPath, []byte("keep"), 0644); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(skipPath, []byte("skip"), 0644); err != nil { + t.Fatal(err) + } + // Both files occupy the same piece, so downloading keep.txt also writes skip.txt. + hash := sha1.Sum([]byte("keepskip")) + info := metainfo.Info{Name: "bundle", PieceLength: 16, Pieces: hash[:], Files: []metainfo.FileInfo{ + {Length: 4, Path: []string{"keep.txt"}}, + {Length: 4, Path: []string{"nested", "skip.txt"}}, + }} + store := storage.NewFileOpts(storage.NewFileClientOpts{ + ClientBaseDir: dir, + TorrentDirMaker: func(baseDir string, _ *metainfo.Info, _ metainfo.Hash) string { return baseDir }, + }) + defer store.Close() + config := torrent.TestingConfig(t) + config.DisableTCP = true + config.DisableUTP = true + c, err := torrent.NewClient(config) + if err != nil { + t.Fatal(err) + } + defer c.Close() + spec, err := torrent.TorrentSpecFromMetaInfoErr(&metainfo.MetaInfo{InfoBytes: bencode.MustMarshal(&info)}) + if err != nil { + t.Fatal(err) + } + spec.Storage = store + tor, _, err := c.AddTorrentSpec(spec) + if err != nil { + t.Fatal(err) + } + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + if err := tor.VerifyDataContext(ctx); err != nil { + t.Fatal(err) + } + f := &Fetcher{torrent: tor, meta: &fetcher.FetcherMeta{Opts: &base.Options{Path: dir, SelectFiles: []int{0}}}, data: &fetcherData{}, torrentDropCtx: ctx} + f.torrentReady.Store(true) + f.updateRes() + if mode == "all" { + f.meta.Opts.SelectFiles = []int{0, 1} + } + if !f.isDone() { + t.Fatal("selected file is not complete") + } + if mode == "part only" || mode == "both paths" || mode == "all" { + if err := os.WriteFile(skipPath+".part", []byte("skip"), 0644); err != nil { + t.Fatal(err) + } + } + if mode == "part only" || mode == "already removed" || mode == "remove error" { + if err := os.Remove(skipPath); err != nil { + t.Fatal(err) + } + } + if mode == "remove error" { + if err := os.Mkdir(skipPath, 0755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(skipPath, "child"), []byte("preserve"), 0644); err != nil { + t.Fatal(err) + } + } + err = f.Wait() + if ctx.Err() != nil { + t.Fatal(ctx.Err()) + } + if mode == "remove error" { + if err == nil { + t.Fatal("cleanup failure was silently ignored") + } + } else if err != nil { + t.Fatal(err) + } + data, err := os.ReadFile(keepPath) + if err != nil || string(data) != "keep" { + t.Fatalf("selected file changed: %q, %v", data, err) + } + if mode == "all" { + if _, err := os.Stat(skipPath + ".part"); err != nil { + t.Fatalf("selected part file was removed: %v", err) + } + data, err := os.ReadFile(skipPath) + if err != nil || string(data) != "skip" { + t.Fatalf("selected file changed: %q, %v", data, err) + } + } else if mode != "remove error" { + for _, removed := range []string{skipPath, skipPath + ".part", filepath.Dir(skipPath)} { + if _, err := os.Stat(removed); !os.IsNotExist(err) { + t.Fatalf("unselected file or empty directory remains: %s: %v", removed, err) + } + } + } + }) + } +} + +func TestRemoveUnselectedFilePrunesOnlyEmptyParents(t *testing.T) { + for _, preserveSibling := range []bool{false, true} { + t.Run(fmt.Sprint(preserveSibling), func(t *testing.T) { + root := t.TempDir() + relative := filepath.Join("bundle", "nested", "deep", "skip.txt") + name := filepath.Join(root, relative) + if err := os.MkdirAll(filepath.Dir(name), 0755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(name+".part", []byte("partial"), 0644); err != nil { + t.Fatal(err) + } + sibling := filepath.Join(root, "bundle", "keep.txt") + if preserveSibling { + if err := os.WriteFile(sibling, []byte("keep"), 0644); err != nil { + t.Fatal(err) + } + } + if err := removeUnselectedFile(root, "bundle", relative); err != nil { + t.Fatal(err) + } + if _, err := os.Stat(filepath.Join(root, "bundle", "nested")); !os.IsNotExist(err) { + t.Fatalf("empty ancestors remain: %v", err) + } + if preserveSibling { + data, err := os.ReadFile(sibling) + if err != nil || string(data) != "keep" { + t.Fatalf("sibling changed: %q, %v", data, err) + } + } + if info, err := os.Stat(filepath.Join(root, "bundle")); err != nil || !info.IsDir() { + t.Fatalf("torrent root directory was removed: %v", err) + } + if _, err := os.Stat(root); err != nil { + t.Fatalf("download directory was removed: %v", err) + } + // A file directly inside the torrent root must not cause that root to be pruned. + rootFile := filepath.Join("bundle", "root.txt") + if err := os.WriteFile(filepath.Join(root, rootFile)+".part", []byte("partial"), 0644); err != nil { + t.Fatal(err) + } + if err := removeUnselectedFile(root, "bundle", rootFile); err != nil { + t.Fatal(err) + } + if _, err := os.Stat(filepath.Join(root, "bundle")); err != nil { + t.Fatalf("torrent root directory was removed after root-level cleanup: %v", err) + } + // Repeating cleanup after the file and its subdirectories are gone is harmless. + if err := removeUnselectedFile(root, "bundle", relative); err != nil { + t.Fatal(err) + } + }) + } +} + +func TestCleanupMatchesNamedAndRootlessStorage(t *testing.T) { + for _, version := range []int64{1, 2} { + for _, layout := range []string{"rootless", "wrapped", "file-tree-root"} { + if version == 1 && layout == "file-tree-root" { + continue + } + t.Run(fmt.Sprintf("v%d/%s", version, layout), func(t *testing.T) { + dir := t.TempDir() + info := metainfo.Info{Name: metainfo.NoName, PieceLength: 16384} + root := dir + if layout == "wrapped" { + info.Name = "bundle" + root = filepath.Join(dir, "bundle") + } + if version == 1 { + info.Files = []metainfo.FileInfo{{Path: []string{"keep.txt"}}, {Path: []string{"nested", "deep", "skip.txt"}}} + } else { + info.MetaVersion = 2 + info.FileTree = metainfo.FileTree{Dir: map[string]metainfo.FileTree{ + "keep.txt": {}, + "nested": {Dir: map[string]metainfo.FileTree{"deep": {Dir: map[string]metainfo.FileTree{"skip.txt": {}}}}}, + }} + } + if layout == "file-tree-root" { + info.FileTree = metainfo.FileTree{Dir: map[string]metainfo.FileTree{"tree-root": info.FileTree}} + root = filepath.Join(dir, "tree-root") + } + // Keep the library's default FilePathMaker here to independently verify + // that cleanup agrees with its real disk layout, including NoName. + store := storage.NewFileOpts(storage.NewFileClientOpts{ + ClientBaseDir: dir, + TorrentDirMaker: func(baseDir string, _ *metainfo.Info, _ metainfo.Hash) string { return baseDir }, + }) + defer store.Close() + torrentStorage, err := store.OpenTorrent(context.Background(), &info, metainfo.Hash{1}) + if err != nil { + t.Fatal(err) + } + defer torrentStorage.Close() + skip := filepath.Join(root, "nested", "deep", "skip.txt") + if _, err := os.Stat(skip); err != nil { + t.Fatalf("unexpected storage layout: %v", err) + } + if err := os.WriteFile(skip+".part", []byte("partial"), 0644); err != nil { + t.Fatal(err) + } + files := info.UpvertedFiles() + boundary, name := torrentFileLayout(&info, files[1]) + if err := removeUnselectedFile(dir, boundary, name); err != nil { + t.Fatal(err) + } + if _, err := os.Stat(filepath.Join(root, "nested")); !os.IsNotExist(err) { + t.Fatalf("empty subtree remains: %v", err) + } + if _, err := os.Stat(filepath.Join(root, "keep.txt")); err != nil { + t.Fatalf("selected file removed: %v", err) + } + // Also verify the boundary when the protected directory becomes empty. + if err := os.Remove(filepath.Join(root, "keep.txt")); err != nil { + t.Fatal(err) + } + for _, file := range files { + boundary, name := torrentFileLayout(&info, file) + if err := removeUnselectedFile(dir, boundary, name); err != nil { + t.Fatal(err) + } + } + if stat, err := os.Stat(root); err != nil || !stat.IsDir() { + t.Fatalf("cleanup boundary was removed: %v", err) + } + }) + } + } +} diff --git a/internal/protocol/http/fetcher.go b/internal/protocol/http/fetcher.go index 59aefc19..7922c003 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) @@ -648,7 +637,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 3b7c901e..4d60c7ee 100644 --- a/pkg/download/downloader.go +++ b/pkg/download/downloader.go @@ -617,12 +617,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 9a5de0e5..bd066ddb 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, @@ -314,12 +321,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 9953f394..b50655e4 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 index 8b5c72ca..17a8e4be 100644 --- a/ui/flutter/test/features/tasks/domain/task_record_test.dart +++ b/ui/flutter/test/features/tasks/domain/task_record_test.dart @@ -1,8 +1,48 @@ import 'package:flutter_test/flutter_test.dart'; +import 'package:gopeed/api/model/task.dart' as api; import 'package:gopeed/core/icons/gopeed_icons.dart'; 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); + }); + test('file names preserve hash and question mark characters for type detection', () { expect( taskFileTypeIcon('Slow English Podcast #2 _ Eating, Talking, and Learning Natural English.mp4'),