diff --git a/internal/fetcher/fetcher.go b/internal/fetcher/fetcher.go index cda8b430..2805ab08 100644 --- a/internal/fetcher/fetcher.go +++ b/internal/fetcher/fetcher.go @@ -3,6 +3,7 @@ package fetcher import ( "github.com/monkeyWie/gopeed/internal/controller" "github.com/monkeyWie/gopeed/pkg/base" + "path" ) // Fetcher defines the interface for a download protocol. @@ -36,6 +37,14 @@ type FetcherMeta struct { Opts *base.Options `json:"opts"` } +func (m *FetcherMeta) Filepath(file *base.FileInfo) string { + finalName := m.Opts.Name + if finalName == "" { + finalName = file.Name + } + return path.Join(m.Opts.Path, m.Res.RootDir, finalName) +} + // FetcherBuilder defines the interface for a fetcher builder. type FetcherBuilder interface { // Schemes returns the schemes supported by the fetcher. diff --git a/internal/protocol/bt/fetcher.go b/internal/protocol/bt/fetcher.go index f22a1dc8..69d7aafc 100644 --- a/internal/protocol/bt/fetcher.go +++ b/internal/protocol/bt/fetcher.go @@ -8,6 +8,7 @@ import ( "github.com/monkeyWie/gopeed/internal/fetcher" "github.com/monkeyWie/gopeed/pkg/base" "github.com/monkeyWie/gopeed/pkg/util" + "path" "path/filepath" "sync" "sync/atomic" @@ -74,15 +75,16 @@ func (f *Fetcher) Resolve(req *base.Request) error { } defer f.torrent.Drop() res := &base.Resource{ - Name: f.torrent.Name(), - Range: true, - Files: make([]*base.FileInfo, len(f.torrent.Files())), - Hash: f.torrent.InfoHash().String(), + Name: f.torrent.Name(), + Range: true, + RootDir: f.torrent.Name(), + Files: make([]*base.FileInfo, len(f.torrent.Files())), + Hash: f.torrent.InfoHash().String(), } for i, file := range f.torrent.Files() { res.Files[i] = &base.FileInfo{ Name: filepath.Base(file.DisplayPath()), - Path: util.Dir(file.Path()), + Path: util.Dir(path.Join(f.torrent.Info().Name, file.Path())), Size: file.Length(), } res.Size += file.Length() @@ -101,7 +103,7 @@ func (f *Fetcher) Create(opts *base.Options) (err error) { } } if opts.Path != "" { - f.torrentPaths[f.meta.Res.Hash] = f.meta.Opts.Path + f.torrentPaths[f.meta.Res.Hash] = path.Join(f.meta.Opts.Path, f.meta.Res.RootDir) } f.progress = make(fetcher.Progress, len(f.meta.Opts.SelectFiles)) diff --git a/internal/protocol/bt/fetcher_test.go b/internal/protocol/bt/fetcher_test.go index 3eb20cf3..84e34f9f 100644 --- a/internal/protocol/bt/fetcher_test.go +++ b/internal/protocol/bt/fetcher_test.go @@ -33,6 +33,7 @@ func doResolve(t *testing.T, fetcher fetcher.Fetcher) { Files: []*base.FileInfo{ { Name: "ubuntu-22.04-live-server-amd64.iso", + Path: "ubuntu-22.04-live-server-amd64.iso", Size: 1466714112, }, }, diff --git a/internal/protocol/http/fetcher.go b/internal/protocol/http/fetcher.go index 1d75c92e..bc14e3d4 100644 --- a/internal/protocol/http/fetcher.go +++ b/internal/protocol/http/fetcher.go @@ -8,7 +8,6 @@ import ( "github.com/monkeyWie/gopeed/internal/fetcher" "github.com/monkeyWie/gopeed/pkg/base" fhttp "github.com/monkeyWie/gopeed/pkg/protocol/http" - "github.com/monkeyWie/gopeed/pkg/util" "golang.org/x/sync/errgroup" "io" "mime" @@ -245,7 +244,7 @@ func (f *Fetcher) Wait() (err error) { } func (f *Fetcher) filepath() string { - return util.Filepath(f.meta.Opts.Path, f.meta.Res.Files[0].Name, f.meta.Opts.Name) + return f.meta.Filepath(f.meta.Res.Files[0]) } func (f *Fetcher) fetch() { diff --git a/pkg/base/model.go b/pkg/base/model.go index 81093d42..c722a405 100644 --- a/pkg/base/model.go +++ b/pkg/base/model.go @@ -1,27 +1,25 @@ package base -import "github.com/monkeyWie/gopeed/pkg/util" +import ( + "github.com/monkeyWie/gopeed/pkg/util" +) -// 下载请求 +// Request download request type Request struct { - // 下载链接 - URL string `json:"url"` - // 附加信息 - Extra any `json:"extra"` + URL string `json:"url"` + Extra any `json:"extra"` } -// 资源信息 +// Resource download resource type Resource struct { - // 资源名称 Name string `json:"name"` - // 资源总大小 - Size int64 `json:"size"` - // 是否支持断点下载 - Range bool `json:"range"` - // 资源所包含的文件列表 + Size int64 `json:"size"` + // is support range download + Range bool `json:"range"` + RootDir string `json:"rootDir"` + // file list Files []*FileInfo `json:"files"` - // 资源hash值 - Hash string `json:"hash"` + Hash string `json:"hash"` } type FileInfo struct { @@ -30,15 +28,15 @@ type FileInfo struct { Size int64 `json:"size"` } -// 下载选项 +// Options for download type Options struct { - // 保存文件名 + // Download file name Name string `json:"name"` - // 保存目录 + // Download file path Path string `json:"path"` - // 选择下载的文件下标列表 + // Select file indexes to download SelectFiles []int `json:"selectFiles"` - // 附加信息 + // Extra info for specific fetcher Extra any `json:"extra"` } diff --git a/pkg/download/downloader.go b/pkg/download/downloader.go index 1f77d517..daf326b0 100644 --- a/pkg/download/downloader.go +++ b/pkg/download/downloader.go @@ -7,6 +7,8 @@ import ( "github.com/monkeyWie/gopeed/internal/fetcher" "github.com/monkeyWie/gopeed/pkg/base" "github.com/monkeyWie/gopeed/pkg/util" + "os" + "path" "strings" "sync" "sync/atomic" @@ -200,10 +202,12 @@ func (d *Downloader) Create(rrId string, opts *base.Options) (taskId string, err if !ok { return "", errors.New("invalid resource id") } - delete(d.fetcherMap, rrId) + meta := fetcher.Meta() + meta.Opts = opts + res := meta.Res if len(opts.SelectFiles) == 0 { opts.SelectFiles = make([]int, 0) - for i := range fetcher.Meta().Res.Files { + for i := range res.Files { opts.SelectFiles = append(opts.SelectFiles, i) } } @@ -221,6 +225,25 @@ func (d *Downloader) Create(rrId string, opts *base.Options) (taskId string, err if err != nil { return } + + // check if the download file is duplicated and rename it automatically. + files := res.Files + if len(files) == 1 { + fullPath := meta.Filepath(files[0]) + newName, err := util.CheckDuplicateAndRename(fullPath) + if err != nil { + return "", err + } + opts.Name = newName + } else if res.RootDir != "" { + fullDirPath := path.Join(opts.Path, res.RootDir) + newName, err := util.CheckDuplicateAndRename(fullDirPath) + if err != nil { + return "", err + } + res.RootDir = newName + } + err = fetcher.Create(opts) if err != nil { return @@ -234,7 +257,7 @@ func (d *Downloader) Create(rrId string, opts *base.Options) (taskId string, err task.Status = base.DownloadStatusRunning // calculate select files size for _, selectIndex := range opts.SelectFiles { - task.Size += fetcher.Meta().Res.Files[selectIndex].Size + task.Size += res.Files[selectIndex].Size } initTask(task) task.timer.Start() @@ -252,7 +275,6 @@ func (d *Downloader) Create(rrId string, opts *base.Options) (taskId string, err } d.emit(EventKeyStart, task) taskId = task.ID - go func() { err := fetcher.Start() if err != nil { @@ -261,6 +283,7 @@ func (d *Downloader) Create(rrId string, opts *base.Options) (taskId string, err } d.watch(task) }() + delete(d.fetcherMap, rrId) return } @@ -336,11 +359,17 @@ func (d *Downloader) Delete(id string, force bool) (err error) { return } if force { - names := make([]string, 0) - for _, file := range task.Meta.Res.Files { - names = append(names, util.Filepath(file.Path, file.Name, task.Meta.Opts.Name)) + if task.Meta.Res.RootDir != "" { + if err = os.RemoveAll(path.Join(task.Meta.Opts.Path, task.Meta.Res.RootDir)); err != nil { + return + } + } else { + for _, file := range task.Meta.Res.Files { + if err = util.SafeRemove(task.Meta.Filepath(file)); err != nil { + return err + } + } } - util.SafeRemoveAll(task.Meta.Opts.Path, names) } d.emit(EventKeyDelete, task) task = nil diff --git a/pkg/util/path.go b/pkg/util/path.go index bd61eaee..9f9fc1ed 100644 --- a/pkg/util/path.go +++ b/pkg/util/path.go @@ -2,9 +2,11 @@ package util import ( "errors" + "fmt" "io" "os" syspath "path" + "strings" ) func Dir(path string) string { @@ -30,39 +32,55 @@ func SafeRemove(name string) error { return nil } -// SafeRemoveAll remove file and parent directories safely -func SafeRemoveAll(path string, names []string) error { - for _, name := range names { - err := SafeRemove(syspath.Join(path, name)) - if err != nil { - return err +// CheckDuplicateAndRename rename duplicate file, add suffix (1) (2) ... +// if file name is a.txt, rename to a (1).txt +// if directory name is a, rename to a (1) +// return new name +func CheckDuplicateAndRename(path string) (string, error) { + dir := syspath.Dir(path) + name := syspath.Base(path) + + // if file not exists, return directly + _, err := os.Stat(path) + if err != nil { + if errors.Is(err, os.ErrNotExist) { + return name, nil } - if err := safeRemoveParent(path, name); err != nil { - return err + return "", err + } + + index := strings.LastIndex(name, ".") + var nameTpl string + if index == -1 { + nameTpl = name + " (%d)" + } else { + nameTpl = name[:index] + " (%d)" + name[index:] + } + for i := 1; ; i++ { + newName := fmt.Sprintf(nameTpl, i) + newPath := syspath.Join(dir, newName) + if _, err := os.Stat(newPath); os.IsNotExist(err) { + return newName, nil } } - return nil } -func safeRemoveParent(path string, subPath string) error { - currPath := syspath.Dir(subPath) - if currPath == "." { - return nil +// GetSingleDir get the top level single folder name,if not exist, return empty string +func GetSingleDir(paths []string) string { + if len(paths) == 0 { + return "" } - // if directory is empty, remove it - dir := syspath.Join(path, syspath.Dir(subPath)) - empty, err := isEmpty(dir) - if err != nil { - return err + split := strings.Split(paths[0], "/") + if len(split) == 0 || split[0] == "" { + return "" } - if empty { - err = SafeRemove(dir) - if err != nil { - return err + dir := split[0] + for i := 1; i < len(paths); i++ { + if !strings.HasPrefix(paths[i], dir) { + return "" } - return safeRemoveParent(path, currPath) } - return nil + return dir } // check directory is empty diff --git a/pkg/util/path_test.go b/pkg/util/path_test.go index fa3ec5e7..88746539 100644 --- a/pkg/util/path_test.go +++ b/pkg/util/path_test.go @@ -2,7 +2,6 @@ package util import ( "os" - "path/filepath" "testing" ) @@ -106,99 +105,97 @@ func TestSafeRemove(t *testing.T) { } } -func TestSafeRemoveAll(t *testing.T) { - testDir := "test_dir" - err := os.Mkdir(testDir, 0755) +func TestGetSingleDir(t *testing.T) { + type args struct { + paths []string + } + tests := []struct { + name string + args args + want string + }{ + { + name: "empty", + args: args{ + paths: []string{}, + }, + want: "", + }, + { + name: "blank", + args: args{ + paths: []string{""}, + }, + want: "", + }, + { + name: "blank multi", + args: args{ + paths: []string{"", "a"}, + }, + want: "", + }, + { + name: "multi dir not single", + args: args{ + paths: []string{"a", "b"}, + }, + want: "", + }, + { + name: "single dir", + args: args{ + paths: []string{"a"}, + }, + want: "a", + }, + { + name: "multi dir single", + args: args{ + paths: []string{"a/b", "a/c"}, + }, + want: "a", + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := GetSingleDir(tt.args.paths); got != tt.want { + t.Errorf("IsSingleDir() = %v, want %v", got, tt.want) + } + }) + } +} + +func TestCheckDuplicateAndRename(t *testing.T) { + doCheckDuplicateAndRename(t, []string{}, "a.txt", "a.txt") + doCheckDuplicateAndRename(t, []string{"a.txt"}, "a.txt", "a (1).txt") + doCheckDuplicateAndRename(t, []string{"a.txt", "a (1).txt"}, "a.txt", "a (2).txt") + + doCheckDuplicateAndRename(t, []string{}, "a", "a") + doCheckDuplicateAndRename(t, []string{"a"}, "a", "a (1)") + doCheckDuplicateAndRename(t, []string{"a", "a (1)"}, "a", "a (2)") +} + +func doCheckDuplicateAndRename(t *testing.T, exitsPaths []string, path string, except string) { + for _, path := range exitsPaths { + if err := os.MkdirAll(path, 0755); err != nil { + t.Fatal(err) + } + } + + defer func() { + for _, path := range exitsPaths { + if err := os.RemoveAll(path); err != nil { + t.Fatal(err) + } + } + }() + + got, err := CheckDuplicateAndRename(path) if err != nil { t.Fatal(err) } - defer os.RemoveAll(testDir) - - doSafeRemoveAll(t, testDir, []string{ - "1.txt", - }, []string{}) - - doSafeRemoveAll(t, testDir, []string{ - "1.txt", - }, []string{ - "2.txt", - }, "2.txt") - - doSafeRemoveAll(t, testDir, []string{ - "1.txt", - "a/b/c/1.txt", - "a/b/1.txt", - "a/1.txt", - }, []string{}) - - doSafeRemoveAll(t, testDir, []string{ - "1.txt", - "a/b/c/1.txt", - "a/b/1.txt", - "a/1.txt", - }, []string{ - "a/b/2.txt", - }, "a/b/2.txt") - - doSafeRemoveAll(t, testDir, []string{ - "1.txt", - "a/b/c/1.txt", - "a/b/1.txt", - "a/1.txt", - }, []string{ - "a/2.txt", - }, "a/2.txt") -} - -func doSafeRemoveAll(t *testing.T, path string, downloadNames []string, otherNames []string, exist ...string) { - preCreate(t, path, downloadNames) - preCreate(t, path, otherNames) - - if err := SafeRemoveAll(path, downloadNames); err != nil { - t.Fatal(err) - } - - if len(exist) == 0 { - for _, name := range downloadNames { - filePath := filepath.Join(path, name) - if isExist(filePath) { - t.Fatalf("file %s should not exist", filePath) - } - subDirPath := filepath.Dir(name) - if subDirPath != "." { - dirPath := filepath.Join(path, subDirPath) - if isExist(dirPath) { - t.Fatalf("dir %s should not exist", dirPath) - } - } - } - return - } - - for _, name := range exist { - if !isExist(filepath.Join(path, name)) { - t.Fatalf("file %s should exist", name) - } + if got != except { + t.Errorf("CheckDuplicateAndRename() = %v, want %v", got, except) } } - -func preCreate(t *testing.T, path string, names []string) { - for _, name := range names { - fullPath := filepath.Join(path, name) - if err := os.MkdirAll(filepath.Dir(fullPath), 0755); err != nil { - t.Fatal(err) - } - file, err := os.Create(fullPath) - if err != nil { - t.Fatal(err) - } - if err := file.Close(); err != nil { - t.Fatal(err) - } - } -} - -func isExist(path string) bool { - _, err := os.Stat(path) - return err == nil || os.IsExist(err) -}