diff --git a/internal/controller/controller.go b/internal/controller/controller.go index 15eb299d1..08136fa8e 100644 --- a/internal/controller/controller.go +++ b/internal/controller/controller.go @@ -7,7 +7,7 @@ import ( ) type Controller struct { - GetConfig func(v any) bool + GetConfig func(v any) ProxyConfig *base.DownloaderProxyConfig FileController //ContextDialer() (proxy.Dialer, error) @@ -22,10 +22,8 @@ type DefaultFileController struct { func NewController() *Controller { return &Controller{ + GetConfig: func(v any) {}, FileController: &DefaultFileController{}, - GetConfig: func(v any) bool { - return false - }, } } diff --git a/internal/fetcher/fetcher.go b/internal/fetcher/fetcher.go index 22afb5208..052188c43 100644 --- a/internal/fetcher/fetcher.go +++ b/internal/fetcher/fetcher.go @@ -7,11 +7,8 @@ import ( ) // Fetcher defines the interface for a download protocol. -// One fetcher for each download task +// Each download task will have a corresponding Fetcher instance for the management of the download task type Fetcher interface { - // Name return the name of the protocol. - Name() string - Setup(ctl *controller.Controller) // Resolve resource info from request Resolve(req *base.Request) error @@ -31,6 +28,12 @@ type Fetcher interface { Wait() error } +type Uploader interface { + Upload() error + UploadedBytes() int64 + WaitUpload() error +} + // FetcherMeta defines the meta information of a fetcher. type FetcherMeta struct { Req *base.Request `json:"req"` @@ -70,11 +73,15 @@ func (m *FetcherMeta) RootDirPath() string { // FetcherBuilder defines the interface for a fetcher builder. type FetcherBuilder interface { + // Name return the name of the protocol. + Name() string // Schemes returns the schemes supported by the fetcher. Schemes() []string // Build returns a new fetcher. Build() Fetcher + // DefaultConfig returns the default configuration of the protocol. + DefaultConfig() any // Store fetcher Store(fetcher Fetcher) (any, error) // Restore fetcher diff --git a/internal/protocol/bt/config.go b/internal/protocol/bt/config.go index 5aab314cc..2f88a0cc9 100644 --- a/internal/protocol/bt/config.go +++ b/internal/protocol/bt/config.go @@ -3,4 +3,10 @@ package bt type config struct { ListenPort int `json:"listenPort"` Trackers []string `json:"trackers"` + // SeedKeep is always keep seeding after downloading is complete, unless manually stopped. + SeedKeep bool `json:"seedKeep` + // SeedRatio is the ratio of uploaded data to downloaded data to seed. + SeedRatio float64 `json:"seedRatio"` + // SeedTime is the time in seconds to seed after downloading is complete. + SeedTime int64 `json:"seedTime"` } diff --git a/internal/protocol/bt/fetcher.go b/internal/protocol/bt/fetcher.go index 700d270d5..faadb4ed9 100644 --- a/internal/protocol/bt/fetcher.go +++ b/internal/protocol/bt/fetcher.go @@ -30,15 +30,12 @@ type Fetcher struct { torrent *torrent.Torrent meta *fetcher.FetcherMeta + data *fetcherData - torrentReady atomic.Bool - torrentDrop atomic.Bool - create atomic.Bool - progress fetcher.Progress -} - -func (f *Fetcher) Name() string { - return "bt" + torrentReady atomic.Bool + torrentDrop atomic.Bool + torrentUpload atomic.Bool + uploadDoneCh chan any } func (f *Fetcher) Setup(ctl *controller.Controller) { @@ -46,13 +43,10 @@ func (f *Fetcher) Setup(ctl *controller.Controller) { if f.meta == nil { f.meta = &fetcher.FetcherMeta{} } - exist := f.ctl.GetConfig(&f.config) - if !exist { - f.config = &config{ - ListenPort: 0, - Trackers: []string{}, - } + if f.data == nil { + f.data = &fetcherData{} } + f.ctl.GetConfig(&f.config) return } @@ -65,6 +59,7 @@ func (f *Fetcher) initClient() (err error) { } cfg := torrent.NewDefaultClientConfig() + cfg.Seed = true cfg.Bep20 = fmt.Sprintf("-GP%s-", parseBep20()) cfg.ExtendedHandshakeClientVersion = fmt.Sprintf("Gopeed %s", base.Version) cfg.ListenPort = f.config.ListenPort @@ -92,11 +87,11 @@ func (f *Fetcher) Resolve(req *base.Request) error { } func (f *Fetcher) Create(opts *base.Options) (err error) { - f.create.Store(true) f.meta.Opts = opts if f.meta.Res != nil { torrentDirMap[f.meta.Res.Hash] = f.meta.FolderPath() } + f.uploadDoneCh = make(chan any, 1) return nil } @@ -111,13 +106,15 @@ func (f *Fetcher) Start() (err error) { } files := f.torrent.Files() // If the user does not specify the file to download, all files will be downloaded by default - if len(f.meta.Opts.SelectFiles) == 0 { - f.meta.Opts.SelectFiles = make([]int, len(files)) - for i := range files { - f.meta.Opts.SelectFiles[i] = i + if f.data.Progress == nil { + if len(f.meta.Opts.SelectFiles) == 0 { + f.meta.Opts.SelectFiles = make([]int, len(files)) + for i := range files { + f.meta.Opts.SelectFiles[i] = i + } } + f.data.Progress = make(fetcher.Progress, len(f.meta.Opts.SelectFiles)) } - f.progress = make(fetcher.Progress, len(f.meta.Opts.SelectFiles)) if len(f.meta.Opts.SelectFiles) == len(files) { f.torrent.DownloadAll() } else { @@ -126,7 +123,6 @@ func (f *Fetcher) Start() (err error) { file.Download() } } - return } @@ -137,8 +133,9 @@ func (f *Fetcher) Pause() (err error) { } func (f *Fetcher) Close() (err error) { - f.torrentDrop.Store(false) + f.torrentDrop.Store(true) f.safeDrop() + f.uploadDoneCh <- nil return nil } @@ -151,21 +148,41 @@ func (f *Fetcher) safeDrop() { f.torrent.Drop() } +func (f *Fetcher) Meta() *fetcher.FetcherMeta { + return f.meta +} + +func (f *Fetcher) Stats() any { + stats := f.torrent.Stats() + return &bt.Stats{ + TotalPeers: stats.TotalPeers, + ActivePeers: stats.ActivePeers, + ConnectedSeeders: stats.ConnectedSeeders, + SeedBytes: f.data.SeedBytes, + SeedRatio: f.seedRadio(), + SeedTime: f.data.SeedTime, + } +} + +func (f *Fetcher) Progress() fetcher.Progress { + if !f.torrentReady.Load() { + return f.data.Progress + } + for i := range f.data.Progress { + selectIndex := f.meta.Opts.SelectFiles[i] + file := f.torrent.Files()[selectIndex] + f.data.Progress[i] = file.BytesCompleted() + } + return f.data.Progress +} + func (f *Fetcher) Wait() (err error) { for { if f.torrentDrop.Load() { break } if f.torrentReady.Load() && len(f.meta.Opts.SelectFiles) > 0 { - done := true - for _, selectIndex := range f.meta.Opts.SelectFiles { - file := f.torrent.Files()[selectIndex] - if file.BytesCompleted() < file.Length() { - done = false - break - } - } - if done { + if f.isDone() { // remove unselected files for i, file := range f.torrent.Files() { selected := false @@ -187,30 +204,14 @@ func (f *Fetcher) Wait() (err error) { return nil } -func (f *Fetcher) Meta() *fetcher.FetcherMeta { - return f.meta -} - -func (f *Fetcher) Stats() any { - stats := f.torrent.Stats() - baseStats := &bt.Stats{ - TotalPeers: stats.TotalPeers, - ActivePeers: stats.ActivePeers, - ConnectedSeeders: stats.ConnectedSeeders, - } - return baseStats -} - -func (f *Fetcher) Progress() fetcher.Progress { - if !f.torrentReady.Load() { - return f.progress - } - for i := range f.progress { - selectIndex := f.meta.Opts.SelectFiles[i] +func (f *Fetcher) isDone() bool { + for _, selectIndex := range f.meta.Opts.SelectFiles { file := f.torrent.Files()[selectIndex] - f.progress[i] = file.BytesCompleted() + if file.BytesCompleted() < file.Length() { + return false + } } - return f.progress + return true } func (f *Fetcher) updateRes() { @@ -235,6 +236,78 @@ func (f *Fetcher) updateRes() { } } +func (f *Fetcher) Upload() (err error) { + return f.addTorrent(f.meta.Req) +} + +func (f *Fetcher) doUpload() { + if f.torrentUpload.Load() { + return + } + f.torrentUpload.Store(true) + + // Check and update seed data + lastData := &fetcherData{ + SeedBytes: f.data.SeedBytes, + SeedTime: f.data.SeedTime, + } + var doneTime int64 = 0 + for { + time.Sleep(time.Second) + + if f.torrentDrop.Load() { + break + } + + if !f.torrentReady.Load() { + continue + } + + stats := f.torrent.Stats() + f.data.SeedBytes = lastData.SeedBytes + stats.BytesWrittenData.Int64() + + // Check is download complete, if not don't check and stop seeding + if !f.isDone() { + continue + } + if doneTime == 0 { + doneTime = time.Now().Unix() + } + f.data.SeedTime = lastData.SeedTime + time.Now().Unix() - doneTime + + // If the seed forever is true, keep seeding + if f.config.SeedKeep { + continue + } + + // If the seed ratio is reached, stop seeding + if f.config.SeedRatio > 0 { + seedRadio := f.seedRadio() + if seedRadio >= f.config.SeedRatio { + f.Close() + break + } + } + + // If the seed time is reached, stop seeding + if f.config.SeedTime > 0 { + if f.data.SeedTime >= f.config.SeedTime { + f.Close() + break + } + } + } +} + +func (f *Fetcher) UploadedBytes() int64 { + return f.data.SeedBytes +} + +func (f *Fetcher) WaitUpload() (err error) { + <-f.uploadDoneCh + return nil +} + func (f *Fetcher) addTorrent(req *base.Request) (err error) { if err = base.ParseReqExtra[bt.ReqExtra](req); err != nil { return @@ -285,14 +358,36 @@ func (f *Fetcher) addTorrent(req *base.Request) (err error) { } <-f.torrent.GotInfo() f.torrentReady.Store(true) + + go f.doUpload() return } +func (f *Fetcher) seedRadio() float64 { + bytesRead := f.data.Progress.TotalDownloaded() + if bytesRead <= 0 { + return 0 + } + + return float64(f.data.SeedBytes) / float64(bytesRead) +} + +type fetcherData struct { + Progress fetcher.Progress + SeedBytes int64 + // SeedTime is the time in seconds to seed after downloading is complete. + SeedTime int64 +} + type FetcherBuilder struct { } var schemes = []string{"FILE", "MAGNET", "APPLICATION/X-BITTORRENT"} +func (fb *FetcherBuilder) Name() string { + return "bt" +} + func (fb *FetcherBuilder) Schemes() []string { return schemes } @@ -301,14 +396,26 @@ func (fb *FetcherBuilder) Build() fetcher.Fetcher { return &Fetcher{} } +func (fb *FetcherBuilder) DefaultConfig() any { + return &config{ + ListenPort: 0, + Trackers: []string{}, + SeedKeep: false, + SeedRatio: 1.0, + SeedTime: 120 * 60, + } +} + func (fb *FetcherBuilder) Store(f fetcher.Fetcher) (data any, err error) { - return nil, nil + _f := f.(*Fetcher) + return _f.data, nil } func (fb *FetcherBuilder) Restore() (v any, f func(meta *fetcher.FetcherMeta, v any) fetcher.Fetcher) { - return nil, func(meta *fetcher.FetcherMeta, v any) fetcher.Fetcher { + return &fetcherData{}, func(meta *fetcher.FetcherMeta, v any) fetcher.Fetcher { return &Fetcher{ meta: meta, + data: v.(*fetcherData), } } } diff --git a/internal/protocol/bt/fetcher_test.go b/internal/protocol/bt/fetcher_test.go index 751f9f85b..4653e4a24 100644 --- a/internal/protocol/bt/fetcher_test.go +++ b/internal/protocol/bt/fetcher_test.go @@ -100,8 +100,13 @@ func doResolve(t *testing.T, fetcher fetcher.Fetcher) { } func buildFetcher() fetcher.Fetcher { - fetcher := new(FetcherBuilder).Build() - fetcher.Setup(controller.NewController()) + fb := new(FetcherBuilder) + fetcher := fb.Build() + newController := controller.NewController() + newController.GetConfig = func(v any) { + json.Unmarshal([]byte(test.ToJson(fb.DefaultConfig())), v) + } + fetcher.Setup(newController) return fetcher } @@ -113,11 +118,8 @@ func buildConfigFetcher(proxyConfig *base.DownloaderProxyConfig) fetcher.Fetcher "udp://tracker.birkenwald.de:6969/announce", "udp://tracker.bitsearch.to:1337/announce", }} - newController.GetConfig = func(v any) bool { - if err := json.Unmarshal([]byte(test.ToJson(mockCfg)), v); err != nil { - return false - } - return true + newController.GetConfig = func(v any) { + json.Unmarshal([]byte(test.ToJson(mockCfg)), v) } newController.ProxyConfig = proxyConfig fetcher.Setup(newController) diff --git a/internal/protocol/http/fetcher.go b/internal/protocol/http/fetcher.go index d03fe9848..04c9d0de7 100644 --- a/internal/protocol/http/fetcher.go +++ b/internal/protocol/http/fetcher.go @@ -64,23 +64,13 @@ type Fetcher struct { eg *errgroup.Group } -func (f *Fetcher) Name() string { - return "http" -} - func (f *Fetcher) Setup(ctl *controller.Controller) { f.ctl = ctl f.doneCh = make(chan error, 1) if f.meta == nil { f.meta = &fetcher.FetcherMeta{} } - exist := f.ctl.GetConfig(&f.config) - if !exist { - f.config = &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", - Connections: 1, - } - } + f.ctl.GetConfig(&f.config) return } @@ -471,6 +461,10 @@ type FetcherBuilder struct { var schemes = []string{"HTTP", "HTTPS"} +func (fb *FetcherBuilder) Name() string { + return "http" +} + func (fb *FetcherBuilder) Schemes() []string { return schemes } @@ -479,6 +473,13 @@ func (fb *FetcherBuilder) Build() fetcher.Fetcher { return &Fetcher{} } +func (fb *FetcherBuilder) 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", + Connections: 16, + } +} + func (fb *FetcherBuilder) Store(f fetcher.Fetcher) (data any, err error) { _f := f.(*Fetcher) return &fetcherData{ diff --git a/internal/protocol/http/fetcher_test.go b/internal/protocol/http/fetcher_test.go index 6c76a0de0..3cfc2e8c9 100644 --- a/internal/protocol/http/fetcher_test.go +++ b/internal/protocol/http/fetcher_test.go @@ -359,19 +359,21 @@ func downloadWithProxy(httpListener net.Listener, proxyListener net.Listener, t } func buildFetcher() *Fetcher { - fetcher := new(FetcherBuilder).Build() - fetcher.Setup(controller.NewController()) + fb := new(FetcherBuilder) + fetcher := fb.Build() + newController := controller.NewController() + newController.GetConfig = func(v any) { + json.Unmarshal([]byte(test.ToJson(fb.DefaultConfig())), v) + } + fetcher.Setup(newController) return fetcher.(*Fetcher) } func buildConfigFetcher(cfg config) fetcher.Fetcher { fetcher := new(FetcherBuilder).Build() newController := controller.NewController() - newController.GetConfig = func(v any) bool { - if err := json.Unmarshal([]byte(test.ToJson(cfg)), v); err != nil { - return false - } - return true + newController.GetConfig = func(v any) { + json.Unmarshal([]byte(test.ToJson(cfg)), v) } fetcher.Setup(newController) return fetcher diff --git a/pkg/base/model.go b/pkg/base/model.go index 991d6c0ec..984639e78 100644 --- a/pkg/base/model.go +++ b/pkg/base/model.go @@ -150,6 +150,9 @@ func (cfg *DownloaderStoreConfig) Init() *DownloaderStoreConfig { if cfg.Proxy == nil { cfg.Proxy = &DownloaderProxyConfig{} } + if cfg.ProtocolConfig == nil { + cfg.ProtocolConfig = make(map[string]any) + } return cfg } diff --git a/pkg/download/downloader.go b/pkg/download/downloader.go index a1225c4e1..9f3021f06 100644 --- a/pkg/download/downloader.go +++ b/pkg/download/downloader.go @@ -42,12 +42,16 @@ var ( type Listener func(event *Event) type Progress struct { - // 下载耗时(纳秒) + // Total download time(ns) Used int64 `json:"used"` - // 每秒下载字节数 + // Download speed(bytes/s) Speed int64 `json:"speed"` - // 已下载的字节数 + // Downloaded size(bytes) Downloaded int64 `json:"downloaded"` + // Uploaded speed(bytes/s) + UploadSpeed int64 `json:"uploadSpeed"` + // Uploaded size(bytes) + Uploaded int64 `json:"uploaded"` } type Downloader struct { @@ -124,11 +128,14 @@ func (d *Downloader) Setup() error { } // init default config d.cfg.DownloaderStoreConfig.Init() + // init protocol config, if not exist, use default config for _, fb := range d.fetcherBuilders { - f := fb.Build() - d.setupFetcher(f) - + protocol := fb.Name() + if _, ok := d.cfg.DownloaderStoreConfig.ProtocolConfig[protocol]; !ok { + d.cfg.DownloaderStoreConfig.ProtocolConfig[protocol] = fb.DefaultConfig() + } } + // load tasks from storage var tasks []*Task if err = d.storage.List(bucketTask, &tasks); err != nil { @@ -166,6 +173,22 @@ func (d *Downloader) Setup() error { } d.extensions = extensions + // handle upload + go func() { + for _, task := range d.tasks { + if task.Status == base.DownloadStatusDone && task.Uploading { + if err := d.restoreFetcher(task); err != nil { + d.Logger.Error().Stack().Err(err).Msgf("task upload restore fetcher failed, task id: %s", task.ID) + } + if uploader, ok := task.fetcher.(fetcher.Uploader); ok { + if err := uploader.Upload(); err != nil { + d.Logger.Error().Stack().Err(err).Msgf("task upload failed, task id: %s", task.ID) + } + } + } + } + }() + // calculate download speed every tick go func() { for !d.closed.Load() { @@ -174,18 +197,27 @@ func (d *Downloader) Setup() error { func() { task.statusLock.Lock() defer task.statusLock.Unlock() - if task.Status != base.DownloadStatusRunning { + if task.Status != base.DownloadStatusRunning && !task.Uploading { return } // check if task is deleted - if d.GetTask(task.ID) == nil { + if d.GetTask(task.ID) == nil || task.fetcher == nil { return } current := task.fetcher.Progress().TotalDownloaded() - task.Progress.Used = task.timer.Used() - task.Progress.Speed = task.calcSpeed(current-task.Progress.Downloaded, float64(d.cfg.RefreshInterval)/1000) - task.Progress.Downloaded = current + tick := float64(d.cfg.RefreshInterval) / 1000 + if task.Status == base.DownloadStatusRunning { + task.Progress.Used = task.timer.Used() + task.Progress.Speed = task.calcSpeed(current-task.Progress.Downloaded, tick) + task.Progress.Downloaded = current + } + if task.Uploading { + uploader := task.fetcher.(fetcher.Uploader) + currentUploaded := uploader.UploadedBytes() + task.Progress.UploadSpeed = task.calcSpeed(currentUploaded-task.Progress.Uploaded, tick) + task.Progress.Uploaded = currentUploaded + } d.emit(EventKeyProgress, task) // store fetcher progress @@ -220,10 +252,10 @@ func (d *Downloader) parseFb(url string) (fetcher.FetcherBuilder, error) { return nil, ErrUnSupportedProtocol } -func (d *Downloader) setupFetcher(fetcher fetcher.Fetcher) { +func (d *Downloader) setupFetcher(fb fetcher.FetcherBuilder, fetcher fetcher.Fetcher) { ctl := controller.NewController() - ctl.GetConfig = func(v any) bool { - return d.getProtocolConfig(fetcher.Name(), v) + ctl.GetConfig = func(v any) { + d.getProtocolConfig(fb.Name(), v) } ctl.ProxyConfig = d.cfg.Proxy fetcher.Setup(ctl) @@ -641,6 +673,30 @@ func (d *Downloader) getProtocolConfig(name string, v any) bool { // wait task done func (d *Downloader) watch(task *Task) { + // wait task upload done + if task.Uploading { + if uploader, ok := task.fetcher.(fetcher.Uploader); ok { + go func() { + err := uploader.WaitUpload() + if err != nil { + d.Logger.Warn().Err(err).Msgf("task wait upload failed, task id: %s", task.ID) + } + d.lock.Lock() + defer d.lock.Unlock() + + // Check if the task is deleted + if d.GetTask(task.ID) != nil { + task.Uploading = false + d.storage.Put(bucketTask, task.ID, task.clone()) + } + }() + } + } + + if task.Status == base.DownloadStatusDone { + return + } + err := task.fetcher.Wait() if err != nil { d.doOnError(task, err) @@ -719,7 +775,7 @@ func (d *Downloader) restoreFetcher(task *Task) error { if task.fetcher == nil { task.fetcher = fb.Build() } - d.setupFetcher(task.fetcher) + d.setupFetcher(fb, task.fetcher) if task.fetcher.Meta().Req == nil { task.fetcher.Meta().Req = task.Meta.Req } @@ -734,7 +790,7 @@ func (d *Downloader) restoreFetcher(task *Task) error { return nil } -func (d *Downloader) doCreate(fetcher fetcher.Fetcher, opts *base.Options) (taskId string, err error) { +func (d *Downloader) doCreate(f fetcher.Fetcher, opts *base.Options) (taskId string, err error) { if opts == nil { opts = &base.Options{} } @@ -742,7 +798,7 @@ func (d *Downloader) doCreate(fetcher fetcher.Fetcher, opts *base.Options) (task opts.SelectFiles = make([]int, 0) } - meta := fetcher.Meta() + meta := f.Meta() meta.Opts = opts if opts.Path == "" { storeConfig, err := d.GetConfig() @@ -752,19 +808,20 @@ func (d *Downloader) doCreate(fetcher fetcher.Fetcher, opts *base.Options) (task opts.Path = storeConfig.DownloadDir } - fb, err := d.parseFb(fetcher.Meta().Req.URL) + fb, err := d.parseFb(f.Meta().Req.URL) if err != nil { return } task := NewTask() task.fetcherBuilder = fb - task.fetcher = fetcher - task.Protocol = fetcher.Name() - task.Meta = fetcher.Meta() + task.fetcher = f + task.Protocol = fb.Name() + task.Meta = f.Meta() task.Progress = &Progress{} + _, task.Uploading = f.(fetcher.Uploader) initTask(task) - if err = fetcher.Create(opts); err != nil { + if err = f.Create(opts); err != nil { return } if err = d.storage.Put(bucketTask, task.ID, task.clone()); err != nil { @@ -867,10 +924,10 @@ func (d *Downloader) doStart(task *Task) (err error) { task.Progress.Speed = 0 task.timer.Start() - if err := d.storage.Put(bucketTask, task.ID, task.clone()); err != nil { + if err := task.fetcher.Start(); err != nil { return err } - if err := task.fetcher.Start(); err != nil { + if err := d.storage.Put(bucketTask, task.ID, task.clone()); err != nil { return err } d.emit(EventKeyStart, task) @@ -945,7 +1002,7 @@ func (d *Downloader) buildFetcher(url string) (fetcher.Fetcher, error) { return nil, err } fetcher := fb.Build() - d.setupFetcher(fetcher) + d.setupFetcher(fb, fetcher) return fetcher, nil } diff --git a/pkg/download/downloader_test.go b/pkg/download/downloader_test.go index 8c4ca2ec0..ad59d1966 100644 --- a/pkg/download/downloader_test.go +++ b/pkg/download/downloader_test.go @@ -342,8 +342,8 @@ func TestDownloader_Protocol_Config(t *testing.T) { var httpCfg map[string]any exits := downloader.getProtocolConfig("http", &httpCfg) - if exits { - t.Errorf("getProtocolConfig() got = %v, want %v", exits, false) + if !exits { + t.Errorf("getProtocolConfig() got = %v, want %v", exits, true) } storeCfg := &base.DownloaderStoreConfig{ diff --git a/pkg/download/extension_test.go b/pkg/download/extension_test.go index a7c4a504b..59707722f 100644 --- a/pkg/download/extension_test.go +++ b/pkg/download/extension_test.go @@ -238,7 +238,7 @@ func TestDownloader_Extension_OnError(t *testing.T) { select { case err = <-errCh: break - case <-time.After(time.Second * 30): + case <-time.After(time.Second * 10): err = errors.New("timeout") } diff --git a/pkg/download/model.go b/pkg/download/model.go index 4fbc5b9d0..2bda69e0d 100644 --- a/pkg/download/model.go +++ b/pkg/download/model.go @@ -19,9 +19,10 @@ type ResolveResult struct { type Task struct { ID string `json:"id"` + Protocol string `json:"protocol"` Meta *fetcher.FetcherMeta `json:"meta"` Status base.Status `json:"status"` - Protocol string `json:"protocol"` + Uploading bool `json:"uploading"` Progress *Progress `json:"progress"` CreatedAt time.Time `json:"createdAt"` UpdatedAt time.Time `json:"updatedAt"` @@ -55,8 +56,10 @@ func (t *Task) updateStatus(status base.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, diff --git a/pkg/download/testdata/extensions/on_error/index.js b/pkg/download/testdata/extensions/on_error/index.js index 23853d847..59e0abac3 100644 --- a/pkg/download/testdata/extensions/on_error/index.js +++ b/pkg/download/testdata/extensions/on_error/index.js @@ -2,7 +2,6 @@ gopeed.events.onError(async function (ctx) { gopeed.logger.info("url", ctx.task.meta.req.url); gopeed.logger.info("error", ctx.error); ctx.task.meta.req.url = "https://github.com"; - ctx.task.pause(); ctx.task.continue(); }); diff --git a/pkg/protocol/bt/model.go b/pkg/protocol/bt/model.go index b9ec54498..b106b6132 100644 --- a/pkg/protocol/bt/model.go +++ b/pkg/protocol/bt/model.go @@ -4,11 +4,16 @@ type ReqExtra struct { Trackers []string `json:"trackers"` } -// Stats for download +// Stats for torrent type Stats struct { - // bt stats // health indicators of torrents, from large to small, ConnectedSeeders are also the key to the health of seed resources TotalPeers int `json:"totalPeers"` ActivePeers int `json:"activePeers"` ConnectedSeeders int `json:"connectedSeeders"` + // Total seed bytes + SeedBytes int64 `json:"seedBytes"` + // Seed ratio + SeedRatio float64 `json:"seedRatio"` + // Total seed time + SeedTime int64 `json:"seedTime"` } diff --git a/ui/flutter/lib/api/model/downloader_config.dart b/ui/flutter/lib/api/model/downloader_config.dart index 2e3a0efcd..9eea24996 100644 --- a/ui/flutter/lib/api/model/downloader_config.dart +++ b/ui/flutter/lib/api/model/downloader_config.dart @@ -41,9 +41,8 @@ class HttpConfig { bool useServerCtime; HttpConfig({ - this.userAgent = - 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/116.0.0.0 Safari/537.36', - this.connections = 16, + this.userAgent = '', + this.connections = 0, this.useServerCtime = false, }); @@ -55,10 +54,19 @@ class HttpConfig { @JsonSerializable() class BtConfig { - int listenPort = 0; - List trackers = []; - - BtConfig(); + int listenPort; + List trackers; + bool seedKeep; + double seedRatio; + int seedTime; + + BtConfig({ + this.listenPort = 0, + this.trackers = const [], + this.seedKeep = false, + this.seedRatio = 0, + this.seedTime = 0, + }); factory BtConfig.fromJson(Map json) => _$BtConfigFromJson(json); diff --git a/ui/flutter/lib/api/model/downloader_config.g.dart b/ui/flutter/lib/api/model/downloader_config.g.dart index b580dfad7..c30a59ef6 100644 --- a/ui/flutter/lib/api/model/downloader_config.g.dart +++ b/ui/flutter/lib/api/model/downloader_config.g.dart @@ -37,9 +37,8 @@ Map _$ProtocolConfigToJson(ProtocolConfig instance) => }; HttpConfig _$HttpConfigFromJson(Map json) => HttpConfig( - userAgent: json['userAgent'] as String? ?? - 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/116.0.0.0 Safari/537.36', - connections: json['connections'] as int? ?? 16, + userAgent: json['userAgent'] as String? ?? '', + connections: json['connections'] as int? ?? 0, useServerCtime: json['useServerCtime'] as bool? ?? false, ); @@ -50,14 +49,23 @@ Map _$HttpConfigToJson(HttpConfig instance) => 'useServerCtime': instance.useServerCtime, }; -BtConfig _$BtConfigFromJson(Map json) => BtConfig() - ..listenPort = json['listenPort'] as int - ..trackers = - (json['trackers'] as List).map((e) => e as String).toList(); +BtConfig _$BtConfigFromJson(Map json) => BtConfig( + listenPort: json['listenPort'] as int? ?? 0, + trackers: (json['trackers'] as List?) + ?.map((e) => e as String) + .toList() ?? + const [], + seedKeep: json['seedKeep'] as bool? ?? false, + seedRatio: (json['seedRatio'] as num?)?.toDouble() ?? 0, + seedTime: json['seedTime'] as int? ?? 0, + ); Map _$BtConfigToJson(BtConfig instance) => { 'listenPort': instance.listenPort, 'trackers': instance.trackers, + 'seedKeep': instance.seedKeep, + 'seedRatio': instance.seedRatio, + 'seedTime': instance.seedTime, }; ExtraConfig _$ExtraConfigFromJson(Map json) => ExtraConfig( diff --git a/ui/flutter/lib/api/model/task.dart b/ui/flutter/lib/api/model/task.dart index 779539917..c8d34ceab 100644 --- a/ui/flutter/lib/api/model/task.dart +++ b/ui/flutter/lib/api/model/task.dart @@ -6,11 +6,15 @@ part 'task.g.dart'; enum Status { ready, running, pause, wait, error, done } +enum Protocol { http, bt } + @JsonSerializable(explicitToJson: true) class Task { String id; + Protocol? protocol; Meta meta; Status status; + bool uploading; Progress progress; DateTime createdAt; DateTime updatedAt; @@ -19,6 +23,7 @@ class Task { required this.id, required this.meta, required this.status, + required this.uploading, required this.progress, required this.createdAt, required this.updatedAt, @@ -34,11 +39,15 @@ class Progress { int used; int speed; int downloaded; + int uploadSpeed; + int uploaded; Progress({ required this.used, required this.speed, required this.downloaded, + required this.uploadSpeed, + required this.uploaded, }); factory Progress.fromJson(Map json) => diff --git a/ui/flutter/lib/api/model/task.g.dart b/ui/flutter/lib/api/model/task.g.dart index b5974a168..47c6efa77 100644 --- a/ui/flutter/lib/api/model/task.g.dart +++ b/ui/flutter/lib/api/model/task.g.dart @@ -10,19 +10,32 @@ Task _$TaskFromJson(Map json) => Task( id: json['id'] as String, meta: Meta.fromJson(json['meta'] as Map), status: $enumDecode(_$StatusEnumMap, json['status']), + uploading: json['uploading'] as bool, progress: Progress.fromJson(json['progress'] as Map), createdAt: DateTime.parse(json['createdAt'] as String), updatedAt: DateTime.parse(json['updatedAt'] as String), - ); + )..protocol = $enumDecodeNullable(_$ProtocolEnumMap, json['protocol']); -Map _$TaskToJson(Task instance) => { - 'id': instance.id, - 'meta': instance.meta.toJson(), - 'status': _$StatusEnumMap[instance.status]!, - 'progress': instance.progress.toJson(), - 'createdAt': instance.createdAt.toIso8601String(), - 'updatedAt': instance.updatedAt.toIso8601String(), - }; +Map _$TaskToJson(Task instance) { + final val = { + 'id': instance.id, + }; + + void writeNotNull(String key, dynamic value) { + if (value != null) { + val[key] = value; + } + } + + writeNotNull('protocol', _$ProtocolEnumMap[instance.protocol]); + val['meta'] = instance.meta.toJson(); + val['status'] = _$StatusEnumMap[instance.status]!; + val['uploading'] = instance.uploading; + val['progress'] = instance.progress.toJson(); + val['createdAt'] = instance.createdAt.toIso8601String(); + val['updatedAt'] = instance.updatedAt.toIso8601String(); + return val; +} const _$StatusEnumMap = { Status.ready: 'ready', @@ -33,14 +46,23 @@ const _$StatusEnumMap = { Status.done: 'done', }; +const _$ProtocolEnumMap = { + Protocol.http: 'http', + Protocol.bt: 'bt', +}; + Progress _$ProgressFromJson(Map json) => Progress( used: json['used'] as int, speed: json['speed'] as int, downloaded: json['downloaded'] as int, + uploadSpeed: json['uploadSpeed'] as int, + uploaded: json['uploaded'] as int, ); Map _$ProgressToJson(Progress instance) => { 'used': instance.used, 'speed': instance.speed, 'downloaded': instance.downloaded, + 'uploadSpeed': instance.uploadSpeed, + 'uploaded': instance.uploaded, }; diff --git a/ui/flutter/lib/app/modules/setting/views/setting_view.dart b/ui/flutter/lib/app/modules/setting/views/setting_view.dart index 70c69fdc2..834fd5878 100644 --- a/ui/flutter/lib/app/modules/setting/views/setting_view.dart +++ b/ui/flutter/lib/app/modules/setting/views/setting_view.dart @@ -334,6 +334,69 @@ class SettingView extends GetView { }, ); }); + final buildBtSeedConfig = _buildConfigItem('seedConfig', + () => 'seedKeep'.tr + (btConfig.seedKeep ? 'on'.tr : 'off'.tr), + (Key key) { + final seedRatioController = + TextEditingController(text: btConfig.seedRatio.toString()); + seedRatioController.addListener(() { + if (seedRatioController.text.isNotEmpty) { + btConfig.seedRatio = double.parse(seedRatioController.text); + debounceSave(); + } + }); + final seedTimeController = + TextEditingController(text: (btConfig.seedTime ~/ 60).toString()); + seedTimeController.addListener(() { + if (seedTimeController.text.isNotEmpty) { + btConfig.seedTime = int.parse(seedTimeController.text) * 60; + debounceSave(); + } + }); + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + SwitchListTile( + controlAffinity: ListTileControlAffinity.leading, + contentPadding: EdgeInsets.zero, + value: btConfig.seedKeep, + onChanged: (bool value) { + downloaderCfg.update((val) { + val!.protocolConfig.bt.seedKeep = value; + }); + debounceSave(); + }, + title: Text('seedKeep'.tr)), + btConfig.seedKeep + ? null + : TextField( + controller: seedRatioController, + decoration: InputDecoration( + labelText: 'seedRatio'.tr, + ), + keyboardType: + const TextInputType.numberWithOptions(decimal: true), + inputFormatters: [ + FilteringTextInputFormatter.allow( + RegExp(r'^\d+\.?\d{0,2}')), + ], + ), + btConfig.seedKeep + ? null + : TextField( + controller: seedTimeController, + decoration: InputDecoration( + labelText: 'seedTime'.tr, + ), + keyboardType: TextInputType.number, + inputFormatters: [ + FilteringTextInputFormatter.digitsOnly, + NumericalRangeFormatter(min: 0, max: 100000000), + ], + ), + ].where((e) => e != null).map((e) => e!).toList(), + ); + }); // ui config items start final buildTheme = _buildConfigItem( @@ -833,6 +896,7 @@ class SettingView extends GetView { buildBtListenPort(), buildBtTrackerSubscribeUrls(), buildBtTrackers(), + buildBtSeedConfig(), ]), )), Text('ui'.tr), diff --git a/ui/flutter/lib/i18n/langs/en_us.dart b/ui/flutter/lib/i18n/langs/en_us.dart index 95818a1d1..1a90696a9 100644 --- a/ui/flutter/lib/i18n/langs/en_us.dart +++ b/ui/flutter/lib/i18n/langs/en_us.dart @@ -95,5 +95,9 @@ const enUS = { 'Thanks to all the contributors who have helped build and develop the Gopeed community!', 'browserExtension': 'Browser Extension', 'launchAtStartup': 'Launch at Startup', + 'seedConfig': 'Seed Config', + 'seedKeep': 'Keep seeding', + 'seedRatio': 'Seed ratio', + 'seedTime': 'Seed time (minutes)', }, }; diff --git a/ui/flutter/lib/i18n/langs/zh_cn.dart b/ui/flutter/lib/i18n/langs/zh_cn.dart index 8522415ce..d52aae756 100644 --- a/ui/flutter/lib/i18n/langs/zh_cn.dart +++ b/ui/flutter/lib/i18n/langs/zh_cn.dart @@ -92,5 +92,9 @@ const zhCN = { 'thanksDesc': '感谢所有为 Gopeed 社区建设添砖加瓦的贡献者们!', 'browserExtension': '浏览器扩展', 'launchAtStartup': '开机自动运行', + 'seedConfig': '做种设置', + 'seedKeep': '持续做种', + 'seedRatio': '做种分享率', + 'seedTime': '做种时间(分钟)', } }; diff --git a/ui/flutter/lib/i18n/langs/zh_tw.dart b/ui/flutter/lib/i18n/langs/zh_tw.dart index c5c2f9bd6..30415ed1f 100644 --- a/ui/flutter/lib/i18n/langs/zh_tw.dart +++ b/ui/flutter/lib/i18n/langs/zh_tw.dart @@ -19,8 +19,7 @@ const zhTW = { 'advancedOptions': '進階選項', 'downloadLink': '下載連結', 'downloadLinkValid': '請輸入下載連結', - 'downloadLinkHit': - '請輸入下載連結,支援 HTTP/HTTPS/MAGNET@append', + 'downloadLinkHit': '請輸入下載連結,支援 HTTP/HTTPS/MAGNET@append', 'downloadLinkHitDesktop': ',或直接拖曳種子檔到此處', 'download': '下載', 'noFileSelected': '請至少選擇一個檔案以繼續。', @@ -93,5 +92,9 @@ const zhTW = { 'thanksDesc': '感謝所有幫助建立和發展 Gopeed 社群的貢獻者!', 'browserExtension': '瀏覽器擴充功能', 'launchAtStartup': '開機自動執行', + 'seedConfig': '做種設定', + 'seedKeep': '持續做種', + 'seedRatio': '做種分享率', + 'seedTime': '做種時間(分鐘)', } };