mirror of
https://github.com/GopeedLab/gopeed.git
synced 2026-11-04 08:10:55 +00:00
998 lines
25 KiB
Go
998 lines
25 KiB
Go
package bt
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"net/url"
|
|
"os"
|
|
"path/filepath"
|
|
"runtime"
|
|
"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/RoaringBitmap/roaring"
|
|
"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 {
|
|
mu sync.Mutex
|
|
closeOnce sync.Once
|
|
storage *deferredFileStorage
|
|
|
|
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
|
|
}
|
|
|
|
// initClient must be called with lock held.
|
|
func (f *Fetcher) initClient() (err error) {
|
|
if client != nil {
|
|
return
|
|
}
|
|
|
|
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.NoDHT = f.config.DisableDHT
|
|
cfg.DisablePEX = f.config.DisablePEX
|
|
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())
|
|
// closeClient may reset closeCtx before the goroutine runs, so pass it as an argument.
|
|
go dnsResolver.Run(closeCtx)
|
|
return
|
|
}
|
|
|
|
// addTorrentSpec holds lock from client initialization until the torrent is added,
|
|
// so closeClient cannot close or reset the client in between.
|
|
func (f *Fetcher) addTorrentSpec(spec *torrent.TorrentSpec) (*torrent.Torrent, bool, error) {
|
|
lock.Lock()
|
|
defer lock.Unlock()
|
|
|
|
if err := f.initClient(); err != nil {
|
|
return nil, false, err
|
|
}
|
|
return client.AddTorrentSpec(spec)
|
|
}
|
|
|
|
func (f *Fetcher) Resolve(req *base.Request) error {
|
|
f.mu.Lock()
|
|
defer f.mu.Unlock()
|
|
if f.torrent != nil {
|
|
f.safeDrop()
|
|
f.torrent = nil
|
|
f.storage = nil
|
|
f.torrentReady.Store(false)
|
|
}
|
|
f.meta.Req = req
|
|
if err := f.addTorrent(req); err != nil {
|
|
return err
|
|
}
|
|
f.updateRes()
|
|
return nil
|
|
}
|
|
|
|
func (f *Fetcher) Start() (err error) {
|
|
f.mu.Lock()
|
|
defer f.mu.Unlock()
|
|
if f.meta.Opts == nil || f.meta.Opts.Path == "" {
|
|
return errors.New("download directory is required when starting a torrent")
|
|
}
|
|
if err := f.torrentDropCtx.Err(); err != nil {
|
|
return err
|
|
}
|
|
if !f.torrentReady.Load() {
|
|
if err = f.addTorrent(f.meta.Req); err != nil {
|
|
return
|
|
}
|
|
}
|
|
|
|
path := f.meta.Opts.Path
|
|
if err := os.MkdirAll(path, 0755); err != nil {
|
|
return err
|
|
}
|
|
hashes, err := f.storage.bind(storage.NewFileClientOpts{
|
|
ClientBaseDir: path,
|
|
FilePathMaker: func(opts storage.FilePathMakerOpts) string {
|
|
_, name := torrentFileLayout(opts.Info, *opts.File)
|
|
return name
|
|
},
|
|
})
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if hashes != nil {
|
|
// File storage infers completion from final file names and sizes. Verify
|
|
// before publishing that state, or peers can see an unverified file as
|
|
// complete and disconnect before a damaged piece is discovered.
|
|
// Late v2 hashes schedule their own initial checks through the library.
|
|
changes := f.torrent.SubscribePieceStateChanges()
|
|
defer changes.Close()
|
|
for i, known := range hashes {
|
|
if !known {
|
|
continue
|
|
}
|
|
if err := f.torrentDropCtx.Err(); err != nil {
|
|
return err
|
|
}
|
|
if err := f.torrent.Piece(i).VerifyDataContext(f.torrentDropCtx); err != nil {
|
|
return err
|
|
}
|
|
// VerifyDataContext can return while the result is still being
|
|
// written to storage. Wait for it before refreshing completion.
|
|
for {
|
|
state := f.torrent.Piece(i).State()
|
|
if !state.Hashing && !state.QueuedForHash && !state.Marking {
|
|
break
|
|
}
|
|
select {
|
|
case <-changes.Values:
|
|
case <-f.torrentDropCtx.Done():
|
|
return f.torrentDropCtx.Err()
|
|
case <-f.torrent.Closed():
|
|
return errors.New("torrent closed during verification")
|
|
}
|
|
}
|
|
}
|
|
for i := range hashes {
|
|
f.torrent.Piece(i).UpdateCompletion()
|
|
}
|
|
}
|
|
|
|
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()
|
|
go f.doUpload(false)
|
|
return
|
|
}
|
|
|
|
func (f *Fetcher) Pause() (err error) {
|
|
f.mu.Lock()
|
|
defer f.mu.Unlock()
|
|
if f.torrent == nil {
|
|
return nil
|
|
}
|
|
f.torrent.DisallowDataDownload()
|
|
return
|
|
}
|
|
|
|
func (f *Fetcher) Close() (err error) {
|
|
f.closeOnce.Do(func() {
|
|
f.torrentDropFunc()
|
|
f.mu.Lock()
|
|
defer f.mu.Unlock()
|
|
f.safeDrop()
|
|
f.torrentReady.Store(false)
|
|
close(f.uploadDoneCh)
|
|
})
|
|
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() *fetcher.Stats {
|
|
f.mu.Lock()
|
|
defer f.mu.Unlock()
|
|
var stats torrent.TorrentStats
|
|
var pieceMap *base.PieceMap
|
|
pieceMapReady := false
|
|
peers := make([]*base.PeerStats, 0)
|
|
if f.torrent != nil {
|
|
stats = f.torrent.Stats()
|
|
pieceMap, pieceMapReady = buildBTPieceMap(f.torrent)
|
|
peers = buildBTPeers(f.torrent)
|
|
} else {
|
|
stats = torrent.TorrentStats{}
|
|
}
|
|
var snapshot any
|
|
if pieceMapReady {
|
|
snapshot = &bt.StatsSnapshot{
|
|
SeedBytes: f.data.SeedBytes,
|
|
SeedRatio: f.seedRadio(),
|
|
SeedTime: f.data.SeedTime,
|
|
PieceMap: pieceMap.Clone(),
|
|
}
|
|
}
|
|
// When completion is temporarily unknown while anacrolix restores storage
|
|
// or verifies pieces, the nil Snapshot tells Downloader to retain the last
|
|
// trusted bitset instead of replacing it with an all-empty map.
|
|
return &fetcher.Stats{
|
|
Snapshot: snapshot,
|
|
Runtime: &bt.StatsRuntime{
|
|
TotalPeers: stats.TotalPeers,
|
|
ActivePeers: stats.ActivePeers,
|
|
ConnectedSeeders: stats.ConnectedSeeders,
|
|
ConnectedLeechers: stats.ActivePeers - stats.ConnectedSeeders,
|
|
Peers: peers,
|
|
},
|
|
}
|
|
}
|
|
|
|
// buildBTPieceMap converts anacrolix's ordered completion runs to Gopeed's
|
|
// one-bit map. ready is false until storage knows the completion state of every
|
|
// piece; this prevents initial hash checking from briefly replacing a restored
|
|
// map with an all-empty value.
|
|
func buildBTPieceMap(t *torrent.Torrent) (pieceMap *base.PieceMap, ready bool) {
|
|
info := t.Info()
|
|
if info == nil {
|
|
return nil, false
|
|
}
|
|
pieceMap = base.NewPieceMap(info.NumPieces(), info.PieceLength)
|
|
ready = applyBTPieceRuns(pieceMap, t.PieceStateRuns())
|
|
return pieceMap, ready
|
|
}
|
|
|
|
func applyBTPieceRuns(pieceMap *base.PieceMap, runs []torrent.PieceStateRun) (ready bool) {
|
|
ready = true
|
|
pieceIndex := 0
|
|
for _, run := range runs {
|
|
if !run.Ok {
|
|
ready = false
|
|
}
|
|
completed := run.Ok && run.Complete
|
|
for range run.Length {
|
|
if pieceIndex >= pieceMap.PieceCount() {
|
|
return ready
|
|
}
|
|
_ = pieceMap.Update(pieceIndex, completed)
|
|
pieceIndex++
|
|
}
|
|
}
|
|
return ready && pieceIndex == pieceMap.PieceCount()
|
|
}
|
|
|
|
func buildBTPeers(t *torrent.Torrent) []*base.PeerStats {
|
|
connections := t.PeerConns()
|
|
totalPieces := 0
|
|
var missingWantedPieces *roaring.Bitmap
|
|
// Magnet torrents can connect to peers before their metadata arrives. Keep
|
|
// those runtime peer stats, but leave completion and relevance unavailable
|
|
// until the metadata provides a trusted total piece count.
|
|
if info := t.Info(); info != nil {
|
|
totalPieces = info.NumPieces()
|
|
missingWantedPieces = btMissingWantedPieces(t.PieceStateRuns(), totalPieces)
|
|
}
|
|
peers := make([]*base.PeerStats, 0, len(connections))
|
|
for _, connection := range connections {
|
|
peerStats := connection.Stats()
|
|
peers = append(peers, &base.PeerStats{
|
|
Address: safeBTPeerAddress(connection),
|
|
Client: btPeerClient(connection),
|
|
DownloadSpeed: int64(peerStats.DownloadRate),
|
|
UploadSpeed: int64(peerStats.LastWriteUploadRate),
|
|
PieceCount: peerStats.RemotePieceCount,
|
|
Completion: btPeerCompletion(peerStats.RemotePieceCount, totalPieces),
|
|
Relevance: btPeerRelevance(connection.PeerPieces(), missingWantedPieces),
|
|
Source: normalizeBTPeerSource(connection.Discovery),
|
|
Transport: normalizeBTTransport(connection.Network),
|
|
})
|
|
}
|
|
return peers
|
|
}
|
|
|
|
// btPeerCompletion converts anacrolix's remote piece count into the ratio the
|
|
// API exposes. A nil value distinguishes unavailable metadata from a real 0%.
|
|
func btPeerCompletion(pieceCount, totalPieces int) *float64 {
|
|
if totalPieces <= 0 {
|
|
return nil
|
|
}
|
|
if pieceCount < 0 {
|
|
pieceCount = 0
|
|
} else if pieceCount > totalPieces {
|
|
pieceCount = totalPieces
|
|
}
|
|
completion := float64(pieceCount) / float64(totalPieces)
|
|
return &completion
|
|
}
|
|
|
|
// btMissingWantedPieces builds the denominator used by peer relevance. Pieces
|
|
// belonging only to skipped files are excluded because their priority is none.
|
|
func btMissingWantedPieces(runs []torrent.PieceStateRun, totalPieces int) *roaring.Bitmap {
|
|
if totalPieces <= 0 {
|
|
return nil
|
|
}
|
|
missing := roaring.New()
|
|
pieceIndex := 0
|
|
for _, run := range runs {
|
|
for range run.Length {
|
|
if pieceIndex >= totalPieces {
|
|
return nil
|
|
}
|
|
if run.Priority != torrent.PiecePriorityNone && !(run.Ok && run.Complete) {
|
|
missing.Add(uint32(pieceIndex))
|
|
}
|
|
pieceIndex++
|
|
}
|
|
}
|
|
if pieceIndex != totalPieces {
|
|
return nil
|
|
}
|
|
return missing
|
|
}
|
|
|
|
// btPeerRelevance reports how much of the local wanted, missing set the remote
|
|
// peer can provide. A zero value is meaningful; nil means there is no denominator.
|
|
func btPeerRelevance(peerPieces, missingWantedPieces *roaring.Bitmap) *float64 {
|
|
if peerPieces == nil || missingWantedPieces == nil || missingWantedPieces.IsEmpty() {
|
|
return nil
|
|
}
|
|
relevantPieces := roaring.And(peerPieces, missingWantedPieces).GetCardinality()
|
|
relevance := float64(relevantPieces) / float64(missingWantedPieces.GetCardinality())
|
|
return &relevance
|
|
}
|
|
|
|
// A uTP socket may close between PeerConns and this snapshot. Some uTP address
|
|
// implementations panic after close, so one disappearing address must not make
|
|
// the entire stats endpoint fail.
|
|
func safeBTPeerAddress(connection *torrent.PeerConn) (address string) {
|
|
defer func() {
|
|
if recover() != nil {
|
|
address = ""
|
|
}
|
|
}()
|
|
if connection.RemoteAddr == nil {
|
|
return ""
|
|
}
|
|
return connection.RemoteAddr.String()
|
|
}
|
|
|
|
func btPeerClient(connection *torrent.PeerConn) string {
|
|
value := connection.PeerClientName.Load()
|
|
if value == nil {
|
|
return ""
|
|
}
|
|
return fmt.Sprint(value)
|
|
}
|
|
|
|
func normalizeBTPeerSource(source torrent.PeerSource) string {
|
|
switch source {
|
|
case torrent.PeerSourceTracker:
|
|
return "tracker"
|
|
case torrent.PeerSourceDhtGetPeers, torrent.PeerSourceDhtAnnouncePeer:
|
|
return "dht"
|
|
case torrent.PeerSourcePex:
|
|
return "pex"
|
|
case torrent.PeerSourceIncoming:
|
|
return "incoming"
|
|
case torrent.PeerSourceDirect:
|
|
return "direct"
|
|
case torrent.PeerSourceUtHolepunch:
|
|
return "holepunch"
|
|
default:
|
|
return "unknown"
|
|
}
|
|
}
|
|
|
|
func normalizeBTTransport(network string) string {
|
|
network = strings.ToLower(network)
|
|
switch {
|
|
case strings.Contains(network, "webrtc"):
|
|
return "webrtc"
|
|
case strings.Contains(network, "udp"):
|
|
// anacrolix labels uTP sockets with their underlying udp network.
|
|
return "utp"
|
|
case strings.Contains(network, "tcp"):
|
|
return "tcp"
|
|
default:
|
|
return "unknown"
|
|
}
|
|
}
|
|
|
|
func (f *Fetcher) Progress() fetcher.Progress {
|
|
f.mu.Lock()
|
|
defer f.mu.Unlock()
|
|
if !f.torrentReady.Load() {
|
|
return append(fetcher.Progress(nil), 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 append(fetcher.Progress(nil), f.data.Progress...)
|
|
}
|
|
|
|
func (f *Fetcher) Wait() error {
|
|
f.mu.Lock()
|
|
var closed <-chan struct{}
|
|
if f.torrent != nil {
|
|
closed = f.torrent.Closed()
|
|
}
|
|
f.mu.Unlock()
|
|
ticker := time.NewTicker(time.Second)
|
|
defer ticker.Stop()
|
|
for {
|
|
select {
|
|
case <-f.torrentDropCtx.Done():
|
|
return nil
|
|
case <-closed:
|
|
return nil
|
|
case <-ticker.C:
|
|
done, err := f.finishDownload()
|
|
if done || err != nil {
|
|
return err
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
func (f *Fetcher) finishDownload() (bool, error) {
|
|
f.mu.Lock()
|
|
defer f.mu.Unlock()
|
|
if !f.torrentReady.Load() || f.meta.Opts == nil || len(f.meta.Opts.SelectFiles) == 0 || !f.isDoneLocked() {
|
|
return false, nil
|
|
}
|
|
selected := make(map[int]bool, len(f.meta.Opts.SelectFiles))
|
|
for _, i := range f.meta.Opts.SelectFiles {
|
|
selected[i] = true
|
|
}
|
|
for i, file := range f.torrent.Files() {
|
|
if selected[i] {
|
|
continue
|
|
}
|
|
root, name := torrentFileLayout(f.torrent.Info(), file.FileInfo())
|
|
if err := removeUnselectedFile(f.meta.Opts.Path, root, name); err != nil {
|
|
return false, err
|
|
}
|
|
}
|
|
return true, nil
|
|
}
|
|
|
|
// Match the file storage layout, including its NoName convention for rootless
|
|
// torrents. File.Path() always prepends BestName, even when storage omits it.
|
|
func torrentFileLayout(info *metainfo.Info, file metainfo.FileInfo) (root, name string) {
|
|
parts := file.BestPath()
|
|
if info.BestName() != metainfo.NoName {
|
|
if len(parts) > 0 {
|
|
root = info.BestName()
|
|
}
|
|
parts = append([]string{info.BestName()}, parts...)
|
|
}
|
|
// A v2 file tree can itself have a named root without an extra name wrapper.
|
|
if root == "" && info.HasV2() && len(info.FileTree.Dir) == 1 {
|
|
for dir, tree := range info.FileTree.Dir {
|
|
if tree.IsDir() {
|
|
root = dir
|
|
}
|
|
}
|
|
}
|
|
return root, filepath.Join(parts...)
|
|
}
|
|
|
|
// Preserve the actual torrent root, or the download directory for rootless
|
|
// torrents. Only prune empty directories strictly below that boundary.
|
|
func removeUnselectedFile(downloadDir, torrentRoot, torrentPath string) error {
|
|
if !filepath.IsLocal(torrentPath) || (torrentRoot != "" && !filepath.IsLocal(torrentRoot)) {
|
|
return fmt.Errorf("invalid torrent file path %q", torrentPath)
|
|
}
|
|
root := filepath.Join(downloadDir, torrentRoot)
|
|
name := filepath.Join(downloadDir, torrentPath)
|
|
rel, err := filepath.Rel(root, name)
|
|
if err != nil || rel == "." || !filepath.IsLocal(rel) {
|
|
return fmt.Errorf("torrent file %q is outside cleanup root %q", name, root)
|
|
}
|
|
for _, candidate := range []string{name, name + ".part"} {
|
|
if err := util.SafeRemove(candidate); err != nil {
|
|
return fmt.Errorf("remove unselected torrent file %q: %w", candidate, err)
|
|
}
|
|
}
|
|
for dir := filepath.Dir(name); dir != root && dir != filepath.Dir(dir); dir = filepath.Dir(dir) {
|
|
entries, err := os.ReadDir(dir)
|
|
if os.IsNotExist(err) {
|
|
continue
|
|
}
|
|
if err != nil {
|
|
return fmt.Errorf("read torrent directory %q: %w", dir, err)
|
|
}
|
|
if len(entries) != 0 {
|
|
break
|
|
}
|
|
if err := util.SafeRemove(dir); err != nil {
|
|
return fmt.Errorf("remove empty torrent directory %q: %w", dir, err)
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (f *Fetcher) isDone() bool {
|
|
f.mu.Lock()
|
|
defer f.mu.Unlock()
|
|
return f.isDoneLocked()
|
|
}
|
|
|
|
func (f *Fetcher) isDoneLocked() bool {
|
|
if f.meta.Opts == nil || f.torrent == nil {
|
|
return false
|
|
}
|
|
for _, index := range f.meta.Opts.SelectFiles {
|
|
file := f.torrent.Files()[index]
|
|
for i := file.BeginPieceIndex(); i < file.EndPieceIndex(); i++ {
|
|
state := f.torrent.Piece(i).State()
|
|
if !state.Ok || !state.Complete || state.Hashing || state.QueuedForHash {
|
|
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 {
|
|
f.mu.Lock()
|
|
defer f.mu.Unlock()
|
|
if f.meta.Opts == nil {
|
|
f.meta.Opts = &base.Options{}
|
|
}
|
|
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(),
|
|
}
|
|
// Use the same root classification as storage (v2 single files have Length == 0).
|
|
if files := f.torrent.Files(); len(files) > 0 {
|
|
res.Name, _ = torrentFileLayout(f.torrent.Info(), files[0].FileInfo())
|
|
}
|
|
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) {
|
|
if err = f.Start(); err != nil {
|
|
return err
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (f *Fetcher) doUpload(fromUpload bool) {
|
|
if !f.torrentUpload.CompareAndSwap(false, true) {
|
|
return
|
|
}
|
|
f.mu.Lock()
|
|
initialBytes, initialTime := f.data.SeedBytes, f.data.SeedTime
|
|
torrentClosed := f.torrent.Closed()
|
|
f.mu.Unlock()
|
|
var doneTime int64
|
|
ticker := time.NewTicker(time.Second)
|
|
defer ticker.Stop()
|
|
for {
|
|
select {
|
|
case <-f.torrentDropCtx.Done():
|
|
return
|
|
case <-torrentClosed:
|
|
return
|
|
case <-ticker.C:
|
|
f.mu.Lock()
|
|
if !f.torrentReady.Load() {
|
|
f.mu.Unlock()
|
|
continue
|
|
}
|
|
stats := f.torrentStats()
|
|
f.data.SeedBytes = initialBytes + stats.BytesWrittenData.Int64()
|
|
if !fromUpload && !f.isDoneLocked() {
|
|
f.mu.Unlock()
|
|
continue
|
|
}
|
|
if doneTime == 0 {
|
|
doneTime = time.Now().Unix()
|
|
}
|
|
f.data.SeedTime = initialTime + time.Now().Unix() - doneTime
|
|
stop := !f.config.SeedKeep && ((f.config.SeedRatio > 0 && f.seedRadio() >= f.config.SeedRatio) || (f.config.SeedTime > 0 && f.data.SeedTime >= f.config.SeedTime))
|
|
f.mu.Unlock()
|
|
if stop {
|
|
f.Close()
|
|
return
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// 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 {
|
|
f.mu.Lock()
|
|
defer f.mu.Unlock()
|
|
return f.data.SeedBytes
|
|
}
|
|
|
|
func (f *Fetcher) WaitUpload() (err error) {
|
|
<-f.uploadDoneCh
|
|
return nil
|
|
}
|
|
|
|
func (f *Fetcher) addTorrent(req *base.Request) (err error) {
|
|
f.ctl.GetConfig(&f.config)
|
|
if err = base.ParseReqExtra[bt.ReqExtra](req); 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
|
|
if runtime.GOOS == "windows" && len(filePath) > 2 && filePath[0] == '/' && filePath[2] == ':' {
|
|
filePath = filePath[1:]
|
|
}
|
|
filePath = filepath.FromSlash(filePath)
|
|
reader, err = os.Open(filePath)
|
|
if err != nil {
|
|
return
|
|
}
|
|
defer reader.(io.Closer).Close()
|
|
} 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
|
|
}
|
|
}
|
|
// 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
|
|
}
|
|
}
|
|
}
|
|
for _, tracker := range f.config.effectiveTrackers() {
|
|
trackers[tracker] = true
|
|
}
|
|
if len(trackers) > 0 {
|
|
for tracker := range trackers {
|
|
spec.Trackers = append(spec.Trackers, []string{tracker})
|
|
}
|
|
}
|
|
}
|
|
s := &deferredFileStorage{}
|
|
spec.Storage = s
|
|
var added bool
|
|
f.torrent, added, err = f.addTorrentSpec(spec)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if !added {
|
|
f.torrent = nil
|
|
return errors.New("torrent is already active")
|
|
}
|
|
f.storage = s
|
|
f.torrent.DisallowDataDownload()
|
|
// Extra trackers must be available while magnet metadata is still missing;
|
|
// they may be the only way to discover a peer that can provide it.
|
|
select {
|
|
case <-f.torrent.GotInfo():
|
|
case <-f.torrentDropCtx.Done():
|
|
f.safeDrop()
|
|
return f.torrentDropCtx.Err()
|
|
case <-f.torrent.Closed():
|
|
return errors.New("torrent closed during resolution")
|
|
}
|
|
f.torrentReady.Store(true)
|
|
|
|
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{
|
|
TrackerSubscribeURLs: append([]string{}, defaultTrackerSubscribeURLs...),
|
|
AutoUpdateTrackers: true,
|
|
CustomTrackers: []string{},
|
|
ConfigState: fetcher.ConfigState[state]{State: state{
|
|
TrackerSources: map[string][]string{},
|
|
TrackerSubscriptionStatuses: map[string]trackerSubscriptionStatus{},
|
|
}},
|
|
ListenPort: 0,
|
|
SeedKeep: false,
|
|
SeedRatio: 1.0,
|
|
SeedTime: 120 * 60,
|
|
DisableDHT: false,
|
|
DisablePEX: false,
|
|
}
|
|
}
|
|
|
|
func (fm *FetcherManager) Store(f fetcher.Fetcher) (data any, err error) {
|
|
_f := f.(*Fetcher)
|
|
_f.mu.Lock()
|
|
defer _f.mu.Unlock()
|
|
snapshot := *_f.data
|
|
snapshot.Progress = append(fetcher.Progress(nil), snapshot.Progress...)
|
|
return &snapshot, 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
|
|
}
|