mirror of
https://github.com/GopeedLab/gopeed.git
synced 2026-11-04 23:57:54 +00:00
618 lines
13 KiB
Go
618 lines
13 KiB
Go
package bt
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"fmt"
|
|
"io"
|
|
"net/url"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"sync"
|
|
"sync/atomic"
|
|
"time"
|
|
|
|
"github.com/GopeedLab/gopeed/internal/controller"
|
|
"github.com/GopeedLab/gopeed/internal/fetcher"
|
|
"github.com/GopeedLab/gopeed/pkg/base"
|
|
"github.com/GopeedLab/gopeed/pkg/protocol/bt"
|
|
"github.com/GopeedLab/gopeed/pkg/util"
|
|
"github.com/anacrolix/torrent"
|
|
"github.com/anacrolix/torrent/metainfo"
|
|
"github.com/anacrolix/torrent/storage"
|
|
)
|
|
|
|
var (
|
|
cfg *torrent.ClientConfig
|
|
client *torrent.Client
|
|
lock sync.Mutex
|
|
closeCtx context.Context
|
|
closeFunc func()
|
|
)
|
|
|
|
type Fetcher struct {
|
|
ctl *controller.Controller
|
|
config *config
|
|
|
|
torrent *torrent.Torrent
|
|
meta *fetcher.FetcherMeta
|
|
data *fetcherData
|
|
|
|
torrentReady atomic.Bool
|
|
torrentUpload atomic.Bool
|
|
torrentDropCtx context.Context
|
|
torrentDropFunc func()
|
|
uploadDoneCh chan any
|
|
}
|
|
|
|
func (f *Fetcher) Setup(ctl *controller.Controller) {
|
|
f.ctl = ctl
|
|
if f.meta == nil {
|
|
f.meta = &fetcher.FetcherMeta{}
|
|
}
|
|
if f.data == nil {
|
|
f.data = &fetcherData{}
|
|
}
|
|
f.uploadDoneCh = make(chan any, 1)
|
|
f.torrentDropCtx, f.torrentDropFunc = context.WithCancel(context.Background())
|
|
f.ctl.GetConfig(&f.config)
|
|
return
|
|
}
|
|
|
|
func (f *Fetcher) initClient() (err error) {
|
|
lock.Lock()
|
|
defer lock.Unlock()
|
|
|
|
if client != nil {
|
|
return
|
|
}
|
|
if closeCtx == nil {
|
|
closeCtx, closeFunc = context.WithCancel(context.Background())
|
|
}
|
|
|
|
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
|
|
cfg.HTTPProxy = f.ctl.GetProxy(f.meta.Req.Proxy)
|
|
dnsResolver := &DnsCacheResolver{RefreshTimeout: 5 * time.Minute}
|
|
cfg.TrackerDialContext = dnsResolver.DialContext
|
|
client, err = torrent.NewClient(cfg)
|
|
if err != nil {
|
|
return
|
|
}
|
|
|
|
closeCtx, closeFunc = context.WithCancel(context.Background())
|
|
go func() {
|
|
dnsResolver.Run(closeCtx)
|
|
}()
|
|
return
|
|
}
|
|
|
|
func (f *Fetcher) Resolve(req *base.Request, opts *base.Options) error {
|
|
f.meta.Req = req
|
|
f.meta.Opts = opts
|
|
if f.meta.Opts == nil {
|
|
f.meta.Opts = &base.Options{}
|
|
}
|
|
if err := f.addTorrent(req, false); err != nil {
|
|
return err
|
|
}
|
|
f.updateRes()
|
|
return nil
|
|
}
|
|
|
|
func (f *Fetcher) Start() (err error) {
|
|
if !f.torrentReady.Load() {
|
|
if err = f.addTorrent(f.meta.Req, false); err != nil {
|
|
return
|
|
}
|
|
}
|
|
|
|
files := f.torrent.Files()
|
|
// If the user does not specify the file to download, all files will be downloaded by default
|
|
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))
|
|
}
|
|
if len(f.meta.Opts.SelectFiles) == len(files) {
|
|
f.torrent.DownloadAll()
|
|
} else {
|
|
for _, selectIndex := range f.meta.Opts.SelectFiles {
|
|
file := files[selectIndex]
|
|
file.Download()
|
|
}
|
|
}
|
|
f.torrent.AllowDataDownload()
|
|
return
|
|
}
|
|
|
|
func (f *Fetcher) Pause() (err error) {
|
|
f.torrent.DisallowDataDownload()
|
|
return
|
|
}
|
|
|
|
func (f *Fetcher) Close() (err error) {
|
|
f.safeDrop()
|
|
f.torrentDropFunc()
|
|
f.uploadDoneCh <- nil
|
|
if len(client.Torrents()) == 0 {
|
|
err = closeClient()
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (f *Fetcher) safeDrop() {
|
|
defer func() {
|
|
// ignore panic
|
|
_ = recover()
|
|
}()
|
|
|
|
f.torrent.Drop()
|
|
}
|
|
|
|
func (f *Fetcher) Meta() *fetcher.FetcherMeta {
|
|
return f.meta
|
|
}
|
|
|
|
func (f *Fetcher) Stats() any {
|
|
var stats torrent.TorrentStats
|
|
if f.torrent != nil {
|
|
stats = f.torrent.Stats()
|
|
} else {
|
|
stats = torrent.TorrentStats{}
|
|
}
|
|
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 {
|
|
select {
|
|
case <-f.torrentDropCtx.Done():
|
|
return
|
|
case <-time.After(time.Second):
|
|
if f.torrentReady.Load() && len(f.meta.Opts.SelectFiles) > 0 {
|
|
if f.isDone() {
|
|
// remove unselected files
|
|
for i, file := range f.torrent.Files() {
|
|
selected := false
|
|
for _, selectIndex := range f.meta.Opts.SelectFiles {
|
|
if i == selectIndex {
|
|
selected = true
|
|
break
|
|
}
|
|
}
|
|
if !selected {
|
|
util.SafeRemove(filepath.Join(f.meta.Opts.Path, f.meta.Res.Name, file.Path()))
|
|
}
|
|
}
|
|
return
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
func (f *Fetcher) isDone() bool {
|
|
if f.meta.Opts == nil {
|
|
return false
|
|
}
|
|
for _, selectIndex := range f.meta.Opts.SelectFiles {
|
|
file := f.torrent.Files()[selectIndex]
|
|
if file.BytesCompleted() < file.Length() {
|
|
return false
|
|
}
|
|
}
|
|
return true
|
|
}
|
|
|
|
// Patch modifies the BT task settings.
|
|
// Invalid file indices are silently ignored.
|
|
func (f *Fetcher) Patch(req *base.Request, opts *base.Options) error {
|
|
if opts == nil {
|
|
return nil
|
|
}
|
|
|
|
if opts.SelectFiles != nil {
|
|
selectFiles := opts.SelectFiles
|
|
|
|
// Get file count from resource metadata
|
|
fileCount := 0
|
|
if f.meta.Res != nil {
|
|
fileCount = len(f.meta.Res.Files)
|
|
}
|
|
|
|
// Filter out invalid indices (silently ignore)
|
|
validSelectFiles := make([]int, 0, len(selectFiles))
|
|
for _, idx := range selectFiles {
|
|
if idx >= 0 && idx < fileCount {
|
|
validSelectFiles = append(validSelectFiles, idx)
|
|
}
|
|
}
|
|
|
|
if f.torrent != nil {
|
|
files := f.torrent.Files()
|
|
|
|
// Cancel all current file downloads first
|
|
f.torrent.CancelPieces(0, f.torrent.NumPieces())
|
|
|
|
// Apply new file selection
|
|
if len(validSelectFiles) == len(files) {
|
|
f.torrent.DownloadAll()
|
|
} else {
|
|
for _, selectIndex := range validSelectFiles {
|
|
file := files[selectIndex]
|
|
file.Download()
|
|
}
|
|
}
|
|
}
|
|
|
|
f.meta.Opts.SelectFiles = validSelectFiles
|
|
// Recalculate the resource size based on new selection
|
|
if f.meta.Res != nil {
|
|
f.meta.Res.CalcSize(validSelectFiles)
|
|
}
|
|
// Reset progress tracking for new file selection
|
|
f.data.Progress = make(fetcher.Progress, len(validSelectFiles))
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
func (f *Fetcher) updateRes() {
|
|
res := &base.Resource{
|
|
Range: true,
|
|
Files: make([]*base.FileInfo, len(f.torrent.Files())),
|
|
Hash: f.torrent.InfoHash().String(),
|
|
}
|
|
// 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.DisplayPath()),
|
|
Size: file.Length(),
|
|
}
|
|
}
|
|
res.CalcSize(nil)
|
|
f.meta.Res = res
|
|
if f.meta.Opts != nil {
|
|
f.meta.Opts.InitSelectFiles(len(res.Files))
|
|
}
|
|
}
|
|
|
|
func (f *Fetcher) Upload() (err error) {
|
|
return f.addTorrent(f.meta.Req, true)
|
|
}
|
|
|
|
func (f *Fetcher) doUpload(fromUpload bool) {
|
|
if !f.torrentUpload.CompareAndSwap(false, true) {
|
|
return
|
|
}
|
|
|
|
// Check and update seed data
|
|
lastData := &fetcherData{
|
|
SeedBytes: f.data.SeedBytes,
|
|
SeedTime: f.data.SeedTime,
|
|
}
|
|
var doneTime int64 = 0
|
|
for {
|
|
select {
|
|
case <-f.torrentDropCtx.Done():
|
|
return
|
|
case <-time.After(time.Second):
|
|
if !f.torrentReady.Load() {
|
|
continue
|
|
}
|
|
|
|
stats := f.torrentStats()
|
|
f.data.SeedBytes = lastData.SeedBytes + stats.BytesWrittenData.Int64()
|
|
|
|
// Check is download complete, if not don't check and stop seeding
|
|
if !fromUpload && !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
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// 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
|
|
}
|
|
|
|
func (f *Fetcher) WaitUpload() (err error) {
|
|
<-f.uploadDoneCh
|
|
return nil
|
|
}
|
|
|
|
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
|
|
}
|
|
schema := util.ParseSchema(req.URL)
|
|
privateTorrent := false
|
|
var spec *torrent.TorrentSpec
|
|
if schema == "MAGNET" {
|
|
spec, err = torrent.TorrentSpecFromMagnetUri(req.URL)
|
|
if err != nil {
|
|
return
|
|
}
|
|
} else {
|
|
var reader io.Reader
|
|
if schema == "FILE" {
|
|
fileUrl, _ := url.Parse(req.URL)
|
|
filePath := fileUrl.Path[1:]
|
|
reader, err = os.Open(filePath)
|
|
if err != nil {
|
|
return
|
|
}
|
|
} else if schema == "DATA" {
|
|
_, data := util.ParseDataUri(req.URL)
|
|
reader = bytes.NewBuffer(data)
|
|
} else {
|
|
reader, err = os.Open(req.URL)
|
|
if err != nil {
|
|
return
|
|
}
|
|
defer reader.(io.Closer).Close()
|
|
}
|
|
|
|
var metaInfo *metainfo.MetaInfo
|
|
metaInfo, err = metainfo.Load(reader)
|
|
// Hotfix for https://github.com/anacrolix/torrent/issues/992, ignore "expected EOF" error
|
|
// TODO remove this after the issue is fixed
|
|
if err != nil && !strings.Contains(err.Error(), "expected EOF") {
|
|
return err
|
|
}
|
|
|
|
info, er := metaInfo.UnmarshalInfo()
|
|
if er != nil {
|
|
return er
|
|
}
|
|
|
|
if info.Private != nil && *info.Private {
|
|
privateTorrent = true
|
|
}
|
|
spec, err = torrent.TorrentSpecFromMetaInfoErr(metaInfo)
|
|
if err != nil {
|
|
return
|
|
}
|
|
}
|
|
spec.Storage = storage.NewFileOpts(storage.NewFileClientOpts{
|
|
ClientBaseDir: cfg.DataDir,
|
|
TorrentDirMaker: func(baseDir string, info *metainfo.Info, infoHash metainfo.Hash) string {
|
|
return f.meta.Opts.Path
|
|
},
|
|
})
|
|
f.torrent, _, err = client.AddTorrentSpec(spec)
|
|
if err != nil {
|
|
return
|
|
}
|
|
|
|
// Do not add external tracker to a private torrent.
|
|
if !privateTorrent {
|
|
// use map to deduplicate
|
|
trackers := make(map[string]bool)
|
|
if req.Extra != nil {
|
|
extra := req.Extra.(*bt.ReqExtra)
|
|
if len(extra.Trackers) > 0 {
|
|
for _, tracker := range extra.Trackers {
|
|
trackers[tracker] = true
|
|
}
|
|
}
|
|
}
|
|
if len(f.config.Trackers) > 0 {
|
|
for _, tracker := range f.config.Trackers {
|
|
trackers[tracker] = true
|
|
}
|
|
}
|
|
if len(trackers) > 0 {
|
|
announceList := make([][]string, 0)
|
|
for tracker := range trackers {
|
|
announceList = append(announceList, []string{tracker})
|
|
}
|
|
f.torrent.AddTrackers(announceList)
|
|
}
|
|
}
|
|
<-f.torrent.GotInfo()
|
|
f.torrentReady.Store(true)
|
|
|
|
go f.doUpload(fromUpload)
|
|
return
|
|
}
|
|
|
|
func (f *Fetcher) seedRadio() float64 {
|
|
var bytesRead int64
|
|
if f.Meta().Res != nil {
|
|
bytesRead = f.Meta().Res.Size
|
|
} else {
|
|
bytesRead = 0
|
|
}
|
|
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
|
|
}
|
|
|
|
func closeClient() error {
|
|
lock.Lock()
|
|
defer lock.Unlock()
|
|
|
|
if closeFunc != nil {
|
|
closeFunc()
|
|
}
|
|
if client != nil {
|
|
errs := client.Close()
|
|
if len(errs) > 0 {
|
|
return errs[0]
|
|
}
|
|
client = nil
|
|
closeCtx = nil
|
|
closeFunc = nil
|
|
}
|
|
return nil
|
|
}
|
|
|
|
type FetcherManager struct {
|
|
}
|
|
|
|
func (fm *FetcherManager) Name() string {
|
|
return "bt"
|
|
}
|
|
|
|
func (fm *FetcherManager) Filters() []*fetcher.SchemeFilter {
|
|
return []*fetcher.SchemeFilter{
|
|
{
|
|
Type: fetcher.FilterTypeUrl,
|
|
Pattern: "MAGNET",
|
|
},
|
|
{
|
|
Type: fetcher.FilterTypeFile,
|
|
Pattern: "TORRENT",
|
|
},
|
|
{
|
|
Type: fetcher.FilterTypeBase64,
|
|
Pattern: "APPLICATION/X-BITTORRENT",
|
|
},
|
|
}
|
|
}
|
|
|
|
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,
|
|
Trackers: []string{},
|
|
SeedKeep: false,
|
|
SeedRatio: 1.0,
|
|
SeedTime: 120 * 60,
|
|
}
|
|
}
|
|
|
|
func (fm *FetcherManager) Store(f fetcher.Fetcher) (data any, err error) {
|
|
_f := f.(*Fetcher)
|
|
return _f.data, nil
|
|
}
|
|
|
|
func (fm *FetcherManager) Restore() (v any, f 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),
|
|
}
|
|
}
|
|
}
|
|
|
|
func (fm *FetcherManager) Close() error {
|
|
return closeClient()
|
|
}
|
|
|
|
// parse version to bep20 format, fixed length 4, if not enough, fill 0
|
|
func parseBep20() string {
|
|
s := strings.ReplaceAll(base.Version, ".", "")
|
|
if len(s) < 4 {
|
|
s += strings.Repeat("0", 4-len(s))
|
|
}
|
|
return s
|
|
}
|