diff --git a/internal/fetcher/fetcher.go b/internal/fetcher/fetcher.go index b10f79c1f..891e79ddc 100644 --- a/internal/fetcher/fetcher.go +++ b/internal/fetcher/fetcher.go @@ -110,6 +110,10 @@ type FetcherManager interface { Filters() []*SchemeFilter // Build returns a new fetcher. Build() Fetcher + // ParseName name displayed when the task is not yet resolved, parsed from the request URL + ParseName(u string) string + // AutoRename returns whether the fetcher need renaming the download file when has the same name file. + AutoRename() bool // DefaultConfig returns the default configuration of the protocol. DefaultConfig() any diff --git a/internal/protocol/bt/fetcher.go b/internal/protocol/bt/fetcher.go index 1366ffa3e..1b2796c72 100644 --- a/internal/protocol/bt/fetcher.go +++ b/internal/protocol/bt/fetcher.go @@ -11,6 +11,7 @@ import ( "github.com/GopeedLab/gopeed/pkg/util" "github.com/anacrolix/torrent" "github.com/anacrolix/torrent/metainfo" + "net/url" "path/filepath" "strings" "sync" @@ -108,7 +109,7 @@ func (f *Fetcher) Resolve(req *base.Request) error { func (f *Fetcher) Create(opts *base.Options) (err error) { f.meta.Opts = opts if f.meta.Res != nil { - torrentDirMap[f.meta.Res.Hash] = f.meta.FolderPath() + torrentDirMap[f.meta.Res.Hash] = opts.Path } return nil } @@ -120,7 +121,7 @@ func (f *Fetcher) Start() (err error) { } } if ft, ok := ftMap[f.meta.Res.Hash]; ok { - ft.setTorrentDir(f.meta.FolderPath()) + ft.setTorrentDir(f.meta.Opts.Path) } files := f.torrent.Files() // If the user does not specify the file to download, all files will be downloaded by default @@ -245,16 +246,18 @@ func (f *Fetcher) isDone() bool { func (f *Fetcher) updateRes() { res := &base.Resource{ - Name: f.torrent.Name(), Range: true, Files: make([]*base.FileInfo, len(f.torrent.Files())), Hash: f.torrent.InfoHash().String(), } - f.torrent.PeerConns() + // Directory torrent + if f.torrent.Info().Length == 0 { + res.Name = f.torrent.Name() + } 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(file.DisplayPath()), Size: file.Length(), } } @@ -458,6 +461,28 @@ func (fm *FetcherManager) Build() fetcher.Fetcher { return &Fetcher{} } +func (fm *FetcherManager) ParseName(u string) string { + var name string + url, err := url.Parse(u) + if err != nil { + return "" + } + + params := url.Query() + if params.Get("dn") != "" { + return params.Get("dn") + } + if params.Get("xt") != "" { + xt := strings.Split(params.Get("xt"), ":") + return xt[len(xt)-1] + } + return name +} + +func (fm *FetcherManager) AutoRename() bool { + return false +} + func (fm *FetcherManager) DefaultConfig() any { return &config{ ListenPort: 0, diff --git a/internal/protocol/bt/fetcher_test.go b/internal/protocol/bt/fetcher_test.go index 1354fc8a0..be20321af 100644 --- a/internal/protocol/bt/fetcher_test.go +++ b/internal/protocol/bt/fetcher_test.go @@ -33,7 +33,6 @@ func TestFetcher_Resolve_DataUri_Torrent(t *testing.T) { } want := &base.Resource{ - Name: "ubuntu-22.04-live-server-amd64.iso", Size: 1466714112, Range: true, Files: []*base.FileInfo{ @@ -69,33 +68,123 @@ func TestFetcher_ResolveWithProxy(t *testing.T) { } func doResolve(t *testing.T, fetcher fetcher.Fetcher) { - err := fetcher.Resolve(&base.Request{ - URL: "./testdata/ubuntu-22.04-live-server-amd64.iso.torrent", - Extra: bt.ReqExtra{ - Trackers: []string{ - "udp://tracker.birkenwald.de:6969/announce", - "udp://tracker.bitsearch.to:1337/announce", + t.Run("Resolve Single File", func(t *testing.T) { + err := fetcher.Resolve(&base.Request{ + URL: "./testdata/ubuntu-22.04-live-server-amd64.iso.torrent", + Extra: bt.ReqExtra{ + Trackers: []string{ + "udp://tracker.birkenwald.de:6969/announce", + "udp://tracker.bitsearch.to:1337/announce", + }, }, - }, + }) + if err != nil { + panic(err) + } + + want := &base.Resource{ + Size: 1466714112, + Range: true, + Files: []*base.FileInfo{ + { + Name: "ubuntu-22.04-live-server-amd64.iso", + Size: 1466714112, + }, + }, + Hash: "8a55cfbd5ca5d11507364765936c4f9e55b253ed", + } + if !reflect.DeepEqual(want, fetcher.Meta().Res) { + t.Errorf("Resolve() got = %v, want %v", fetcher.Meta().Res, want) + } }) - if err != nil { - panic(err) - } - want := &base.Resource{ - Name: "ubuntu-22.04-live-server-amd64.iso", - Size: 1466714112, - Range: true, - Files: []*base.FileInfo{ - { - Name: "ubuntu-22.04-live-server-amd64.iso", - Size: 1466714112, + t.Run("Resolve Multi Files", func(t *testing.T) { + err := fetcher.Resolve(&base.Request{ + URL: "./testdata/test.torrent", + Extra: bt.ReqExtra{ + Trackers: []string{ + "udp://tracker.birkenwald.de:6969/announce", + "udp://tracker.bitsearch.to:1337/announce", + }, + }, + }) + if err != nil { + panic(err) + } + + want := &base.Resource{ + Name: "test", + Size: 107484864, + Range: true, + Files: []*base.FileInfo{ + { + Name: "c.txt", + Path: "path", + Size: 98501754, + }, + { + Name: "b.txt", + Size: 8904996, + }, + { + Name: "a.txt", + Size: 78114, + }, }, + Hash: "ccbc92b0cd8deec16a2ef4be242a8c9243b1cedb", + } + if !reflect.DeepEqual(want, fetcher.Meta().Res) { + t.Errorf("Resolve() got = %v, want %v", fetcher.Meta().Res, want) + } + }) + +} + +func TestFetcherManager_ParseName(t *testing.T) { + type args struct { + u string + } + tests := []struct { + name string + args args + want string + }{ + { + name: "broken url", + args: args{ + u: "magnet://!@#%github.com", + }, + want: "", + }, + { + name: "dn", + args: args{ + u: "magnet:?xt=urn:btih:8a55cfbd5ca5d11507364765936c4f9e55b253ed&dn=ubuntu-22.04-live-server-amd64.iso", + }, + want: "ubuntu-22.04-live-server-amd64.iso", + }, + { + name: "no dn", + args: args{ + u: "magnet:?xt=urn:btih:8a55cfbd5ca5d11507364765936c4f9e55b253ed", + }, + want: "8a55cfbd5ca5d11507364765936c4f9e55b253ed", + }, + { + name: "non standard magnet", + args: args{ + u: "magnet:?xxt=abcd", + }, + want: "", }, - Hash: "8a55cfbd5ca5d11507364765936c4f9e55b253ed", } - if !reflect.DeepEqual(want, fetcher.Meta().Res) { - t.Errorf("Resolve() got = %v, want %v", fetcher.Meta().Res, want) + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + fm := &FetcherManager{} + if got := fm.ParseName(tt.args.u); got != tt.want { + t.Errorf("ParseName() = %v, want %v", got, tt.want) + } + }) } } diff --git a/internal/protocol/bt/testdata/test.torrent b/internal/protocol/bt/testdata/test.torrent new file mode 100644 index 000000000..0c93e1b81 Binary files /dev/null and b/internal/protocol/bt/testdata/test.torrent differ diff --git a/internal/protocol/http/fetcher.go b/internal/protocol/http/fetcher.go index 330a0c2e3..3647c2c4b 100644 --- a/internal/protocol/http/fetcher.go +++ b/internal/protocol/http/fetcher.go @@ -99,7 +99,7 @@ func (f *Fetcher) Resolve(req *base.Request) error { } if base.HttpCodePartialContent == httpResp.StatusCode || (base.HttpCodeOK == httpResp.StatusCode && httpResp.Header.Get(base.HttpHeaderAcceptRanges) == base.HttpHeaderBytes && strings.HasPrefix(httpResp.Header.Get(base.HttpHeaderContentRange), base.HttpHeaderBytes)) { - // 1.返回206响应码表示支持断点下载 2.不返回206但是Accept-Ranges首部并且等于bytes也表示支持断点下载 + // response 206 status code, support breakpoint continuation res.Range = true // 解析资源大小: bytes 0-1000/1001 => 1001 contentTotal := path.Base(httpResp.Header.Get(base.HttpHeaderContentRange)) @@ -111,7 +111,8 @@ func (f *Fetcher) Resolve(req *base.Request) error { res.Size = parse } } else if base.HttpCodeOK == httpResp.StatusCode { - // 返回200响应码,不支持断点下载,通过Content-Length头获取文件大小,获取不到的话可能是chunked编码 + // response 200 status code, not support breakpoint continuation, get file size by Content-Length header + // if not found, maybe chunked encoding contentLength := httpResp.Header.Get(base.HttpHeaderContentLength) if contentLength != "" { parse, err := strconv.ParseInt(contentLength, 10, 64) @@ -481,6 +482,25 @@ func (fm *FetcherManager) Build() fetcher.Fetcher { return &Fetcher{} } +func (fm *FetcherManager) ParseName(u string) string { + var name string + url, err := url.Parse(u) + if err != nil { + return "" + } + // Get filePath by URL + name = path.Base(url.Path) + // If file name is empty, use host name + if name == "" || name == "/" || name == "." { + name = url.Hostname() + } + return name +} + +func (fm *FetcherManager) AutoRename() bool { + return true +} + func (fm *FetcherManager) DefaultConfig() any { return &config{ UserAgent: "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/116.0.0.0 Safari/537.36", diff --git a/internal/protocol/http/fetcher_test.go b/internal/protocol/http/fetcher_test.go index 51373e6ab..d1507adfe 100644 --- a/internal/protocol/http/fetcher_test.go +++ b/internal/protocol/http/fetcher_test.go @@ -192,6 +192,54 @@ func TestFetcher_ConfigUseServerCtime(t *testing.T) { } } +func TestFetcherManager_ParseName(t *testing.T) { + type args struct { + u string + } + tests := []struct { + name string + args args + want string + }{ + { + name: "broken url", + args: args{ + u: "https://!@#%github.com", + }, + want: "", + }, + { + name: "file path", + args: args{ + u: "https://github.com/index.html", + }, + want: "index.html", + }, + { + name: "file path with query and hash", + args: args{ + u: "https://github.com/a/b/index.html/#list?name=1", + }, + want: "index.html", + }, + { + name: "no file path", + args: args{ + u: "https://github.com", + }, + want: "github.com", + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + fm := &FetcherManager{} + if got := fm.ParseName(tt.args.u); got != tt.want { + t.Errorf("ParseName() = %v, want %v", got, tt.want) + } + }) + } +} + func downloadReady(listener net.Listener, connections int, t *testing.T) fetcher.Fetcher { return doDownloadReady(buildFetcher(), listener, connections, t) } @@ -359,11 +407,11 @@ func downloadWithProxy(httpListener net.Listener, proxyListener net.Listener, t } func buildFetcher() *Fetcher { - fb := new(FetcherManager) - fetcher := fb.Build() + fm := new(FetcherManager) + fetcher := fm.Build() newController := controller.NewController() newController.GetConfig = func(v any) { - json.Unmarshal([]byte(test.ToJson(fb.DefaultConfig())), v) + json.Unmarshal([]byte(test.ToJson(fm.DefaultConfig())), v) } fetcher.Setup(newController) return fetcher.(*Fetcher) diff --git a/pkg/download/downloader.go b/pkg/download/downloader.go index e60a13718..8bb2c7f49 100644 --- a/pkg/download/downloader.go +++ b/pkg/download/downloader.go @@ -930,26 +930,28 @@ func (d *Downloader) doStart(task *Task) (err error) { } if isCreate { - d.checkDuplicateLock.Lock() - defer d.checkDuplicateLock.Unlock() - task.Meta.Opts.Name = util.ReplaceInvalidFilename(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.ReplaceInvalidFilename(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.ReplaceInvalidFilename(task.Meta.Res.Files[0].Name) - fullFilePath := task.Meta.SingleFilepath() - newName, err := util.CheckDuplicateAndRename(fullFilePath) - if err != nil { - return err + if task.fetcherManager.AutoRename() { + d.checkDuplicateLock.Lock() + defer d.checkDuplicateLock.Unlock() + task.Meta.Opts.Name = util.ReplaceInvalidFilename(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.ReplaceInvalidFilename(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.ReplaceInvalidFilename(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.Opts.Name = newName } task.Meta.Res.CalcSize(task.Meta.Opts.SelectFiles) diff --git a/pkg/download/model.go b/pkg/download/model.go index 0ec58fda0..9edf955cd 100644 --- a/pkg/download/model.go +++ b/pkg/download/model.go @@ -1,6 +1,7 @@ package download import ( + "encoding/json" "github.com/GopeedLab/gopeed/internal/controller" "github.com/GopeedLab/gopeed/internal/fetcher" "github.com/GopeedLab/gopeed/internal/protocol/bt" @@ -49,6 +50,47 @@ func NewTask() *Task { } } +// Name returns the display name of the task. +func (t *Task) Name() string { + // Custom name first + if t.Meta.Opts.Name != "" { + return t.Meta.Opts.Name + } + + // Task is not resolved, parse the name from the URL + if t.Meta.Res == nil { + fallbackName := "unknown" + if t.fetcherManager == nil { + return fallbackName + } + parseName := t.fetcherManager.ParseName(t.Meta.Req.URL) + if parseName == "" { + return fallbackName + } + return parseName + } + + // Task is a folder + if t.Meta.Res.Name != "" { + return t.Meta.Res.Name + } + + // Get the name of the first file + return t.Meta.Res.Files[0].Name +} + +func (t *Task) MarshalJSON() ([]byte, error) { + type rawTaskType Task + jsonTask := struct { + rawTaskType + Name string `json:"name"` + }{ + rawTaskType(*t), + t.Name(), + } + return json.Marshal(jsonTask) +} + func (t *Task) updateStatus(status base.Status) { t.UpdatedAt = time.Now() t.Status = status diff --git a/ui/flutter/lib/api/model/downloader_config.g.dart b/ui/flutter/lib/api/model/downloader_config.g.dart index c30a59ef6..7d7449e13 100644 --- a/ui/flutter/lib/api/model/downloader_config.g.dart +++ b/ui/flutter/lib/api/model/downloader_config.g.dart @@ -9,7 +9,7 @@ part of 'downloader_config.dart'; DownloaderConfig _$DownloaderConfigFromJson(Map json) => DownloaderConfig( downloadDir: json['downloadDir'] as String? ?? '', - maxRunning: json['maxRunning'] as int? ?? 0, + maxRunning: (json['maxRunning'] as num?)?.toInt() ?? 0, ) ..protocolConfig = ProtocolConfig.fromJson( json['protocolConfig'] as Map?) @@ -38,7 +38,7 @@ Map _$ProtocolConfigToJson(ProtocolConfig instance) => HttpConfig _$HttpConfigFromJson(Map json) => HttpConfig( userAgent: json['userAgent'] as String? ?? '', - connections: json['connections'] as int? ?? 0, + connections: (json['connections'] as num?)?.toInt() ?? 0, useServerCtime: json['useServerCtime'] as bool? ?? false, ); @@ -50,14 +50,14 @@ Map _$HttpConfigToJson(HttpConfig instance) => }; BtConfig _$BtConfigFromJson(Map json) => BtConfig( - listenPort: json['listenPort'] as int? ?? 0, + listenPort: (json['listenPort'] as num?)?.toInt() ?? 0, trackers: (json['trackers'] as List?) ?.map((e) => e as String) .toList() ?? const [], seedKeep: json['seedKeep'] as bool? ?? false, seedRatio: (json['seedRatio'] as num?)?.toDouble() ?? 0, - seedTime: json['seedTime'] as int? ?? 0, + seedTime: (json['seedTime'] as num?)?.toInt() ?? 0, ); Map _$BtConfigToJson(BtConfig instance) => { diff --git a/ui/flutter/lib/api/model/task.dart b/ui/flutter/lib/api/model/task.dart index c8d34ceab..53acf9e69 100644 --- a/ui/flutter/lib/api/model/task.dart +++ b/ui/flutter/lib/api/model/task.dart @@ -11,6 +11,7 @@ enum Protocol { http, bt } @JsonSerializable(explicitToJson: true) class Task { String id; + String name; Protocol? protocol; Meta meta; Status status; @@ -21,6 +22,7 @@ class Task { Task({ required this.id, + required this.name, required this.meta, required this.status, required this.uploading, diff --git a/ui/flutter/lib/api/model/task.g.dart b/ui/flutter/lib/api/model/task.g.dart index 47c6efa77..125edefb1 100644 --- a/ui/flutter/lib/api/model/task.g.dart +++ b/ui/flutter/lib/api/model/task.g.dart @@ -8,6 +8,7 @@ part of 'task.dart'; Task _$TaskFromJson(Map json) => Task( id: json['id'] as String, + name: json['name'] as String, meta: Meta.fromJson(json['meta'] as Map), status: $enumDecode(_$StatusEnumMap, json['status']), uploading: json['uploading'] as bool, @@ -19,6 +20,7 @@ Task _$TaskFromJson(Map json) => Task( Map _$TaskToJson(Task instance) { final val = { 'id': instance.id, + 'name': instance.name, }; void writeNotNull(String key, dynamic value) { @@ -52,11 +54,11 @@ const _$ProtocolEnumMap = { }; Progress _$ProgressFromJson(Map json) => Progress( - used: json['used'] as int, - speed: json['speed'] as int, - downloaded: json['downloaded'] as int, - uploadSpeed: json['uploadSpeed'] as int, - uploaded: json['uploaded'] as int, + used: (json['used'] as num).toInt(), + speed: (json['speed'] as num).toInt(), + downloaded: (json['downloaded'] as num).toInt(), + uploadSpeed: (json['uploadSpeed'] as num).toInt(), + uploaded: (json['uploaded'] as num).toInt(), ); Map _$ProgressToJson(Progress instance) => { diff --git a/ui/flutter/lib/app/modules/task/views/task_view.dart b/ui/flutter/lib/app/modules/task/views/task_view.dart index 511e8557d..a3ccb0528 100644 --- a/ui/flutter/lib/app/modules/task/views/task_view.dart +++ b/ui/flutter/lib/app/modules/task/views/task_view.dart @@ -82,8 +82,7 @@ class TaskView extends GetView { ), ListTile( title: Text('taskName'.tr), - subtitle: - buildTooltipSubtitle(selectTask.value?.showName)), + subtitle: buildTooltipSubtitle(selectTask.value?.name)), ListTile( title: Text('taskUrl'.tr), subtitle: @@ -121,37 +120,12 @@ class TaskView extends GetView { } extension TaskEnhance on Task { - String get showName { - if (meta.opts.name.isNotEmpty) { - return meta.opts.name; - } - if (meta.res == null) { - final u = Uri.parse(meta.req.url); - if (u.scheme.startsWith("http")) { - return u.path.isNotEmpty - ? u.path.substring(u.path.lastIndexOf("/") + 1) - : u.host; - } else { - final params = u.queryParameters; - if (params.containsKey("dn")) { - return params["dn"]!; - } else { - return params["xt"]!.split(":").last; - } - } - } - if (meta.res!.name.isNotEmpty) { - return meta.res!.name; - } - return meta.res!.files[0].name; - } - bool get isFolder { return meta.res?.name.isNotEmpty ?? false; } String get explorerUrl { - return path.join(Util.safeDir(meta.opts.path), Util.safeDir(showName)); + return path.join(Util.safeDir(meta.opts.path), Util.safeDir(name)); } Future explorer() async { diff --git a/ui/flutter/lib/app/views/buid_task_list_view.dart b/ui/flutter/lib/app/views/buid_task_list_view.dart index b69b79edd..cb630efaf 100644 --- a/ui/flutter/lib/app/views/buid_task_list_view.dart +++ b/ui/flutter/lib/app/views/buid_task_list_view.dart @@ -183,10 +183,10 @@ class BuildTaskListView extends GetView { mainAxisSize: MainAxisSize.min, children: [ ListTile( - title: Text(task.showName), + title: Text(task.name), leading: isFolderTask() ? const Icon(FaIcons.folder) - : Icon(FaIcons.allIcons[findIcon(task.showName)])), + : Icon(FaIcons.allIcons[findIcon(task.name)])), Row( children: [ Expanded(