Compare commits

...
20 Commits
Author SHA1 Message Date
Levi 49604966fd release: v1.6.0 2024-09-21 11:00:47 +08:00
Levi bdb8ed177f feat: supports batch operation of tasks by filter (#753) 2024-09-21 10:19:52 +08:00
Levi 2dc7048cba feat: support download directory white list (#752) 2024-09-20 21:32:21 +08:00
Levi d3750464aa fix: torrent stats panic (#737) 2024-09-20 12:28:24 +08:00
Levi 5c9bcd3731 fix: DotAll flag not working with group (#750) 2024-09-19 21:28:46 +08:00
Aman Raj Singh Mourya bdda550b2c feat: avoid using the depreciated background (#746) 2024-09-18 14:39:01 +08:00
liwei 3308d966f0 docs: fix banner transparent 2024-09-13 18:40:58 +08:00
Levi 18041ed9ec docs: improve banner image (#740) 2024-09-11 19:02:39 +08:00
Levi 56b85e545e release: v1.5.9 2024-09-07 19:20:12 +08:00
Levi c5af916952 feat: support default direct download config (#735) 2024-09-07 14:08:17 +08:00
Levi a1981328aa fix: avoid HTTP zero connections (#729) 2024-09-07 13:49:32 +08:00
Levi 51244133e0 feat: support extension onDone event (#728) 2024-09-07 10:10:39 +08:00
Levi 0c17093b1a feat: task separate proxy (#727) 2024-09-06 18:35:23 +08:00
Levi 6538b29b62 feat: macos round icon (#719) 2024-09-03 18:47:49 +08:00
Levi 81e90e6b89 fix: try request grant external storage on Android (#660) 2024-09-03 18:47:06 +08:00
JuanQ 4f39296664 feat: Add spanish language (#667) 2024-08-27 08:59:18 +08:00
Levi ece5b91dc2 fix: torrent redundant directory (#649) 2024-08-14 21:54:59 +08:00
Levi d9f04ae928 fix: tasks block when paused while not ready (#648) 2024-08-14 21:22:23 +08:00
Levi e3ccecbd9c fix: task directory open (#647) 2024-08-14 21:21:41 +08:00
Levi fb1c2cb060 fix: downgrade minSdkVersion (#642) 2024-08-14 21:21:09 +08:00
65 changed files with 1622 additions and 359 deletions

No files matched your search

+3 -3
View File
@@ -1,3 +1,5 @@
# [![](_docs/img/banner.png)](https://gopeed.com)
[![Test Status](https://github.com/GopeedLab/gopeed/workflows/test/badge.svg)](https://github.com/GopeedLab/gopeed/actions?query=workflow%3Atest)
[![Codecov](https://codecov.io/gh/GopeedLab/gopeed/branch/main/graph/badge.svg)](https://codecov.io/gh/GopeedLab/gopeed)
[![Release](https://img.shields.io/github/release/GopeedLab/gopeed.svg)](https://github.com/GopeedLab/gopeed/releases)
@@ -7,8 +9,6 @@
<a href="https://trendshift.io/repositories/7953" target="_blank"><img src="https://trendshift.io/api/badge/repositories/7953" alt="GopeedLab%2Fgopeed | Trendshift" style="width: 250px; height: 55px;" width="250" height="55"/></a>
![](_docs/img/banner.png)
[![ko-fi](https://ko-fi.com/img/githubbutton_sm.svg)](https://ko-fi.com/R6R6IJGN6)
[English](/README.md) | [中文](/README_zh-CN.md) | [日本語](/README_ja-JP.md) | [正體中文](/README_zh-TW.md) | [Tiếng Việt](/README_vi-VN.md)
@@ -114,7 +114,7 @@ This project is divided into two parts, the front end uses `flutter`, the back e
### Environment
1. Golang 1.21+
1. Golang 1.22+
2. Flutter 3.16+
### Clone
+2 -2
View File
@@ -1,3 +1,5 @@
# [![](_docs/img/banner.png)](https://gopeed.com)
[![Test Status](https://github.com/GopeedLab/gopeed/workflows/test/badge.svg)](https://github.com/GopeedLab/gopeed/actions?query=workflow%3Atest)
[![Codecov](https://codecov.io/gh/GopeedLab/gopeed/branch/main/graph/badge.svg)](https://codecov.io/gh/GopeedLab/gopeed)
[![Release](https://img.shields.io/github/release/GopeedLab/gopeed.svg)](https://github.com/GopeedLab/gopeed/releases)
@@ -7,8 +9,6 @@
<a href="https://trendshift.io/repositories/7953" target="_blank"><img src="https://trendshift.io/api/badge/repositories/7953" alt="GopeedLab%2Fgopeed | Trendshift" style="width: 250px; height: 55px;" width="250" height="55"/></a>
![](_docs/img/banner.png)
[![ko-fi](https://ko-fi.com/img/githubbutton_sm.svg)](https://ko-fi.com/R6R6IJGN6)
[English](/README.md) | [中文](/README_zh-CN.md) | [日本語](/README_ja-JP.md) | [正體中文](/README_zh-TW.md) | [Tiếng Việt](/README_vi-VN.md)
+2 -2
View File
@@ -1,3 +1,5 @@
# [![](_docs/img/banner.png)](https://gopeed.com)
[![Trạng thái kiểm tra](https://github.com/GopeedLab/gopeed/workflows/test/badge.svg)](https://github.com/GopeedLab/gopeed/actions?query=workflow%3Atest)
[![Codecov](https://codecov.io/gh/GopeedLab/gopeed/branch/main/graph/badge.svg)](https://codecov.io/gh/GopeedLab/gopeed)
[![Phiên bản](https://img.shields.io/github/release/GopeedLab/gopeed.svg)](https://github.com/GopeedLab/gopeed/releases)
@@ -7,8 +9,6 @@
<a href="https://trendshift.io/repositories/7953" target="_blank"><img src="https://trendshift.io/api/badge/repositories/7953" alt="GopeedLab%2Fgopeed | Trendshift" style="width: 250px; height: 55px;" width="250" height="55"/></a>
![](_docs/img/banner.png)
[![ko-fi](https://ko-fi.com/img/githubbutton_sm.svg)](https://ko-fi.com/R6R6IJGN6)
[English](/README.md) | [中文](/README_zh-CN.md) | [日本語](/README_ja-JP.md) | [正體中文](/README_zh-TW.md) | [Tiếng Việt](/README_vi-VN.md)
+2 -2
View File
@@ -1,3 +1,5 @@
# [![](_docs/img/banner.png)](https://gopeed.com)
[![Test Status](https://github.com/GopeedLab/gopeed/workflows/test/badge.svg)](https://github.com/GopeedLab/gopeed/actions?query=workflow%3Atest)
[![Codecov](https://codecov.io/gh/GopeedLab/gopeed/branch/main/graph/badge.svg)](https://codecov.io/gh/GopeedLab/gopeed)
[![Release](https://img.shields.io/github/release/GopeedLab/gopeed.svg)](https://github.com/GopeedLab/gopeed/releases)
@@ -7,8 +9,6 @@
<a href="https://trendshift.io/repositories/7953" target="_blank"><img src="https://trendshift.io/api/badge/repositories/7953" alt="GopeedLab%2Fgopeed | Trendshift" style="width: 250px; height: 55px;" width="250" height="55"/></a>
![](_docs/img/banner.png)
[![ko-fi](https://ko-fi.com/img/githubbutton_sm.svg)](https://ko-fi.com/R6R6IJGN6)
[English](/README.md) | [中文](/README_zh-CN.md) | [日本語](/README_ja-JP.md) | [正體中文](/README_zh-TW.md) | [Tiếng Việt](/README_vi-VN.md)
+2 -2
View File
@@ -1,3 +1,5 @@
# [![](_docs/img/banner.png)](https://gopeed.com)
[![Test Status](https://github.com/GopeedLab/gopeed/workflows/test/badge.svg)](https://github.com/GopeedLab/gopeed/actions?query=workflow%3Atest)
[![Codecov](https://codecov.io/gh/GopeedLab/gopeed/branch/main/graph/badge.svg)](https://codecov.io/gh/GopeedLab/gopeed)
[![Release](https://img.shields.io/github/release/GopeedLab/gopeed.svg)](https://github.com/GopeedLab/gopeed/releases)
@@ -7,8 +9,6 @@
<a href="https://trendshift.io/repositories/7953" target="_blank"><img src="https://trendshift.io/api/badge/repositories/7953" alt="GopeedLab%2Fgopeed | Trendshift" style="width: 250px; height: 55px;" width="250" height="55"/></a>
![](_docs/img/banner.png)
[![ko-fi](https://ko-fi.com/img/githubbutton_sm.svg)](https://ko-fi.com/R6R6IJGN6)
[English](/README.md) | [中文](/README_zh-CN.md) | [日本語](/README_ja-JP.md) | [正體中文](/README_zh-TW.md) | [Tiếng Việt](/README_vi-VN.md)
Binary file not shown.

Before

Width:  |  Height:  |  Size: 26 KiB

After

Width:  |  Height:  |  Size: 51 KiB

+3 -3
View File
@@ -6,7 +6,7 @@ require (
github.com/anacrolix/missinggo/v2 v2.7.3
github.com/anacrolix/torrent v1.56.1
github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5
github.com/dop251/goja v0.0.0-20240806095544-3491d4a58fbe
github.com/dop251/goja v0.0.0-20240919115326-6c7d1df7ff05
github.com/dop251/goja_nodejs v0.0.0-20240728170619-29b559befffc
github.com/go-git/go-git/v5 v5.8.1
github.com/gorilla/handlers v1.5.1
@@ -66,7 +66,7 @@ require (
github.com/go-sourcemap/sourcemap v2.1.4+incompatible // indirect
github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da // indirect
github.com/google/btree v1.1.2 // indirect
github.com/google/pprof v0.0.0-20240727154555-813a5fbdbec8 // indirect
github.com/google/pprof v0.0.0-20240910150728-a0b0bb1d4134 // indirect
github.com/google/uuid v1.6.0 // indirect
github.com/gorilla/websocket v1.5.3 // indirect
github.com/huandu/xstrings v1.5.0 // indirect
@@ -114,7 +114,7 @@ require (
golang.org/x/mod v0.19.0 // indirect
golang.org/x/net v0.27.0 // indirect
golang.org/x/sys v0.22.0 // indirect
golang.org/x/text v0.17.0 // indirect
golang.org/x/text v0.18.0 // indirect
golang.org/x/time v0.5.0 // indirect
golang.org/x/tools v0.23.0 // indirect
gopkg.in/warnings.v0 v0.1.2 // indirect
+6
View File
@@ -127,6 +127,8 @@ github.com/dlclark/regexp2 v1.11.4/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cn
github.com/docopt/docopt-go v0.0.0-20180111231733-ee0de3bc6815/go.mod h1:WwZ+bS3ebgob9U8Nd0kOddGdZWjyMGR8Wziv+TBNwSE=
github.com/dop251/goja v0.0.0-20240806095544-3491d4a58fbe h1:jwFJkgsdelB87ohlXaAGSd05Cb5ALDFa9iW9IGRHcRM=
github.com/dop251/goja v0.0.0-20240806095544-3491d4a58fbe/go.mod h1:DF+w/nLMIkvRpyhd/0K+Okbh3fVZBtXLwRtS/ccAa5w=
github.com/dop251/goja v0.0.0-20240919115326-6c7d1df7ff05 h1:oK4+QcKsczZjHYTHD0JAdkvq5w74JEkG95J0XNBx/BI=
github.com/dop251/goja v0.0.0-20240919115326-6c7d1df7ff05/go.mod h1:MxLav0peU43GgvwVgNbLAj1s/bSGboKkhuULvq/7hx4=
github.com/dop251/goja_nodejs v0.0.0-20240728170619-29b559befffc h1:MKYt39yZJi0Z9xEeRmDX2L4ocE0ETKcHKw6MVL3R+co=
github.com/dop251/goja_nodejs v0.0.0-20240728170619-29b559befffc/go.mod h1:VULptt4Q/fNzQUJlqY/GP3qHyU7ZH46mFkBZe0ZTokU=
github.com/dustin/go-humanize v0.0.0-20180421182945-02af3965c54e/go.mod h1:HtrtbFcZ19U5GC7JDqmcUSB87Iq5E25KnS6fMYU6eOk=
@@ -214,6 +216,8 @@ github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeN
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
github.com/google/pprof v0.0.0-20240727154555-813a5fbdbec8 h1:FKHo8hFI3A+7w0aUQuYXQ+6EN5stWmeY/AZqtM8xk9k=
github.com/google/pprof v0.0.0-20240727154555-813a5fbdbec8/go.mod h1:K1liHPHnj73Fdn/EKuT8nrFqBihUSKXoLYU0BuatOYo=
github.com/google/pprof v0.0.0-20240910150728-a0b0bb1d4134 h1:c5FlPPgxOn7kJz3VoPLkQYQXGBS3EklQ4Zfi57uOuqQ=
github.com/google/pprof v0.0.0-20240910150728-a0b0bb1d4134/go.mod h1:vavhavw2zAxS5dIdcRluK6cSGGPlZynqzFM8NdvU144=
github.com/google/uuid v1.3.1/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
@@ -569,6 +573,8 @@ golang.org/x/text v0.12.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE=
golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
golang.org/x/text v0.17.0 h1:XtiM5bkSOt+ewxlOE/aE/AKEHibwj/6gvWMl9Rsh0Qc=
golang.org/x/text v0.17.0/go.mod h1:BuEKDfySbSR4drPmRPG/7iBdf8hvFMuRexcpahXilzY=
golang.org/x/text v0.18.0 h1:XvMDiNzPAl0jr17s6W9lcaIhGUfUORdGCNsuLmPG224=
golang.org/x/text v0.18.0/go.mod h1:BuEKDfySbSR4drPmRPG/7iBdf8hvFMuRexcpahXilzY=
golang.org/x/time v0.5.0 h1:o7cqy6amK/52YcAKIPlM3a+Fpj35zvRj2TP+e1xFSfk=
golang.org/x/time v0.5.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM=
golang.org/x/tools v0.0.0-20180828015842-6cd1fcedba52/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
+5 -2
View File
@@ -2,13 +2,15 @@ package controller
import (
"github.com/GopeedLab/gopeed/pkg/base"
"net/http"
"net/url"
"os"
"path/filepath"
)
type Controller struct {
GetConfig func(v any)
ProxyConfig *base.DownloaderProxyConfig
GetConfig func(v any)
GetProxy func(requestProxy *base.RequestProxy) func(*http.Request) (*url.URL, error)
FileController
//ContextDialer() (proxy.Dialer, error)
}
@@ -23,6 +25,7 @@ type DefaultFileController struct {
func NewController() *Controller {
return &Controller{
GetConfig: func(v any) {},
GetProxy: func(requestProxy *base.RequestProxy) func(*http.Request) (*url.URL, error) { return nil },
FileController: &DefaultFileController{},
}
}
+4
View File
@@ -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
+47 -11
View File
@@ -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"
@@ -72,7 +73,7 @@ func (f *Fetcher) initClient() (err error) {
cfg.Bep20 = fmt.Sprintf("-GP%s-", parseBep20())
cfg.ExtendedHandshakeClientVersion = fmt.Sprintf("Gopeed %s", base.Version)
cfg.ListenPort = f.config.ListenPort
cfg.HTTPProxy = f.ctl.ProxyConfig.ToHandler()
cfg.HTTPProxy = f.ctl.GetProxy(f.meta.Req.Proxy)
cfg.DefaultStorage = newFileOpts(newFileClientOpts{
ClientBaseDir: cfg.DataDir,
HandleFileTorrent: func(infoHash metainfo.Hash, ft *fileTorrentImpl) {
@@ -97,18 +98,21 @@ func (f *Fetcher) initClient() (err error) {
}
func (f *Fetcher) Resolve(req *base.Request) error {
if err := base.ParseReqExtra[bt.ReqExtra](req); err != nil {
return err
}
f.meta.Req = req
if err := f.addTorrent(req, false); err != nil {
return err
}
f.updateRes()
f.meta.Req = req
return nil
}
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 +124,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 +249,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(),
}
}
@@ -289,7 +295,7 @@ func (f *Fetcher) doUpload(fromUpload bool) {
continue
}
stats := f.torrent.Stats()
stats := f.torrentStats()
f.data.SeedBytes = lastData.SeedBytes + stats.BytesWrittenData.Int64()
// Check is download complete, if not don't check and stop seeding
@@ -326,6 +332,17 @@ func (f *Fetcher) doUpload(fromUpload bool) {
}
}
// Get torrent stats maybe panic, see https://github.com/anacrolix/torrent/issues/972
func (f *Fetcher) torrentStats() torrent.TorrentStats {
defer func() {
if r := recover(); r != nil {
// ignore panic
}
}()
return f.torrent.Stats()
}
func (f *Fetcher) UploadedBytes() int64 {
return f.data.SeedBytes
}
@@ -336,9 +353,6 @@ func (f *Fetcher) WaitUpload() (err error) {
}
func (f *Fetcher) addTorrent(req *base.Request, fromUpload bool) (err error) {
if err = base.ParseReqExtra[bt.ReqExtra](req); err != nil {
return
}
if err = f.initClient(); err != nil {
return
}
@@ -458,6 +472,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,
+118 -25
View File
@@ -8,6 +8,8 @@ import (
"github.com/GopeedLab/gopeed/internal/test"
"github.com/GopeedLab/gopeed/pkg/base"
"github.com/GopeedLab/gopeed/pkg/protocol/bt"
gohttp "net/http"
"net/url"
"os"
"reflect"
"testing"
@@ -33,7 +35,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 +70,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)
}
})
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,
want := &base.Resource{
Size: 1466714112,
Range: true,
Files: []*base.FileInfo{
{
Name: "ubuntu-22.04-live-server-amd64.iso",
Size: 1466714112,
},
},
},
Hash: "8a55cfbd5ca5d11507364765936c4f9e55b253ed",
Hash: "8a55cfbd5ca5d11507364765936c4f9e55b253ed",
}
if !reflect.DeepEqual(want, fetcher.Meta().Res) {
t.Errorf("Resolve() got = %v, want %v", fetcher.Meta().Res, want)
}
})
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
}
if !reflect.DeepEqual(want, fetcher.Meta().Res) {
t.Errorf("Resolve() got = %v, want %v", fetcher.Meta().Res, want)
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: "",
},
}
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)
}
})
}
}
@@ -121,7 +212,9 @@ func buildConfigFetcher(proxyConfig *base.DownloaderProxyConfig) fetcher.Fetcher
newController.GetConfig = func(v any) {
json.Unmarshal([]byte(test.ToJson(mockCfg)), v)
}
newController.ProxyConfig = proxyConfig
newController.GetProxy = func(requestProxy *base.RequestProxy) func(*gohttp.Request) (*url.URL, error) {
return proxyConfig.ToHandler()
}
fetcher.Setup(newController)
return fetcher
}
Binary file not shown.
+29 -5
View File
@@ -79,6 +79,7 @@ func (f *Fetcher) Resolve(req *base.Request) error {
if err := base.ParseReqExtra[fhttp.ReqExtra](req); err != nil {
return err
}
f.meta.Req = req
httpReq, err := f.buildRequest(nil, req)
if err != nil {
return err
@@ -99,7 +100,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 +112,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)
@@ -152,7 +154,6 @@ func (f *Fetcher) Resolve(req *base.Request) error {
file.Name = httpReq.URL.Hostname()
}
res.Files = append(res.Files, file)
f.meta.Req = req
f.meta.Res = res
return nil
}
@@ -170,8 +171,12 @@ func (f *Fetcher) Create(opts *base.Options) error {
opts.Extra = &fhttp.OptsExtra{}
}
extra := opts.Extra.(*fhttp.OptsExtra)
if extra.Connections == 0 {
if extra.Connections <= 0 {
extra.Connections = f.config.Connections
// Avoid zero connections configuration
if extra.Connections <= 0 {
extra.Connections = 1
}
}
return nil
}
@@ -443,7 +448,7 @@ func (f *Fetcher) splitChunk() (chunks []*chunk) {
func (f *Fetcher) buildClient() *http.Client {
transport := &http.Transport{
Proxy: f.ctl.ProxyConfig.ToHandler(),
Proxy: f.ctl.GetProxy(f.meta.Req.Proxy),
}
// Cookie handle
jar, _ := cookiejar.New(nil)
@@ -481,6 +486,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",
+59 -7
View File
@@ -9,6 +9,8 @@ import (
"github.com/GopeedLab/gopeed/pkg/base"
"github.com/GopeedLab/gopeed/pkg/protocol/http"
"net"
gohttp "net/http"
"net/url"
"testing"
"time"
)
@@ -192,6 +194,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)
}
@@ -337,10 +387,12 @@ func downloadResume(listener net.Listener, connections int, t *testing.T) {
func downloadWithProxy(httpListener net.Listener, proxyListener net.Listener, t *testing.T) {
fetcher := downloadReady(httpListener, 4, t)
ctl := controller.NewController()
ctl.ProxyConfig = &base.DownloaderProxyConfig{
Enable: true,
Scheme: "socks5",
Host: proxyListener.Addr().String(),
ctl.GetProxy = func(requestProxy *base.RequestProxy) func(*gohttp.Request) (*url.URL, error) {
return (&base.DownloaderProxyConfig{
Enable: true,
Scheme: "socks5",
Host: proxyListener.Addr().String(),
}).ToHandler()
}
fetcher.Setup(ctl)
err := fetcher.Start()
@@ -359,11 +411,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)
-8
View File
@@ -1,8 +0,0 @@
package base
import "errors"
var (
NotFound = errors.New("not found")
BadParams = errors.New("bad params")
)
+34 -9
View File
@@ -16,6 +16,8 @@ type Request struct {
Extra any `json:"extra"`
// Labels is used to mark the download task
Labels map[string]string `json:"labels"`
// Proxy is special proxy config for request
Proxy *RequestProxy `json:"proxy"`
}
func (r *Request) Validate() error {
@@ -25,6 +27,37 @@ func (r *Request) Validate() error {
return nil
}
type RequestProxyMode string
const (
// RequestProxyModeFollow follow setting proxy
RequestProxyModeFollow RequestProxyMode = "follow"
// RequestProxyModeNone not use proxy
RequestProxyModeNone RequestProxyMode = "none"
// RequestProxyModeCustom custom proxy
RequestProxyModeCustom RequestProxyMode = "custom"
)
type RequestProxy struct {
Mode RequestProxyMode `json:"mode"`
Scheme string `json:"scheme"`
Host string `json:"host"`
Usr string `json:"usr"`
Pwd string `json:"pwd"`
}
func (p *RequestProxy) ToHandler() func(r *http.Request) (*url.URL, error) {
if p == nil || p.Mode != RequestProxyModeCustom {
return nil
}
if p.Scheme == "" || p.Host == "" {
return nil
}
return http.ProxyURL(util.BuildProxyUrl(p.Scheme, p.Host, p.Usr, p.Pwd))
}
// Resource download resource
type Resource struct {
// if name is not empty, the resource is a folder and the name is the folder name
@@ -94,15 +127,7 @@ func (o *Options) InitSelectFiles(fileSize int) {
}
func (o *Options) Clone() *Options {
if o == nil {
return nil
}
return &Options{
Name: o.Name,
Path: o.Path,
SelectFiles: o.SelectFiles,
Extra: o.Extra,
}
return util.DeepClone(o)
}
func ParseReqExtra[E any](req *Request) error {
+206 -104
View File
@@ -12,6 +12,9 @@ import (
"github.com/rs/zerolog"
"github.com/rs/zerolog/pkgerrors"
"github.com/virtuald/go-paniclog"
"math"
gohttp "net/http"
"net/url"
"os"
"path/filepath"
"sort"
@@ -253,7 +256,20 @@ func (d *Downloader) setupFetcher(fm fetcher.FetcherManager, fetcher fetcher.Fet
ctl.GetConfig = func(v any) {
d.getProtocolConfig(fm.Name(), v)
}
ctl.ProxyConfig = d.cfg.Proxy
// Get proxy config, task request proxy config has higher priority, then use global proxy config
ctl.GetProxy = func(requestProxy *base.RequestProxy) func(*gohttp.Request) (*url.URL, error) {
if requestProxy == nil {
return d.cfg.Proxy.ToHandler()
}
switch requestProxy.Mode {
case base.RequestProxyModeNone:
return nil
case base.RequestProxyModeCustom:
return requestProxy.ToHandler()
default:
return d.cfg.Proxy.ToHandler()
}
}
fetcher.Setup(ctl)
}
@@ -356,56 +372,28 @@ func (d *Downloader) Create(rrId string, opts *base.Options) (taskId string, err
return d.doCreate(fetcher, opts)
}
func (d *Downloader) Pause(id string) (err error) {
task := d.GetTask(id)
if task == nil {
func (d *Downloader) Pause(filter *TaskFilter) (err error) {
if filter == nil || filter.IsEmpty() {
return d.pauseAll()
}
filter.NotStatuses = []base.Status{base.DownloadStatusPause, base.DownloadStatusError, base.DownloadStatusDone}
pauseTasks := d.GetTasksByFilter(filter)
if len(pauseTasks) == 0 {
return ErrTaskNotFound
}
func() {
task.lock.Lock()
defer task.lock.Unlock()
if task.Status == base.DownloadStatusPause {
return
}
for _, task := range pauseTasks {
if err = d.doPause(task); err != nil {
return
}
d.notifyRunning()
}()
}
d.notifyRunning()
return
}
func (d *Downloader) Continue(id string) (err error) {
task := d.GetTask(id)
if task == nil {
return ErrTaskNotFound
}
func() {
d.lock.Lock()
defer d.lock.Unlock()
remainRunningCount := d.remainRunningCount()
if remainRunningCount == 0 {
for _, t := range d.tasks {
if t.Status == base.DownloadStatusRunning {
if err = d.doPause(t); err != nil {
return
}
t.Status = base.DownloadStatusWait
d.waitTasks = append(d.waitTasks, t)
break
}
}
}
}()
return d.doStart(task)
}
func (d *Downloader) PauseAll() (err error) {
func (d *Downloader) pauseAll() (err error) {
func() {
d.lock.Lock()
defer d.lock.Unlock()
@@ -423,7 +411,65 @@ func (d *Downloader) PauseAll() (err error) {
return
}
func (d *Downloader) ContinueAll() (err error) {
// Continue specific tasks, if continue tasks will exceed maxRunning, it needs pause some running tasks before that
func (d *Downloader) Continue(filter *TaskFilter) (err error) {
if filter == nil || filter.IsEmpty() {
return d.continueAll()
}
filter.NotStatuses = []base.Status{base.DownloadStatusRunning, base.DownloadStatusDone}
continueTasks := d.GetTasksByFilter(filter)
if len(continueTasks) == 0 {
return ErrTaskNotFound
}
realContinueTasks := make([]*Task, 0)
func() {
d.lock.Lock()
defer d.lock.Unlock()
continueCount := len(continueTasks)
remainRunningCount := d.remainRunningCount()
needRunningCount := int(math.Min(float64(d.cfg.MaxRunning), float64(continueCount)))
needPauseCount := needRunningCount - remainRunningCount
if needPauseCount > 0 {
pausedCount := 0
for _, task := range d.tasks {
if task.Status == base.DownloadStatusRunning {
if err = d.doPause(task); err != nil {
return
}
task.Status = base.DownloadStatusWait
d.waitTasks = append(d.waitTasks, task)
pausedCount++
}
if pausedCount == needPauseCount {
break
}
}
}
for _, task := range continueTasks {
if len(realContinueTasks) < needRunningCount {
realContinueTasks = append(realContinueTasks, task)
} else {
task.Status = base.DownloadStatusWait
d.waitTasks = append(d.waitTasks, task)
}
}
}()
for _, task := range realContinueTasks {
if err = d.doStart(task); err != nil {
return
}
}
return
}
// continueAll continue all tasks but does not affect tasks already running
func (d *Downloader) continueAll() (err error) {
continuedTasks := make([]*Task, 0)
func() {
@@ -444,8 +490,7 @@ func (d *Downloader) ContinueAll() (err error) {
}()
for _, task := range continuedTasks {
tt := task
if err = d.doStart(tt); err != nil {
if err = d.doStart(task); err != nil {
return
}
}
@@ -453,40 +498,26 @@ func (d *Downloader) ContinueAll() (err error) {
return
}
func (d *Downloader) Delete(id string, force bool) (err error) {
task := d.GetTask(id)
if task == nil {
return ErrTaskNotFound
func (d *Downloader) ContinueBatch(filter *TaskFilter) (err error) {
if filter == nil || filter.IsEmpty() {
return d.continueAll()
}
func() {
d.lock.Lock()
defer d.lock.Unlock()
for i, t := range d.tasks {
if t.ID == id {
d.tasks = append(d.tasks[:i], d.tasks[i+1:]...)
break
}
continueTasks := d.GetTasksByFilter(filter)
for _, task := range continueTasks {
if err = d.doStart(task); err != nil {
return
}
for i, t := range d.waitTasks {
if t.ID == id {
d.waitTasks = append(d.waitTasks[:i], d.waitTasks[i+1:]...)
break
}
}
}()
err = d.doDelete(task, force)
if err != nil {
return
}
d.notifyRunning()
return
}
func (d *Downloader) DeleteByStatues(statues []base.Status, force bool) (err error) {
deleteTasks := d.GetTasksByStatues(statues)
func (d *Downloader) Delete(filter *TaskFilter, force bool) (err error) {
if filter == nil || filter.IsEmpty() {
return d.deleteAll()
}
deleteTasks := d.GetTasksByFilter(filter)
if len(deleteTasks) == 0 {
return
}
@@ -528,6 +559,23 @@ func (d *Downloader) DeleteByStatues(statues []base.Status, force bool) (err err
return
}
func (d *Downloader) deleteAll() (err error) {
func() {
d.lock.Lock()
defer d.lock.Unlock()
d.tasks = make([]*Task, 0)
d.waitTasks = make([]*Task, 0)
}()
for _, task := range d.tasks {
if err = d.doDelete(task, true); err != nil {
return
}
}
return
}
func (d *Downloader) Stats(id string) (sr any, err error) {
task := d.GetTask(id)
if task == nil {
@@ -550,9 +598,6 @@ func (d *Downloader) Stats(id string) (sr any, err error) {
func (d *Downloader) doDelete(task *Task, force bool) (err error) {
err = func() error {
d.lock.Lock()
defer d.lock.Unlock()
if err := d.storage.Delete(bucketTask, task.ID); err != nil {
return err
}
@@ -580,10 +625,10 @@ func (d *Downloader) doDelete(task *Task, force bool) (err error) {
task = nil
return nil
}()
if err != nil {
d.Logger.Error().Stack().Err(err).Msgf("delete task failed, task id: %s", task.ID)
}
return
}
@@ -591,12 +636,12 @@ func (d *Downloader) Close() error {
d.closed.Store(true)
closeArr := []func() error{
d.PauseAll,
d.storage.Close,
d.pauseAll,
}
for _, fm := range d.cfg.FetchManagers {
closeArr = append(closeArr, fm.Close)
}
closeArr = append(closeArr, d.storage.Close)
// Make sure all resources are released, if had error, return the last error
var lastErr error
for i, close := range closeArr {
@@ -653,17 +698,51 @@ func (d *Downloader) GetTasks() []*Task {
return d.tasks
}
func (d *Downloader) GetTasksByStatues(statues []base.Status) []*Task {
if len(statues) == 0 {
// GetTasksByFilter get tasks by filter, if filter is nil, return all tasks
// return tasks and if match all tasks
func (d *Downloader) GetTasksByFilter(filter *TaskFilter) []*Task {
if filter == nil || filter.IsEmpty() {
return d.tasks
}
idMatch := func(task *Task) bool {
if len(filter.IDs) == 0 {
return true
}
for _, id := range filter.IDs {
if task.ID == id {
return true
}
}
return false
}
statusMatch := func(task *Task) bool {
if len(filter.Statuses) == 0 {
return true
}
for _, status := range filter.Statuses {
if task.Status == status {
return true
}
}
return false
}
notStatusMatch := func(task *Task) bool {
if len(filter.NotStatuses) == 0 {
return true
}
for _, status := range filter.NotStatuses {
if task.Status == status {
return false
}
}
return true
}
tasks := make([]*Task, 0)
for _, task := range d.tasks {
for _, status := range statues {
if task.Status == status {
tasks = append(tasks, task)
break
}
if idMatch(task) && statusMatch(task) && notStatusMatch(task) {
tasks = append(tasks, task)
}
}
return tasks
@@ -732,6 +811,12 @@ func (d *Downloader) watch(task *Task) {
d.doOnError(task, err)
return
}
// When delete a not resolved task, need check if the task resource is nil
if task.Meta.Res == nil {
return
}
task.Progress.Used = task.timer.Used()
if task.Meta.Res.Size == 0 {
task.Meta.Res.Size = task.fetcher.Progress().TotalDownloaded()
@@ -748,6 +833,7 @@ func (d *Downloader) watch(task *Task) {
d.emit(EventKeyDone, task)
d.emit(EventKeyFinally, task, err)
d.notifyRunning()
d.triggerOnDone(task)
if e, ok := task.Meta.Opts.Extra.(*http.OptsExtra); ok {
downloadFilePath := task.Meta.SingleFilepath()
@@ -843,6 +929,20 @@ func (d *Downloader) doCreate(f fetcher.Fetcher, opts *base.Options) (taskId str
opts.Path = storeConfig.DownloadDir
}
// if enable white download directory, check if the download directory is in the white list
if len(d.cfg.DownloadDirWhiteList) > 0 {
inWhiteList := false
for _, dir := range d.cfg.DownloadDirWhiteList {
if match, err := filepath.Match(dir, opts.Path); match && err == nil {
inWhiteList = true
break
}
}
if !inWhiteList {
return "", errors.New("download directory is not in white list")
}
}
fm, err := d.parseFm(f.Meta().Req.URL)
if err != nil {
return
@@ -932,26 +1032,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
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
} 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.Res.CalcSize(task.Meta.Opts.SelectFiles)
+231 -16
View File
@@ -82,7 +82,45 @@ func TestDownloader_Create(t *testing.T) {
want := test.FileMd5(test.BuildFile)
got := test.FileMd5(test.DownloadFile)
if want != got {
t.Errorf("Download() got = %v, want %v", got, want)
t.Errorf("Downloader_Create() got = %v, want %v", got, want)
}
}
func TestDownloader_CreateNotInWhite(t *testing.T) {
listener := test.StartTestFileServer()
defer listener.Close()
downloader := NewDownloader(&DownloaderConfig{
DownloadDirWhiteList: []string{"./downloads"},
})
if err := downloader.Setup(); err != nil {
t.Fatal(err)
}
defer downloader.Clear()
req := &base.Request{
URL: "http://" + listener.Addr().String() + "/" + test.BuildName,
}
rr, err := downloader.Resolve(req)
if err != nil {
t.Fatal(err)
}
var wg sync.WaitGroup
wg.Add(1)
downloader.Listener(func(event *Event) {
if event.Key == EventKeyDone {
wg.Done()
}
})
_, err = downloader.Create(rr.ID, &base.Options{
Path: test.Dir,
Name: test.DownloadName,
Extra: http.OptsExtra{
Connections: 4,
},
})
if !strings.Contains(err.Error(), "white") {
t.Errorf("TestDownloader_CreateNotInWhite() got = %v, want %v", err.Error(), "not in white list")
}
}
@@ -95,7 +133,7 @@ func TestDownloader_CreateDirectBatch(t *testing.T) {
t.Fatal(err)
}
defer func() {
downloader.DeleteByStatues(nil, true)
downloader.Delete(nil, true)
downloader.Clear()
}()
@@ -159,21 +197,21 @@ func TestDownloader_CreateDirectBatch(t *testing.T) {
func TestDownloader_CreateWithProxy(t *testing.T) {
// No proxy
doTestDownloaderCreateWithProxy(t, false, func(proxyCfg *base.DownloaderProxyConfig) *base.DownloaderProxyConfig {
doTestDownloaderCreateWithProxy(t, false, nil, func(proxyCfg *base.DownloaderProxyConfig) *base.DownloaderProxyConfig {
return nil
}, nil)
// Disable proxy
doTestDownloaderCreateWithProxy(t, false, func(proxyCfg *base.DownloaderProxyConfig) *base.DownloaderProxyConfig {
doTestDownloaderCreateWithProxy(t, false, nil, func(proxyCfg *base.DownloaderProxyConfig) *base.DownloaderProxyConfig {
proxyCfg.Enable = false
return proxyCfg
}, nil)
// Enable system proxy but not set proxy environment variable
doTestDownloaderCreateWithProxy(t, false, func(proxyCfg *base.DownloaderProxyConfig) *base.DownloaderProxyConfig {
doTestDownloaderCreateWithProxy(t, false, nil, func(proxyCfg *base.DownloaderProxyConfig) *base.DownloaderProxyConfig {
proxyCfg.System = true
return proxyCfg
}, nil)
// Enable proxy but error proxy environment variable
doTestDownloaderCreateWithProxy(t, false, func(proxyCfg *base.DownloaderProxyConfig) *base.DownloaderProxyConfig {
doTestDownloaderCreateWithProxy(t, false, nil, func(proxyCfg *base.DownloaderProxyConfig) *base.DownloaderProxyConfig {
os.Setenv("HTTP_PROXY", "http://127.0.0.1:1234")
os.Setenv("HTTPS_PROXY", "http://127.0.0.1:1234")
proxyCfg.System = true
@@ -184,33 +222,50 @@ func TestDownloader_CreateWithProxy(t *testing.T) {
}
})
// Enable system proxy and set proxy environment variable
doTestDownloaderCreateWithProxy(t, false, func(proxyCfg *base.DownloaderProxyConfig) *base.DownloaderProxyConfig {
doTestDownloaderCreateWithProxy(t, false, nil, func(proxyCfg *base.DownloaderProxyConfig) *base.DownloaderProxyConfig {
os.Setenv("HTTP_PROXY", proxyCfg.ToUrl().String())
os.Setenv("HTTPS_PROXY", proxyCfg.ToUrl().String())
proxyCfg.System = true
return proxyCfg
}, nil)
// Invalid proxy scheme
doTestDownloaderCreateWithProxy(t, false, func(proxyCfg *base.DownloaderProxyConfig) *base.DownloaderProxyConfig {
doTestDownloaderCreateWithProxy(t, false, nil, func(proxyCfg *base.DownloaderProxyConfig) *base.DownloaderProxyConfig {
proxyCfg.Scheme = ""
return proxyCfg
}, nil)
// Invalid proxy host
doTestDownloaderCreateWithProxy(t, false, func(proxyCfg *base.DownloaderProxyConfig) *base.DownloaderProxyConfig {
doTestDownloaderCreateWithProxy(t, false, nil, func(proxyCfg *base.DownloaderProxyConfig) *base.DownloaderProxyConfig {
proxyCfg.Host = ""
return proxyCfg
}, nil)
// Use proxy without auth
doTestDownloaderCreateWithProxy(t, false, func(proxyCfg *base.DownloaderProxyConfig) *base.DownloaderProxyConfig {
doTestDownloaderCreateWithProxy(t, false, nil, func(proxyCfg *base.DownloaderProxyConfig) *base.DownloaderProxyConfig {
return proxyCfg
}, nil)
// Use proxy with auth
doTestDownloaderCreateWithProxy(t, true, func(proxyCfg *base.DownloaderProxyConfig) *base.DownloaderProxyConfig {
doTestDownloaderCreateWithProxy(t, true, nil, func(proxyCfg *base.DownloaderProxyConfig) *base.DownloaderProxyConfig {
return proxyCfg
}, nil)
// Request proxy mode follow
doTestDownloaderCreateWithProxy(t, false, func(reqProxy *base.RequestProxy) *base.RequestProxy {
reqProxy.Mode = base.RequestProxyModeFollow
return reqProxy
}, nil, nil)
// Request proxy mode none
doTestDownloaderCreateWithProxy(t, false, func(reqProxy *base.RequestProxy) *base.RequestProxy {
reqProxy.Mode = base.RequestProxyModeNone
return reqProxy
}, nil, nil)
// Request proxy mode custom
doTestDownloaderCreateWithProxy(t, false, func(reqProxy *base.RequestProxy) *base.RequestProxy {
return reqProxy
}, nil, nil)
}
func doTestDownloaderCreateWithProxy(t *testing.T, auth bool, buildProxyConfig func(proxyCfg *base.DownloaderProxyConfig) *base.DownloaderProxyConfig, errHandler func(err error)) {
func doTestDownloaderCreateWithProxy(t *testing.T, auth bool, buildReqProxy func(reqProxy *base.RequestProxy) *base.RequestProxy, buildProxyConfig func(proxyCfg *base.DownloaderProxyConfig) *base.DownloaderProxyConfig, errHandler func(err error)) {
usr, pwd := "", ""
if auth {
usr, pwd = "admin", "123"
@@ -223,17 +278,29 @@ func doTestDownloaderCreateWithProxy(t *testing.T, auth bool, buildProxyConfig f
t.Fatal(err)
}
defer downloader.Clear()
downloader.cfg.DownloaderStoreConfig.Proxy = buildProxyConfig(&base.DownloaderProxyConfig{
globalProxyCfg := &base.DownloaderProxyConfig{
Enable: true,
Scheme: "socks5",
Host: proxyListener.Addr().String(),
Usr: usr,
Pwd: pwd,
})
}
if buildProxyConfig != nil {
globalProxyCfg = buildProxyConfig(globalProxyCfg)
}
downloader.cfg.DownloaderStoreConfig.Proxy = globalProxyCfg
req := &base.Request{
URL: test.ExternalDownloadUrl,
}
if buildReqProxy != nil {
req.Proxy = buildReqProxy(&base.RequestProxy{
Scheme: "socks5",
Host: proxyListener.Addr().String(),
Usr: usr,
Pwd: pwd,
})
}
rr, err := downloader.Resolve(req)
if err != nil {
if errHandler == nil {
@@ -333,7 +400,7 @@ func TestDownloader_StoreAndRestore(t *testing.T) {
t.Fatal(err)
}
time.Sleep(time.Millisecond * 1001)
err = downloader.Pause(id)
err = downloader.Pause(&TaskFilter{IDs: []string{id}})
if err != nil {
t.Fatal(err)
}
@@ -357,7 +424,7 @@ func TestDownloader_StoreAndRestore(t *testing.T) {
wg.Done()
}
})
err = downloader.Continue(id)
err = downloader.Continue(&TaskFilter{IDs: []string{id}})
wg.Wait()
if err != nil {
t.Fatal(err)
@@ -418,3 +485,151 @@ func TestDownloader_Protocol_Config(t *testing.T) {
t.Errorf("GetConfig() got = %v, want %v", test.ToJson(storeCfg), test.ToJson(newStoreCfg))
}
}
func TestDownloader_GetTasksByFilter(t *testing.T) {
listener := test.StartTestFileServer()
defer listener.Close()
downloader := NewDownloader(nil)
if err := downloader.Setup(); err != nil {
t.Fatal(err)
}
defer func() {
downloader.Delete(nil, true)
downloader.Clear()
}()
reqs := make([]*base.Request, 0)
fileNames := make([]string, 0)
for i := 0; i < 10; i++ {
req := &base.Request{
URL: "http://" + listener.Addr().String() + "/" + test.BuildName,
}
reqs = append(reqs, req)
if i == 0 {
fileNames = append(fileNames, test.DownloadName)
} else {
arr := strings.Split(test.DownloadName, ".")
fileNames = append(fileNames, arr[0]+" ("+strconv.Itoa(i)+")."+arr[1])
}
}
var wg sync.WaitGroup
wg.Add(len(reqs))
downloader.Listener(func(event *Event) {
if event.Key == EventKeyDone {
wg.Done()
}
})
taskIds, err := downloader.CreateDirectBatch(reqs, &base.Options{
Path: test.Dir,
Name: test.DownloadName,
Extra: http.OptsExtra{
Connections: 4,
},
})
if err != nil {
t.Fatal(err)
}
wg.Wait()
t.Run("GetTasksByFilter nil", func(t *testing.T) {
tasks := downloader.GetTasksByFilter(nil)
if len(tasks) != len(reqs) {
t.Errorf("GetTasksByFilter nil task got = %v, want %v", len(tasks), len(reqs))
}
})
t.Run("GetTasksByFilter empty", func(t *testing.T) {
tasks := downloader.GetTasksByFilter(&TaskFilter{})
if len(tasks) != len(reqs) {
t.Errorf("GetTasksByFilter empty task got = %v, want %v", len(tasks), len(reqs))
}
})
t.Run("GetTasksByFilter ids", func(t *testing.T) {
tasks := downloader.GetTasksByFilter(&TaskFilter{
IDs: taskIds,
})
if len(tasks) != len(reqs) {
t.Errorf("GetTasksByFilter ids task got = %v, want %v", len(tasks), len(reqs))
}
})
t.Run("GetTasksByFilter match ids", func(t *testing.T) {
tasks := downloader.GetTasksByFilter(&TaskFilter{
IDs: []string{taskIds[0]},
})
if len(tasks) != 1 {
t.Errorf("GetTasksByFilter ids task got = %v, want %v", len(tasks), 1)
}
})
t.Run("GetTasksByFilter not match ids", func(t *testing.T) {
tasks := downloader.GetTasksByFilter(&TaskFilter{
IDs: []string{"xxx"},
})
if len(tasks) != 0 {
t.Errorf("GetTasksByFilter ids task got = %v, want %v", len(tasks), 0)
}
})
t.Run("GetTasksByFilter status", func(t *testing.T) {
tasks := downloader.GetTasksByFilter(&TaskFilter{
Statuses: []base.Status{base.DownloadStatusDone},
})
if len(tasks) != len(reqs) {
t.Errorf("GetTasksByFilter status task got = %v, want %v", len(tasks), len(reqs))
}
})
t.Run("GetTasksByFilter not match status", func(t *testing.T) {
tasks := downloader.GetTasksByFilter(&TaskFilter{
Statuses: []base.Status{base.DownloadStatusError},
})
if len(tasks) != 0 {
t.Errorf("GetTasksByFilter status task got = %v, want %v", len(tasks), 0)
}
})
t.Run("GetTasksByFilter match notStatus", func(t *testing.T) {
tasks := downloader.GetTasksByFilter(&TaskFilter{
NotStatuses: []base.Status{base.DownloadStatusRunning, base.DownloadStatusPause},
})
if len(tasks) != len(reqs) {
t.Errorf("GetTasksByFilter match notStatus task got = %v, want %v", len(tasks), len(reqs))
}
})
t.Run("GetTasksByFilter not match notStatus", func(t *testing.T) {
tasks := downloader.GetTasksByFilter(&TaskFilter{
NotStatuses: []base.Status{base.DownloadStatusDone},
})
if len(tasks) != 0 {
t.Errorf("GetTasksByFilter not match notStatus task got = %v, want %v", len(tasks), 0)
}
})
t.Run("GetTasksByFilter match ids and status", func(t *testing.T) {
tasks := downloader.GetTasksByFilter(&TaskFilter{
IDs: []string{taskIds[0]},
Statuses: []base.Status{base.DownloadStatusDone},
})
if len(tasks) != 1 {
t.Errorf("GetTasksByFilter match ids and status task got = %v, want %v", len(tasks), 1)
}
})
t.Run("GetTasksByFilter not match ids and status", func(t *testing.T) {
tasks := downloader.GetTasksByFilter(&TaskFilter{
IDs: []string{taskIds[0]},
Statuses: []base.Status{base.DownloadStatusError},
})
if len(tasks) != 0 {
t.Errorf("GetTasksByFilter not match ids and status task got = %v, want %v", len(tasks), 0)
}
})
}
+1 -1
View File
@@ -106,7 +106,7 @@ func (e *Engine) await(value any) {
}
func (e *Engine) Close() {
e.loop.Stop()
e.loop.StopNoWait()
}
type Config struct {
+30 -7
View File
@@ -38,7 +38,7 @@ const (
EventOnResolve ActivationEvent = "onResolve"
EventOnStart ActivationEvent = "onStart"
EventOnError ActivationEvent = "onError"
//EventOnDone ActivationEvent = "onDone"
EventOnDone ActivationEvent = "onDone"
)
func (d *Downloader) InstallExtensionByGit(url string) (*Extension, error) {
@@ -304,6 +304,17 @@ func (d *Downloader) triggerOnError(task *Task, err error) {
)
}
func (d *Downloader) triggerOnDone(task *Task) {
doTrigger(d,
EventOnDone,
task.Meta.Req,
&OnErrorContext{
Task: NewExtensionTask(d, task),
},
nil,
)
}
func doTrigger[T any](d *Downloader, event ActivationEvent, req *base.Request, ctx T, handler func(ext *Extension, gopeed *Instance, ctx T)) error {
// init extension global object
gopeed := &Instance{
@@ -611,9 +622,9 @@ func (h InstanceEvents) OnError(fn goja.Callable) {
h.register(EventOnError, fn)
}
//func (h InstanceEvents) OnDone(fn goja.Callable) {
// h.register(HookEventOnDone, fn)
//}
func (h InstanceEvents) OnDone(fn goja.Callable) {
h.register(EventOnDone, fn)
}
type ExtensionInfo struct {
Identity string `json:"identity"`
@@ -687,6 +698,10 @@ type OnErrorContext struct {
Error error `json:"error"`
}
type OnDoneContext struct {
Task *Task `json:"task"`
}
type ExtensionTask struct {
*Task
@@ -694,18 +709,26 @@ type ExtensionTask struct {
}
func NewExtensionTask(download *Downloader, task *Task) *ExtensionTask {
newTask := task.clone()
// Assign the pointer of the properties that the extension supports modification
newTask.Meta = task.Meta
newTask.Status = task.Status
return &ExtensionTask{
Task: task.clone(),
Task: newTask,
download: download,
}
}
func (t *ExtensionTask) Continue() error {
return t.download.Continue(t.ID)
return t.download.Continue(&TaskFilter{
IDs: []string{t.ID},
})
}
func (t *ExtensionTask) Pause() error {
return t.download.Pause(t.ID)
return t.download.Pause(&TaskFilter{
IDs: []string{t.ID},
})
}
func parseSettings(settings []*Setting) map[string]any {
+39 -2
View File
@@ -247,8 +247,45 @@ func TestDownloader_Extension_OnError(t *testing.T) {
}
// extension on error modify url and continue download
task := downloader.GetTask(id)
if task.Meta.Req.URL != "https://github.com" {
t.Fatalf("except url: https://github.com, actual: %s", task.Meta.Req.URL)
if task.Status != base.DownloadStatusDone {
t.Fatalf("except status is done, actual: %s", task.Status)
}
})
}
func TestDownloader_Extension_OnDone(t *testing.T) {
setupDownloader(func(downloader *Downloader) {
if _, err := downloader.InstallExtensionByFolder("./testdata/extensions/on_done", false); err != nil {
t.Fatal(err)
}
errCh := make(chan error, 1)
downloader.Listener(func(event *Event) {
if event.Key == EventKeyFinally {
errCh <- event.Err
}
})
id, err := downloader.CreateDirect(&base.Request{
URL: "https://github.com",
}, nil)
if err != nil {
t.Fatal(err)
}
select {
case err = <-errCh:
break
case <-time.After(time.Second * 10):
err = errors.New("timeout")
}
// wait for script execution
time.Sleep(time.Millisecond * 3000)
if err != nil {
panic("extension on done download error: " + err.Error())
}
// extension on error modify url and continue download
task := downloader.GetTask(id)
if task.Meta.Req.Labels["modified"] != "true" {
t.Fatalf("except label: modified=true, actual: %s", task.Meta.Req.Labels["modified"])
}
if task.Status != base.DownloadStatusDone {
t.Fatalf("except status is done, actual: %s", task.Status)
+57 -13
View File
@@ -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,22 +50,54 @@ 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
}
func (t *Task) clone() *Task {
return &Task{
ID: t.ID,
Protocol: t.Protocol,
Meta: t.Meta,
Status: t.Status,
Uploading: t.Uploading,
Progress: t.Progress,
CreatedAt: t.CreatedAt,
UpdatedAt: t.UpdatedAt,
}
return util.DeepClone(t)
}
func (t *Task) calcSpeed(speedArr []int64, downloaded int64, usedTime float64) int64 {
@@ -79,13 +112,24 @@ func (t *Task) calcSpeed(speedArr []int64, downloaded int64, usedTime float64) i
return int64(float64(total) / float64(len(speedArr)) / usedTime)
}
type TaskFilter struct {
IDs []string
Statuses []base.Status
NotStatuses []base.Status
}
func (f *TaskFilter) IsEmpty() bool {
return len(f.IDs) == 0 && len(f.Statuses) == 0 && len(f.NotStatuses) == 0
}
type DownloaderConfig struct {
Controller *controller.Controller
FetchManagers []fetcher.FetcherManager
RefreshInterval int `json:"refreshInterval"` // RefreshInterval time duration to refresh task progress(ms)
Storage Storage
StorageDir string
RefreshInterval int `json:"refreshInterval"` // RefreshInterval time duration to refresh task progress(ms)
Storage Storage
StorageDir string
DownloadDirWhiteList []string `json:"downloadDirWhiteList"`
ProductionMode bool
+5
View File
@@ -0,0 +1,5 @@
gopeed.events.onDone(async function (ctx) {
gopeed.logger.info("url", ctx.task.meta.req.url);
ctx.task.meta.req.labels['modified'] = 'true';
});
+16
View File
@@ -0,0 +1,16 @@
{
"name": "on-done",
"title": "gopeed extension on done event test",
"version": "0.0.1",
"scripts": [
{
"event": "onDone",
"match": {
"urls": [
"*://github.com/*"
]
},
"entry": "index.js"
}
]
}
+75 -29
View File
@@ -74,13 +74,27 @@ func CreateTaskBatch(w http.ResponseWriter, r *http.Request) {
}
func PauseTask(w http.ResponseWriter, r *http.Request) {
vars := mux.Vars(r)
taskId := vars["id"]
if taskId == "" {
WriteJson(w, model.NewErrorResult("param invalid: id", model.CodeInvalidParam))
filter, errResult := parseIdFilter(r)
if errResult != nil {
WriteJson(w, errResult)
return
}
if err := Downloader.Pause(taskId); err != nil {
if err := Downloader.Pause(filter); err != nil {
WriteJson(w, model.NewErrorResult(err.Error()))
return
}
WriteJson(w, model.NewNilResult())
}
func PauseTasks(w http.ResponseWriter, r *http.Request) {
filter, errResult := parseFilter(r)
if errResult != nil {
WriteJson(w, errResult)
return
}
if err := Downloader.Pause(filter); err != nil {
WriteJson(w, model.NewErrorResult(err.Error()))
return
}
@@ -88,29 +102,27 @@ func PauseTask(w http.ResponseWriter, r *http.Request) {
}
func ContinueTask(w http.ResponseWriter, r *http.Request) {
vars := mux.Vars(r)
taskId := vars["id"]
if taskId == "" {
WriteJson(w, model.NewErrorResult("param invalid: id", model.CodeInvalidParam))
filter, errResult := parseIdFilter(r)
if errResult != nil {
WriteJson(w, errResult)
return
}
if err := Downloader.Continue(taskId); err != nil {
if err := Downloader.Continue(filter); err != nil {
WriteJson(w, model.NewErrorResult(err.Error()))
return
}
WriteJson(w, model.NewNilResult())
}
func PauseAllTask(w http.ResponseWriter, r *http.Request) {
if err := Downloader.PauseAll(); err != nil {
WriteJson(w, model.NewErrorResult(err.Error()))
func ContinueTasks(w http.ResponseWriter, r *http.Request) {
filter, errResult := parseFilter(r)
if errResult != nil {
WriteJson(w, errResult)
return
}
WriteJson(w, model.NewNilResult())
}
func ContinueAllTask(w http.ResponseWriter, r *http.Request) {
if err := Downloader.ContinueAll(); err != nil {
if err := Downloader.Continue(filter); err != nil {
WriteJson(w, model.NewErrorResult(err.Error()))
return
}
@@ -118,14 +130,14 @@ func ContinueAllTask(w http.ResponseWriter, r *http.Request) {
}
func DeleteTask(w http.ResponseWriter, r *http.Request) {
vars := mux.Vars(r)
taskId := vars["id"]
force := r.FormValue("force")
if taskId == "" {
WriteJson(w, model.NewErrorResult("param invalid: id", model.CodeInvalidParam))
filter, errResult := parseIdFilter(r)
if errResult != nil {
WriteJson(w, errResult)
return
}
if err := Downloader.Delete(taskId, force == "true"); err != nil {
force := r.FormValue("force")
if err := Downloader.Delete(filter, force == "true"); err != nil {
WriteJson(w, model.NewErrorResult(err.Error()))
return
}
@@ -133,10 +145,14 @@ func DeleteTask(w http.ResponseWriter, r *http.Request) {
}
func DeleteTasks(w http.ResponseWriter, r *http.Request) {
r.ParseForm()
status := r.Form["status"]
filter, errResult := parseFilter(r)
if errResult != nil {
WriteJson(w, errResult)
return
}
force := r.FormValue("force")
if err := Downloader.DeleteByStatues(convertStatues(status), force == "true"); err != nil {
if err := Downloader.Delete(filter, force == "true"); err != nil {
WriteJson(w, model.NewErrorResult(err.Error()))
return
}
@@ -159,9 +175,13 @@ func GetTask(w http.ResponseWriter, r *http.Request) {
}
func GetTasks(w http.ResponseWriter, r *http.Request) {
r.ParseForm()
status := r.Form["status"]
tasks := Downloader.GetTasksByStatues(convertStatues(status))
filter, errResult := parseFilter(r)
if errResult != nil {
WriteJson(w, errResult)
return
}
tasks := Downloader.GetTasksByFilter(filter)
WriteJson(w, model.NewOkResult(tasks))
}
@@ -324,6 +344,32 @@ func GetStats(w http.ResponseWriter, r *http.Request) {
WriteJson(w, model.NewOkResult(statsResult))
}
func parseIdFilter(r *http.Request) (*download.TaskFilter, any) {
vars := mux.Vars(r)
taskId := vars["id"]
if taskId == "" {
return nil, model.NewErrorResult("param invalid: id", model.CodeInvalidParam)
}
filter := &download.TaskFilter{
IDs: []string{taskId},
}
return filter, nil
}
func parseFilter(r *http.Request) (*download.TaskFilter, any) {
if err := r.ParseForm(); err != nil {
return nil, model.NewErrorResult(err.Error())
}
filter := &download.TaskFilter{
IDs: r.Form["id"],
Statuses: convertStatues(r.Form["status"]),
NotStatuses: convertStatues(r.Form["notStatus"]),
}
return filter, nil
}
func convertStatues(statues []string) []base.Status {
result := make([]base.Status, 0)
for _, status := range statues {
+8 -7
View File
@@ -14,13 +14,14 @@ const (
)
type StartConfig struct {
Network string `json:"network"`
Address string `json:"address"`
RefreshInterval int `json:"refreshInterval"`
Storage Storage `json:"storage"`
StorageDir string `json:"storageDir"`
ApiToken string `json:"apiToken"`
DownloadConfig *base.DownloaderStoreConfig `json:"downloadConfig"`
Network string `json:"network"`
Address string `json:"address"`
RefreshInterval int `json:"refreshInterval"`
Storage Storage `json:"storage"`
StorageDir string `json:"storageDir"`
WhiteDownloadDirs []string `json:"whiteDownloadDirs"`
ApiToken string `json:"apiToken"`
DownloadConfig *base.DownloaderStoreConfig `json:"downloadConfig"`
ProductionMode bool
+2 -2
View File
@@ -104,9 +104,9 @@ func BuildServer(startCfg *model.StartConfig) (*http.Server, net.Listener, error
r.Methods(http.MethodPost).Path("/api/v1/tasks").HandlerFunc(CreateTask)
r.Methods(http.MethodPost).Path("/api/v1/tasks/batch").HandlerFunc(CreateTaskBatch)
r.Methods(http.MethodPut).Path("/api/v1/tasks/{id}/pause").HandlerFunc(PauseTask)
r.Methods(http.MethodPut).Path("/api/v1/tasks/pause").HandlerFunc(PauseTasks)
r.Methods(http.MethodPut).Path("/api/v1/tasks/{id}/continue").HandlerFunc(ContinueTask)
r.Methods(http.MethodPut).Path("/api/v1/tasks/pause").HandlerFunc(PauseAllTask)
r.Methods(http.MethodPut).Path("/api/v1/tasks/continue").HandlerFunc(ContinueAllTask)
r.Methods(http.MethodPut).Path("/api/v1/tasks/continue").HandlerFunc(ContinueTasks)
r.Methods(http.MethodDelete).Path("/api/v1/tasks/{id}").HandlerFunc(DeleteTask)
r.Methods(http.MethodDelete).Path("/api/v1/tasks").HandlerFunc(DeleteTasks)
r.Methods(http.MethodGet).Path("/api/v1/tasks/{id}").HandlerFunc(GetTask)
+2 -2
View File
@@ -582,8 +582,8 @@ func doTest(handler func()) {
Downloader.Clear()
}()
defer func() {
Downloader.PauseAll()
Downloader.DeleteByStatues(nil, true)
Downloader.Pause(nil)
Downloader.Delete(nil, true)
os.RemoveAll(cfg.StorageDir)
}()
taskReq.URL = "http://" + fileListener.Addr().String() + "/" + test.BuildName
+14
View File
@@ -12,3 +12,17 @@ func MapToStruct(s any, v any) error {
}
return json.Unmarshal(b, v)
}
func DeepClone[T any](v *T) *T {
if v == nil {
return nil
}
var t T
b, err := json.Marshal(v)
if err != nil {
return &t
}
json.Unmarshal(b, &t)
return &t
}
+60
View File
@@ -0,0 +1,60 @@
package util
import (
"reflect"
"testing"
)
func TestDeepClone(t *testing.T) {
type user struct {
Name string `json:"name"`
Age int `json:"age"`
v int
}
type args[T any] struct {
v *T
}
type testCase[T any] struct {
name string
args args[T]
want *T
}
tests := []testCase[user]{
{
name: "case 1",
args: args[user]{
v: &user{
Name: "test",
Age: 10,
},
},
want: &user{
Name: "test",
Age: 10,
},
},
{
name: "case 2",
args: args[user]{
v: &user{
Name: "test",
Age: 10,
v: 1,
},
},
want: &user{
Name: "test",
Age: 10,
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := DeepClone(tt.args.v); !reflect.DeepEqual(got, tt.want) {
t.Errorf("DeepClone() = %v, want %v", got, tt.want)
}
})
}
}
+1 -1
View File
@@ -47,7 +47,7 @@ android {
applicationId "com.gopeed"
// You can update the following values to match your application needs.
// For more information, see: https://docs.flutter.dev/deployment/android#reviewing-the-build-configuration.
minSdkVersion 32
minSdkVersion 21
targetSdkVersion 29
versionCode flutterVersionCode.toInteger()
versionName flutterVersionName
@@ -2,9 +2,15 @@
package="com.gopeed">
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.READ_MEDIA_IMAGES" />
<uses-permission android:name="android.permission.READ_MEDIA_AUDIO" />
<uses-permission android:name="android.permission.READ_MEDIA_VIDEO" />
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" android:maxSdkVersion="32" />
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" android:maxSdkVersion="32" />
<uses-permission android:name="android.permission.REQUEST_INSTALL_PACKAGES" />
<application
android:requestLegacyExternalStorage="true"
android:name="${applicationName}"
android:icon="@mipmap/ic_launcher"
android:label="Gopeed"
Binary file not shown.

Before

Width:  |  Height:  |  Size: 181 KiB

After

Width:  |  Height:  |  Size: 63 KiB

@@ -79,6 +79,7 @@ class ExtraConfig {
String themeMode;
String locale;
bool lastDeleteTaskKeep;
bool defaultDirectDownload;
ExtraConfigBt bt = ExtraConfigBt();
@@ -86,6 +87,7 @@ class ExtraConfig {
this.themeMode = '',
this.locale = '',
this.lastDeleteTaskKeep = false,
this.defaultDirectDownload = false,
});
factory ExtraConfig.fromJson(Map<String, dynamic>? json) =>
@@ -9,7 +9,7 @@ part of 'downloader_config.dart';
DownloaderConfig _$DownloaderConfigFromJson(Map<String, dynamic> 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<String, dynamic>?)
@@ -38,7 +38,7 @@ Map<String, dynamic> _$ProtocolConfigToJson(ProtocolConfig instance) =>
HttpConfig _$HttpConfigFromJson(Map<String, dynamic> 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<String, dynamic> _$HttpConfigToJson(HttpConfig instance) =>
};
BtConfig _$BtConfigFromJson(Map<String, dynamic> json) => BtConfig(
listenPort: json['listenPort'] as int? ?? 0,
listenPort: (json['listenPort'] as num?)?.toInt() ?? 0,
trackers: (json['trackers'] as List<dynamic>?)
?.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<String, dynamic> _$BtConfigToJson(BtConfig instance) => <String, dynamic>{
@@ -72,6 +72,7 @@ ExtraConfig _$ExtraConfigFromJson(Map<String, dynamic> json) => ExtraConfig(
themeMode: json['themeMode'] as String? ?? '',
locale: json['locale'] as String? ?? '',
lastDeleteTaskKeep: json['lastDeleteTaskKeep'] as bool? ?? false,
defaultDirectDownload: json['defaultDirectDownload'] as bool? ?? false,
)..bt = ExtraConfigBt.fromJson(json['bt'] as Map<String, dynamic>);
Map<String, dynamic> _$ExtraConfigToJson(ExtraConfig instance) =>
@@ -79,6 +80,7 @@ Map<String, dynamic> _$ExtraConfigToJson(ExtraConfig instance) =>
'themeMode': instance.themeMode,
'locale': instance.locale,
'lastDeleteTaskKeep': instance.lastDeleteTaskKeep,
'defaultDirectDownload': instance.defaultDirectDownload,
'bt': instance.bt.toJson(),
};
+23
View File
@@ -7,11 +7,13 @@ class Request {
String url;
Object? extra;
Map<String, String>? labels = {};
RequestProxy? proxy;
Request({
required this.url,
this.extra,
this.labels,
this.proxy,
});
factory Request.fromJson(Map<String, dynamic> json) =>
@@ -45,3 +47,24 @@ class ReqExtraBt {
Map<String, dynamic> toJson() => _$ReqExtraBtToJson(this);
}
enum RequestProxyMode {
follow,
none,
custom,
}
@JsonSerializable()
class RequestProxy {
RequestProxyMode mode = RequestProxyMode.follow;
String scheme = 'http';
String host = '';
String usr = '';
String pwd = '';
RequestProxy();
factory RequestProxy.fromJson(Map<String, dynamic> json) =>
_$RequestProxyFromJson(json);
Map<String, dynamic> toJson() => _$RequestProxyToJson(this);
}
+26
View File
@@ -12,6 +12,9 @@ Request _$RequestFromJson(Map<String, dynamic> json) => Request(
labels: (json['labels'] as Map<String, dynamic>?)?.map(
(k, e) => MapEntry(k, e as String),
),
proxy: json['proxy'] == null
? null
: RequestProxy.fromJson(json['proxy'] as Map<String, dynamic>),
);
Map<String, dynamic> _$RequestToJson(Request instance) {
@@ -27,6 +30,7 @@ Map<String, dynamic> _$RequestToJson(Request instance) {
writeNotNull('extra', instance.extra);
writeNotNull('labels', instance.labels);
writeNotNull('proxy', instance.proxy?.toJson());
return val;
}
@@ -50,3 +54,25 @@ Map<String, dynamic> _$ReqExtraBtToJson(ReqExtraBt instance) =>
<String, dynamic>{
'trackers': instance.trackers,
};
RequestProxy _$RequestProxyFromJson(Map<String, dynamic> json) => RequestProxy()
..mode = $enumDecode(_$RequestProxyModeEnumMap, json['mode'])
..scheme = json['scheme'] as String
..host = json['host'] as String
..usr = json['usr'] as String
..pwd = json['pwd'] as String;
Map<String, dynamic> _$RequestProxyToJson(RequestProxy instance) =>
<String, dynamic>{
'mode': _$RequestProxyModeEnumMap[instance.mode]!,
'scheme': instance.scheme,
'host': instance.host,
'usr': instance.usr,
'pwd': instance.pwd,
};
const _$RequestProxyModeEnumMap = {
RequestProxyMode.follow: 'follow',
RequestProxyMode.none: 'none',
RequestProxyMode.custom: 'custom',
};
+2 -2
View File
@@ -8,7 +8,7 @@ part of 'resource.dart';
Resource _$ResourceFromJson(Map<String, dynamic> json) => Resource(
name: json['name'] as String? ?? "",
size: json['size'] as int? ?? 0,
size: (json['size'] as num?)?.toInt() ?? 0,
range: json['range'] as bool? ?? false,
files: (json['files'] as List<dynamic>)
.map((e) => FileInfo.fromJson(e as Map<String, dynamic>))
@@ -27,7 +27,7 @@ Map<String, dynamic> _$ResourceToJson(Resource instance) => <String, dynamic>{
FileInfo _$FileInfoFromJson(Map<String, dynamic> json) => FileInfo(
path: json['path'] as String? ?? "",
name: json['name'] as String,
size: json['size'] as int? ?? 0,
size: (json['size'] as num?)?.toInt() ?? 0,
req: json['req'] == null
? null
: Request.fromJson(json['req'] as Map<String, dynamic>),
+2
View File
@@ -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,
+7 -5
View File
@@ -8,6 +8,7 @@ part of 'task.dart';
Task _$TaskFromJson(Map<String, dynamic> json) => Task(
id: json['id'] as String,
name: json['name'] as String,
meta: Meta.fromJson(json['meta'] as Map<String, dynamic>),
status: $enumDecode(_$StatusEnumMap, json['status']),
uploading: json['uploading'] as bool,
@@ -19,6 +20,7 @@ Task _$TaskFromJson(Map<String, dynamic> json) => Task(
Map<String, dynamic> _$TaskToJson(Task instance) {
final val = <String, dynamic>{
'id': instance.id,
'name': instance.name,
};
void writeNotNull(String key, dynamic value) {
@@ -52,11 +54,11 @@ const _$ProtocolEnumMap = {
};
Progress _$ProgressFromJson(Map<String, dynamic> 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<String, dynamic> _$ProgressToJson(Progress instance) => <String, dynamic>{
@@ -3,6 +3,9 @@ import 'dart:typed_data';
import 'package:flutter/material.dart';
import 'package:get/get.dart';
import 'package:gopeed/api/model/request.dart';
import '../../app/controllers/app_controller.dart';
class CreateController extends GetxController
with GetSingleTickerProviderStateMixin {
@@ -13,6 +16,7 @@ class CreateController extends GetxController
final isConfirming = false.obs;
final showAdvanced = false.obs;
final directDownload = false.obs;
final proxyConfig = Rx<RequestProxy?>(null);
late TabController advancedTabController;
final oldUrl = "".obs;
final fileDataUri = "".obs;
@@ -21,6 +25,11 @@ class CreateController extends GetxController
void onInit() {
super.onInit();
advancedTabController = TabController(length: 2, vsync: this);
directDownload.value = Get.find<AppController>()
.downloaderConfig
.value
.extra
.defaultDirectDownload;
}
@override
@@ -32,6 +32,10 @@ class CreateView extends GetView<CreateController> {
final _connectionsController = TextEditingController();
final _pathController = TextEditingController();
final _confirmController = RoundedLoadingButtonController();
final _proxyIpController = TextEditingController();
final _proxyPortController = TextEditingController();
final _proxyUsrController = TextEditingController();
final _proxyPwdController = TextEditingController();
final _httpUaController = TextEditingController();
final _httpCookieController = TextEditingController();
final _httpRefererController = TextEditingController();
@@ -206,7 +210,7 @@ class CreateView extends GetView<CreateController> {
decoration: BoxDecoration(
color: Theme.of(context)
.colorScheme
.background,
.surface,
borderRadius:
BorderRadius.circular(
10.0),
@@ -261,7 +265,164 @@ class CreateView extends GetView<CreateController> {
() => Visibility(
visible: controller.showAdvanced.value,
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Transform.translate(
offset: const Offset(-40, 0),
child: Row(
children: [
const Icon(
Icons.wifi_2_bar,
color: Colors.grey,
),
const SizedBox(
width: 15,
),
SizedBox(
width: 150,
child: DropdownButton<
RequestProxyMode>(
hint: Text('proxy'.tr),
isExpanded: true,
value: controller
.proxyConfig.value?.mode,
onChanged: (value) async {
if (value != null) {
controller.proxyConfig
.value = RequestProxy()
..mode = value;
}
},
items: [
DropdownMenuItem<
RequestProxyMode>(
value:
RequestProxyMode.follow,
child: Text(
'followSettings'.tr),
),
DropdownMenuItem<
RequestProxyMode>(
value:
RequestProxyMode.none,
child: Text('noProxy'.tr),
),
DropdownMenuItem<
RequestProxyMode>(
value:
RequestProxyMode.custom,
child:
Text('customProxy'.tr),
),
],
))
],
),
),
...(controller.proxyConfig.value?.mode ==
RequestProxyMode.custom
? [
SizedBox(
width: 150,
child: DropdownButtonFormField<
String>(
value: controller
.proxyConfig.value?.scheme,
onChanged: (value) async {
if (value != null) {}
},
items: const [
DropdownMenuItem<String>(
value: 'http',
child: Text('HTTP'),
),
DropdownMenuItem<String>(
value: 'https',
child: Text('HTTPS'),
),
DropdownMenuItem<String>(
value: 'socks5',
child: Text('SOCKS5'),
),
],
),
),
Row(children: [
Flexible(
child: TextFormField(
controller:
_proxyIpController,
decoration: InputDecoration(
labelText: 'server'.tr,
contentPadding:
const EdgeInsets.all(
0.0),
),
),
),
const Padding(
padding: EdgeInsets.only(
left: 10)),
Flexible(
child: TextFormField(
controller:
_proxyPortController,
decoration: InputDecoration(
labelText: 'port'.tr,
contentPadding:
const EdgeInsets.all(
0.0),
),
keyboardType:
TextInputType.number,
inputFormatters: [
FilteringTextInputFormatter
.digitsOnly,
NumericalRangeFormatter(
min: 0, max: 65535),
],
),
),
]),
Row(children: [
Flexible(
child: TextFormField(
controller:
_proxyUsrController,
decoration: InputDecoration(
labelText: 'username'.tr,
contentPadding:
const EdgeInsets.all(
0.0),
),
),
),
const Padding(
padding: EdgeInsets.only(
left: 10)),
Flexible(
child: TextFormField(
controller:
_proxyPwdController,
decoration: InputDecoration(
labelText: 'password'.tr,
contentPadding:
const EdgeInsets.all(
0.0),
),
),
),
])
]
: const []),
],
),
const Divider(),
TabBar(
controller: controller.advancedTabController,
tabs: const [
@@ -416,18 +577,21 @@ class CreateView extends GetView<CreateController> {
if (isDirect) {
await Future.wait(urls.map((url) {
return createTask(CreateTask(
req: Request(url: url, extra: parseReqExtra(url)),
req: Request(
url: url, extra: parseReqExtra(url), proxy: parseProxy()),
opt: Options(
name: isMultiLine ? "" : _renameController.text,
path: _pathController.text,
selectFiles: [],
extra: parseReqOptsExtra())));
name: isMultiLine ? "" : _renameController.text,
path: _pathController.text,
selectFiles: [],
extra: parseReqOptsExtra(),
)));
}));
Get.rootDelegate.offNamed(Routes.TASK);
} else {
final rr = await resolve(Request(
url: submitUrl,
extra: parseReqExtra(_urlController.text),
proxy: parseProxy(),
));
await _showResolveDialog(rr);
}
@@ -440,6 +604,18 @@ class CreateView extends GetView<CreateController> {
}
}
RequestProxy? parseProxy() {
if (controller.proxyConfig.value?.mode == RequestProxyMode.custom) {
return RequestProxy()
..mode = RequestProxyMode.custom
..scheme = _proxyIpController.text
..host = "${_proxyIpController.text}:${_proxyPortController.text}"
..usr = _proxyUsrController.text
..pwd = _proxyPwdController.text;
}
return controller.proxyConfig.value;
}
Object? parseReqExtra(String url) {
Object? reqExtra;
if (controller.showAdvanced.value) {
@@ -543,7 +719,7 @@ class CreateView extends GetView<CreateController> {
controller.selectedIndexes.map((index) {
final file = rr.res.files[index];
return createTask(CreateTask(
req: file.req!,
req: file.req!..proxy = parseProxy(),
opt: Options(
name: file.name,
path: path.join(_pathController.text,
@@ -3,7 +3,6 @@ import 'dart:io';
import 'package:badges/badges.dart' as badges;
import 'package:flutter/material.dart';
import 'package:path/path.dart' as path;
import 'package:flutter/services.dart';
import 'package:get/get.dart';
import 'package:gopeed/app/views/copy_button.dart';
@@ -110,6 +109,28 @@ class SettingView extends GetView<SettingController> {
],
);
});
final buildDefaultDirectDownload =
_buildConfigItem('defaultDirectDownload'.tr, () {
return appController.downloaderConfig.value.extra.defaultDirectDownload
? 'on'.tr
: 'off'.tr;
}, (Key key) {
return Container(
alignment: Alignment.centerLeft,
child: Switch(
value:
appController.downloaderConfig.value.extra.defaultDirectDownload,
onChanged: (bool value) async {
appController.downloaderConfig.update((val) {
val!.extra.defaultDirectDownload = value;
});
await debounceSave();
},
),
);
});
buildBrowserExtension() {
return ListTile(
title: Text('browserExtension'.tr),
@@ -847,7 +868,7 @@ class SettingView extends GetView<SettingController> {
// advanced config log items start
buildLogsDir() {
return ListTile(
title: Text("日志目录"),
title: Text("logDirectory".tr),
subtitle: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
@@ -904,6 +925,7 @@ class SettingView extends GetView<SettingController> {
children: _addDivider([
buildDownloadDir(),
buildMaxRunning(),
buildDefaultDirectDownload(),
buildBrowserExtension(),
buildAutoStartup(),
]),
@@ -981,7 +1003,7 @@ class SettingView extends GetView<SettingController> {
: null,
]),
)),
const Text('开发者'),
Text('developer'.tr),
Card(
child: Column(
children: _addDivider([
@@ -82,8 +82,7 @@ class TaskView extends GetView<TaskController> {
),
ListTile(
title: Text('taskName'.tr),
subtitle:
buildTooltipSubtitle(selectTask.value?.showName)),
subtitle: buildTooltipSubtitle(selectTask.value?.name)),
ListTile(
title: Text('taskUrl'.tr),
subtitle:
@@ -121,42 +120,12 @@ class TaskView extends GetView<TaskController> {
}
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 {
if (isFolder) {
return path.join(Util.safeDir(meta.opts.path),
Util.safeDir(meta.res!.files[0].path), showName);
} else {
return path.join(Util.safeDir(meta.opts.path), Util.safeDir(showName));
}
return path.join(Util.safeDir(meta.opts.path), Util.safeDir(name));
}
Future<void> explorer() async {
@@ -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(
@@ -1,16 +1,20 @@
import 'dart:io';
import 'package:device_info_plus/device_info_plus.dart';
import 'package:file_picker/file_picker.dart';
import 'package:flutter/material.dart';
import 'package:get/get.dart';
import 'package:lecle_downloads_path_provider/lecle_downloads_path_provider.dart';
import 'package:path_provider/path_provider.dart';
import 'package:permission_handler/permission_handler.dart';
import 'package:toggle_switch/toggle_switch.dart';
import '../../util/mac_secure_util.dart';
import '../../util/message.dart';
import '../../util/util.dart';
final deviceInfo = DeviceInfoPlugin();
class DirectorySelector extends StatefulWidget {
final TextEditingController controller;
final bool showLabel;
@@ -68,12 +72,26 @@ class _DirectorySelectorState extends State<DirectorySelector> {
if (index == 0) {
return false;
}
// Check write permission
final downloadDir =
(await DownloadsPath.downloadsDirectory())?.path;
if (downloadDir == null) {
return true;
}
// Check and request external storage permission when sdk version < 30 (android 11)
if ((await deviceInfo.androidInfo).version.sdkInt < 30) {
var status = await Permission.storage.status;
if (!status.isGranted) {
status = await Permission.storage.request();
if (!status.isGranted) {
showErrorMessage('noStoragePermission'.tr);
return true;
}
}
}
// Check write permission
final fileRandomeName =
"test_${DateTime.now().millisecondsSinceEpoch}.tmp";
final testFile = File('$downloadDir/Gopeed/$fileRandomeName');
+5 -1
View File
@@ -17,6 +17,7 @@ const enUS = {
'create': 'Create Task',
'directDownload': 'Direct Download',
'advancedOptions': 'Advanced Options',
'followSettings': 'Follow Settings',
'downloadLink': 'Download Link',
'downloadLinkValid': 'Please enter the download link',
'downloadLinkHit':
@@ -35,6 +36,7 @@ const enUS = {
'connections': 'Connections',
'useServerCtime': 'Use server time for file creation',
'maxRunning': 'Max Running Tasks',
'defaultDirectDownload': 'Check direct download by default',
'items': '@count items',
'subscribeTracker': 'Subscribe Tracker',
'subscribeFail':
@@ -60,6 +62,8 @@ const enUS = {
'set': 'SET',
'portInUse': 'Port [@port] is in use, please change the port',
'effectAfterRestart': 'Effect after restart',
'developer': 'Developer',
'logDirectory': 'Log Directory',
'show': 'Show',
'startAll': 'Start All',
'pauseAll': 'Pause All',
@@ -96,7 +100,7 @@ const enUS = {
'browserExtension': 'Browser Extension',
'launchAtStartup': 'Launch at Startup',
'seedConfig': 'Seed Config',
'seedKeep': 'Keep seeding',
'seedKeep': 'Keep seeding until manually stopped',
'seedRatio': 'Seed ratio',
'seedTime': 'Seed time (minutes)',
'taskDetail': 'Task Detail',
+107
View File
@@ -0,0 +1,107 @@
const esES = {
'es_ES': {
'label': 'Español',
'error': 'Error',
'tip': 'Consejo',
'confirm': 'Confirmar',
'cancel': 'Cancelar',
'on': 'Activado',
'off': 'Desactivado',
'selectAll': 'Seleccionar Todo',
'task': 'Tareas',
'downloading': 'descargando',
'downloaded': 'descargado',
'setting': 'Ajustes',
'donate': 'Donar',
'exit': 'Salir',
'create': 'Crear Tarea',
'directDownload': 'Descarga Directa',
'advancedOptions': 'Opciones Avanzadas',
'downloadLink': 'Enlace de Descarga',
'downloadLinkValid': 'Por favor, introduce el enlace de descarga',
'downloadLinkHit':
'Por favor, introduce el enlace de descarga, se admite HTTP/HTTPS/MAGNET@append',
'downloadLinkHitDesktop': ', o arrastra el archivo torrent aquí directamente',
'download': 'Descargar',
'noFileSelected': 'Por favor, selecciona al menos un archivo para continuar.',
'noStoragePermission': 'Se requiere permiso de almacenamiento',
'selectFile': 'Seleccionar Archivo',
'rename': 'Renombrar',
'basic': 'Básico',
'advanced': 'Avanzado',
'general': 'General',
'downloadDir': 'Directorio de Descarga',
'downloadDirValid': 'Por favor, selecciona el directorio de descarga',
'connections': 'Conexiones',
'useServerCtime': 'Usar tiempo del servidor para la creación de archivos',
'maxRunning': 'Máximo de Tareas en Ejecución',
'items': '@count elementos',
'subscribeTracker': 'Suscribirse al Tracker',
'subscribeFail':
'Suscripción fallida, por favor verifica la red o intenta más tarde',
'update': 'Actualizar',
'updateDaily': 'Actualizar diariamente',
'lastUpdate': 'Última actualización: @time',
'addTracker': 'Añadir Tracker',
'addTrackerHit': 'Por favor, introduce la URL del servidor tracker, una por línea',
'ui': 'Interfaz',
'theme': 'Tema',
'themeSystem': 'Sistema',
'themeLight': 'Claro',
'themeDark': 'Oscuro',
'locale': 'Idioma',
'about': 'Acerca de',
'homepage': 'Página de inicio',
'version': 'Versión',
'protocol': 'Protocolo',
'port': 'Puerto',
'apiToken': 'Token API',
'notSet': 'No establecido',
'set': 'Establecido',
'portInUse': 'El puerto [@port] está en uso, por favor cambia el puerto',
'effectAfterRestart': 'Efectivo después de reiniciar',
'show': 'Mostrar',
'startAll': 'Iniciar Todo',
'pauseAll': 'Pausar Todo',
'deleteTask': 'Eliminar Tarea',
'deleteTaskTip': 'Mantener archivos descargados',
'delete': 'Eliminar',
'newVersionTitle': 'Nueva versión @version disponible',
'newVersionUpdate': 'Actualizar Ahora',
'newVersionLater': 'Más tarde',
'extensions': 'Extensiones',
'extensionInstallUrl': 'URL de instalación',
'extensionInstallSuccess': 'Instalado con éxito',
'extensionUpdateSuccess': 'Actualizado con éxito',
'extensionDelete': 'Eliminar Extensión',
'extensionAlreadyLatest': 'Ya es la versión más reciente',
'extensionFind': 'Buscar Extensiones',
'extensionDevelop': 'Desarrollar Extensiones',
'history': 'Historial',
'clearHistory': 'Borrar Historial',
'noHistoryFound': 'No se encontró historial',
'serviceTitle': 'Servicio de Descarga',
'serviceText': 'En ejecución',
'network': 'Red',
'proxy': 'Proxy',
'noProxy': 'Sin Proxy',
'systemProxy': 'Proxy del Sistema',
'customProxy': 'Proxy Personalizado',
'server': 'Servidor',
'username': 'Nombre de usuario',
'password': 'Contraseña',
'thanks': 'Agradecimientos',
'thanksDesc':
'¡Gracias a todos los contribuyentes que han ayudado a construir y desarrollar la comunidad Gopeed!',
'browserExtension': 'Extensión del Navegador',
'launchAtStartup': 'Iniciar al Arranque',
'seedConfig': 'Configuración de Semilla',
'seedKeep': 'Mantener sembrado',
'seedRatio': 'Ratio de semilla',
'seedTime': 'Tiempo de semilla (minutos)',
'taskDetail': 'Detalles de la Tarea',
'taskName': 'Nombre de la Tarea',
'taskUrl': 'URL de la Tarea',
'downloadPath': 'Ruta de Descarga',
},
};
+5 -1
View File
@@ -17,6 +17,7 @@ const zhCN = {
'create': '创建任务',
'directDownload': '直接下载',
'advancedOptions': '高级选项',
'followSettings': '跟随设置',
'downloadLink': '下载链接',
'downloadLinkValid': '请输入下载链接',
'downloadLinkHit': '请输入下载链接,支持 HTTP/HTTPS/MAGNET@append',
@@ -34,6 +35,7 @@ const zhCN = {
'connections': '连接数',
'useServerCtime': '创建文件使用服务器时间',
'maxRunning': '最大下载数',
'defaultDirectDownload': '默认勾选直接下载',
'items': '@count 项',
'subscribeTracker': '订阅 Tracker',
'subscribeFail': '订阅失败,请检查网络或稍后重试',
@@ -58,6 +60,8 @@ const zhCN = {
'set': '已设置',
'portInUse': '端口[@port]已被占用,请更换端口',
'effectAfterRestart': '此配置项将在重启应用后生效',
'developer': '开发者',
'logDirectory': '日志目录',
'show': '显示',
'startAll': '全部开始',
'pauseAll': '全部暂停',
@@ -93,7 +97,7 @@ const zhCN = {
'browserExtension': '浏览器扩展',
'launchAtStartup': '开机自动运行',
'seedConfig': '做种设置',
'seedKeep': '持续做种',
'seedKeep': '持续做种直到手动停止',
'seedRatio': '做种分享率',
'seedTime': '做种时间(分钟)',
'taskDetail': '任务详情',
+5 -7
View File
@@ -17,6 +17,7 @@ const zhTW = {
'create': '建立任務',
'directDownload': '直接下載',
'advancedOptions': '進階選項',
'followSettings': '跟隨設定',
'downloadLink': '下載連結',
'downloadLinkValid': '請輸入下載連結',
'downloadLinkHit': '請輸入下載連結,支援 HTTP/HTTPS/MAGNET@append',
@@ -34,6 +35,7 @@ const zhTW = {
'connections': '連接數',
'useServerCtime': '使用伺服器時間作為檔案建立時間',
'maxRunning': '最大執行任務數',
'defaultDirectDownload': '預設勾選直接下載',
'items': '@count 個項目',
'subscribeTracker': '訂閱 Tracker',
'subscribeFail': '訂閱失敗,請檢查網路或稍後再試',
@@ -58,6 +60,8 @@ const zhTW = {
'set': '已設定',
'portInUse': '連接埠 [@port] 已被使用,請更改連接埠',
'effectAfterRestart': '重新啟動後生效',
'developer': '開發者',
'logDirectory': '日誌目錄',
'show': '顯示',
'startAll': '全部開始',
'pauseAll': '全部暫停',
@@ -93,15 +97,9 @@ const zhTW = {
'browserExtension': '瀏覽器擴充功能',
'launchAtStartup': '開機自動執行',
'seedConfig': '做種設定',
'seedKeep': '持續做種',
'seedKeep': '持續做種直到手動停止',
'seedRatio': '做種分享率',
'seedTime': '做種時間(分鐘)',
/*
'taskDetail': '任务详情',
'taskName': '任务名称',
'taskUrl': '任务链接',
'downloadPath': '下载路径',
*/
'taskDetail': '任務詳情',
'taskName': '任務名稱',
'taskUrl': '任務連結',
+2
View File
@@ -13,6 +13,7 @@ import 'langs/tr_tr.dart';
import 'langs/vi_vn.dart';
import 'langs/zh_cn.dart';
import 'langs/zh_tw.dart';
import 'langs/es_es.dart';
final messages = _Messages();
@@ -33,5 +34,6 @@ class _Messages extends Translations {
...itIT,
...idID,
...frFR,
...esES,
};
}
+1 -1
View File
@@ -1,4 +1,4 @@
platform :osx, '10.11'
platform :osx, '10.14'
# CocoaPods analytics sends network stats synchronously affecting flutter build latency.
ENV['COCOAPODS_DISABLE_STATS'] = 'true'
+46 -20
View File
@@ -1,58 +1,84 @@
PODS:
- desktop_drop (0.0.1):
- app_links (1.0.0):
- FlutterMacOS
- file_selector_macos (0.0.1):
- desktop_drop (0.0.1):
- FlutterMacOS
- FlutterMacOS (1.0.0)
- macos_secure_bookmarks (0.0.3):
- FlutterMacOS
- package_info_plus (0.0.1):
- FlutterMacOS
- path_provider_macos (0.0.1):
- path_provider_foundation (0.0.1):
- Flutter
- FlutterMacOS
- shared_preferences_macos (0.0.1):
- screen_retriever (0.0.1):
- FlutterMacOS
- share_plus (0.0.1):
- FlutterMacOS
- shared_preferences_foundation (0.0.1):
- Flutter
- FlutterMacOS
- tray_manager (0.0.1):
- FlutterMacOS
- url_launcher_macos (0.0.1):
- FlutterMacOS
- window_manager (0.2.0):
- FlutterMacOS
DEPENDENCIES:
- app_links (from `Flutter/ephemeral/.symlinks/plugins/app_links/macos`)
- desktop_drop (from `Flutter/ephemeral/.symlinks/plugins/desktop_drop/macos`)
- file_selector_macos (from `Flutter/ephemeral/.symlinks/plugins/file_selector_macos/macos`)
- FlutterMacOS (from `Flutter/ephemeral`)
- macos_secure_bookmarks (from `Flutter/ephemeral/.symlinks/plugins/macos_secure_bookmarks/macos`)
- package_info_plus (from `Flutter/ephemeral/.symlinks/plugins/package_info_plus/macos`)
- path_provider_macos (from `Flutter/ephemeral/.symlinks/plugins/path_provider_macos/macos`)
- shared_preferences_macos (from `Flutter/ephemeral/.symlinks/plugins/shared_preferences_macos/macos`)
- path_provider_foundation (from `Flutter/ephemeral/.symlinks/plugins/path_provider_foundation/darwin`)
- screen_retriever (from `Flutter/ephemeral/.symlinks/plugins/screen_retriever/macos`)
- share_plus (from `Flutter/ephemeral/.symlinks/plugins/share_plus/macos`)
- shared_preferences_foundation (from `Flutter/ephemeral/.symlinks/plugins/shared_preferences_foundation/darwin`)
- tray_manager (from `Flutter/ephemeral/.symlinks/plugins/tray_manager/macos`)
- url_launcher_macos (from `Flutter/ephemeral/.symlinks/plugins/url_launcher_macos/macos`)
- window_manager (from `Flutter/ephemeral/.symlinks/plugins/window_manager/macos`)
EXTERNAL SOURCES:
app_links:
:path: Flutter/ephemeral/.symlinks/plugins/app_links/macos
desktop_drop:
:path: Flutter/ephemeral/.symlinks/plugins/desktop_drop/macos
file_selector_macos:
:path: Flutter/ephemeral/.symlinks/plugins/file_selector_macos/macos
FlutterMacOS:
:path: Flutter/ephemeral
macos_secure_bookmarks:
:path: Flutter/ephemeral/.symlinks/plugins/macos_secure_bookmarks/macos
package_info_plus:
:path: Flutter/ephemeral/.symlinks/plugins/package_info_plus/macos
path_provider_macos:
:path: Flutter/ephemeral/.symlinks/plugins/path_provider_macos/macos
shared_preferences_macos:
:path: Flutter/ephemeral/.symlinks/plugins/shared_preferences_macos/macos
path_provider_foundation:
:path: Flutter/ephemeral/.symlinks/plugins/path_provider_foundation/darwin
screen_retriever:
:path: Flutter/ephemeral/.symlinks/plugins/screen_retriever/macos
share_plus:
:path: Flutter/ephemeral/.symlinks/plugins/share_plus/macos
shared_preferences_foundation:
:path: Flutter/ephemeral/.symlinks/plugins/shared_preferences_foundation/darwin
tray_manager:
:path: Flutter/ephemeral/.symlinks/plugins/tray_manager/macos
url_launcher_macos:
:path: Flutter/ephemeral/.symlinks/plugins/url_launcher_macos/macos
window_manager:
:path: Flutter/ephemeral/.symlinks/plugins/window_manager/macos
SPEC CHECKSUMS:
app_links: 10e0a0ab602ffaf34d142cd4862f29d34b303b2a
desktop_drop: 69eeff437544aa619c8db7f4481b3a65f7696898
file_selector_macos: f1b08a781e66103e3ba279fd5d4024a2478b3af6
FlutterMacOS: ae6af50a8ea7d6103d888583d46bd8328a7e9811
FlutterMacOS: 8f6f14fa908a6fb3fba0cd85dbd81ec4b251fb24
macos_secure_bookmarks: cff041c4b377fa00941bb901aaec4d27fac5edf2
package_info_plus: 02d7a575e80f194102bef286361c6c326e4c29ce
path_provider_macos: 3c0c3b4b0d4a76d2bf989a913c2de869c5641a19
shared_preferences_macos: a64dc611287ed6cbe28fd1297898db1336975727
url_launcher_macos: 597e05b8e514239626bcf4a850fcf9ef5c856ec3
path_provider_foundation: 3784922295ac71e43754bd15e0653ccfd36a147c
screen_retriever: 59634572a57080243dd1bf715e55b6c54f241a38
share_plus: 76dd39142738f7a68dd57b05093b5e8193f220f7
shared_preferences_foundation: b4c3b4cddf1c21f02770737f147a3f5da9d39695
tray_manager: 9064e219c56d75c476e46b9a21182087930baf90
url_launcher_macos: 5f437abeda8c85500ceb03f5c1938a8c5a705399
window_manager: 3a1844359a6295ab1e47659b1a777e36773cd6e8
PODFILE CHECKSUM: 6eac6b3292e5142cfc23bdeb71848a40ec51c14c
PODFILE CHECKSUM: 353c8bcc5d5b0994e508d035b5431cfe18c1dea7
COCOAPODS: 1.11.3
COCOAPODS: 1.15.2
@@ -3,7 +3,7 @@
archiveVersion = 1;
classes = {
};
objectVersion = 51;
objectVersion = 54;
objects = {
/* Begin PBXAggregateTarget section */
@@ -206,7 +206,7 @@
isa = PBXProject;
attributes = {
LastSwiftUpdateCheck = 0920;
LastUpgradeCheck = 1300;
LastUpgradeCheck = 1430;
ORGANIZATIONNAME = "";
TargetAttributes = {
33CC10EC2044A3C60003C045 = {
@@ -259,6 +259,7 @@
/* Begin PBXShellScriptBuildPhase section */
3399D490228B24CF009A79C7 /* ShellScript */ = {
isa = PBXShellScriptBuildPhase;
alwaysOutOfDate = 1;
buildActionMask = 2147483647;
files = (
);
@@ -407,7 +408,7 @@
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
GCC_WARN_UNUSED_FUNCTION = YES;
GCC_WARN_UNUSED_VARIABLE = YES;
MACOSX_DEPLOYMENT_TARGET = 10.11;
MACOSX_DEPLOYMENT_TARGET = 10.14;
MTL_ENABLE_DEBUG_INFO = NO;
SDKROOT = macosx;
SWIFT_COMPILATION_MODE = wholemodule;
@@ -490,7 +491,7 @@
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
GCC_WARN_UNUSED_FUNCTION = YES;
GCC_WARN_UNUSED_VARIABLE = YES;
MACOSX_DEPLOYMENT_TARGET = 10.11;
MACOSX_DEPLOYMENT_TARGET = 10.14;
MTL_ENABLE_DEBUG_INFO = YES;
ONLY_ACTIVE_ARCH = YES;
SDKROOT = macosx;
@@ -537,7 +538,7 @@
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
GCC_WARN_UNUSED_FUNCTION = YES;
GCC_WARN_UNUSED_VARIABLE = YES;
MACOSX_DEPLOYMENT_TARGET = 10.11;
MACOSX_DEPLOYMENT_TARGET = 10.14;
MTL_ENABLE_DEBUG_INFO = NO;
SDKROOT = macosx;
SWIFT_COMPILATION_MODE = wholemodule;
@@ -1,6 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<Scheme
LastUpgradeVersion = "1300"
LastUpgradeVersion = "1430"
version = "1.3">
<BuildAction
parallelizeBuildables = "YES"
Binary file not shown.

Before

Width:  |  Height:  |  Size: 191 KiB

After

Width:  |  Height:  |  Size: 67 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 8.2 KiB

After

Width:  |  Height:  |  Size: 5.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 709 B

After

Width:  |  Height:  |  Size: 592 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 24 KiB

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.4 KiB

After

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 71 KiB

After

Width:  |  Height:  |  Size: 29 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.2 KiB

After

Width:  |  Height:  |  Size: 2.8 KiB

+64
View File
@@ -241,6 +241,22 @@ packages:
url: "https://pub.dev"
source: hosted
version: "0.4.4"
device_info_plus:
dependency: "direct main"
description:
name: device_info_plus
sha256: "77f757b789ff68e4eaf9c56d1752309bd9f7ad557cb105b938a7f8eb89e59110"
url: "https://pub.dev"
source: hosted
version: "9.1.2"
device_info_plus_platform_interface:
dependency: transitive
description:
name: device_info_plus_platform_interface
sha256: "282d3cf731045a2feb66abfe61bbc40870ae50a3ed10a4d3d217556c35c8c2ba"
url: "https://pub.dev"
source: hosted
version: "7.0.1"
dio:
dependency: "direct main"
description:
@@ -709,6 +725,54 @@ packages:
url: "https://pub.dev"
source: hosted
version: "2.3.0"
permission_handler:
dependency: "direct main"
description:
name: permission_handler
sha256: "18bf33f7fefbd812f37e72091a15575e72d5318854877e0e4035a24ac1113ecb"
url: "https://pub.dev"
source: hosted
version: "11.3.1"
permission_handler_android:
dependency: transitive
description:
name: permission_handler_android
sha256: "76e4ab092c1b240d31177bb64d2b0bea43f43d0e23541ec866151b9f7b2490fa"
url: "https://pub.dev"
source: hosted
version: "12.0.12"
permission_handler_apple:
dependency: transitive
description:
name: permission_handler_apple
sha256: e6f6d73b12438ef13e648c4ae56bd106ec60d17e90a59c4545db6781229082a0
url: "https://pub.dev"
source: hosted
version: "9.4.5"
permission_handler_html:
dependency: transitive
description:
name: permission_handler_html
sha256: "54bf176b90f6eddd4ece307e2c06cf977fb3973719c35a93b85cc7093eb6070d"
url: "https://pub.dev"
source: hosted
version: "0.1.1"
permission_handler_platform_interface:
dependency: transitive
description:
name: permission_handler_platform_interface
sha256: fe0ffe274d665be8e34f9c59705441a7d248edebbe5d9e3ec2665f88b79358ea
url: "https://pub.dev"
source: hosted
version: "4.2.2"
permission_handler_windows:
dependency: transitive
description:
name: permission_handler_windows
sha256: "1a790728016f79a41216d88672dbc5df30e686e811ad4e698bfc51f76ad91f1e"
url: "https://pub.dev"
source: hosted
version: "0.2.1"
petitparser:
dependency: transitive
description:
+3 -1
View File
@@ -15,7 +15,7 @@ publish_to: "none" # Remove this line if you wish to publish to pub.dev
# In iOS, build-name is used as CFBundleShortVersionString while build-number used as CFBundleVersion.
# Read more about iOS versioning at
# https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html
version: 1.5.8+1
version: 1.6.0+1
environment:
sdk: '>=3.0.0 <4.0.0'
@@ -67,6 +67,8 @@ dependencies:
launch_at_startup: ^0.2.2
args: ^2.5.0
toggle_switch: ^2.3.0
permission_handler: ^11.3.1
device_info_plus: ^9.1.2
dev_dependencies:
flutter_test:
sdk: flutter