mirror of
https://github.com/rclone/rclone
synced 2024-12-22 13:03:02 +01:00
torrent: init
This commit is contained in:
parent
a78bc093de
commit
a77cfaf59b
@ -57,6 +57,7 @@ import (
|
|||||||
_ "github.com/rclone/rclone/backend/storj"
|
_ "github.com/rclone/rclone/backend/storj"
|
||||||
_ "github.com/rclone/rclone/backend/sugarsync"
|
_ "github.com/rclone/rclone/backend/sugarsync"
|
||||||
_ "github.com/rclone/rclone/backend/swift"
|
_ "github.com/rclone/rclone/backend/swift"
|
||||||
|
_ "github.com/rclone/rclone/backend/torrent"
|
||||||
_ "github.com/rclone/rclone/backend/ulozto"
|
_ "github.com/rclone/rclone/backend/ulozto"
|
||||||
_ "github.com/rclone/rclone/backend/union"
|
_ "github.com/rclone/rclone/backend/union"
|
||||||
_ "github.com/rclone/rclone/backend/uptobox"
|
_ "github.com/rclone/rclone/backend/uptobox"
|
||||||
|
384
backend/torrent/object.go
Normal file
384
backend/torrent/object.go
Normal file
@ -0,0 +1,384 @@
|
|||||||
|
package torrent
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"sync"
|
||||||
|
"sync/atomic"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/anacrolix/torrent"
|
||||||
|
"github.com/anacrolix/torrent/types"
|
||||||
|
"github.com/rclone/rclone/fs"
|
||||||
|
"github.com/rclone/rclone/fs/hash"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
defaultReadAhead int64 = 4 << 20
|
||||||
|
largeFileReadAhead int64 = 16 << 20
|
||||||
|
|
||||||
|
criticalWindow = 3
|
||||||
|
prefetchWindow = 10
|
||||||
|
|
||||||
|
priorityNow = 255
|
||||||
|
priorityHigh = 192
|
||||||
|
priorityNormal = 128
|
||||||
|
priorityLow = 64
|
||||||
|
)
|
||||||
|
|
||||||
|
type Object struct {
|
||||||
|
fs *Fs
|
||||||
|
virtualPath string
|
||||||
|
torrentPath string
|
||||||
|
size int64
|
||||||
|
modTime time.Time
|
||||||
|
sourcePath string
|
||||||
|
}
|
||||||
|
|
||||||
|
func (o *Object) Fs() fs.Info { return o.fs }
|
||||||
|
func (o *Object) Remote() string { return o.virtualPath }
|
||||||
|
func (o *Object) ModTime(context.Context) time.Time { return o.modTime }
|
||||||
|
func (o *Object) Size() int64 { return o.size }
|
||||||
|
func (o *Object) Storable() bool { return false }
|
||||||
|
func (o *Object) String() string { return o.virtualPath }
|
||||||
|
func (o *Object) Hash(context.Context, hash.Type) (string, error) { return "", hash.ErrUnsupported }
|
||||||
|
func (o *Object) SetModTime(context.Context, time.Time) error { return fs.ErrorPermissionDenied }
|
||||||
|
func (o *Object) Remove(context.Context) error { return fs.ErrorPermissionDenied }
|
||||||
|
func (o *Object) Update(context.Context, io.Reader, fs.ObjectInfo, ...fs.OpenOption) error {
|
||||||
|
return fs.ErrorPermissionDenied
|
||||||
|
}
|
||||||
|
|
||||||
|
type pieceWindow struct {
|
||||||
|
start int64
|
||||||
|
end int64
|
||||||
|
priority int
|
||||||
|
readCount int64
|
||||||
|
lastRead time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
type torrentReader struct {
|
||||||
|
ctx context.Context
|
||||||
|
cancel context.CancelFunc
|
||||||
|
object *Object
|
||||||
|
file *torrent.File
|
||||||
|
reader torrent.Reader
|
||||||
|
startTime time.Time
|
||||||
|
closed atomic.Bool
|
||||||
|
mu sync.Mutex
|
||||||
|
offset int64
|
||||||
|
|
||||||
|
readAhead int64
|
||||||
|
pieceLength int64
|
||||||
|
windows []pieceWindow
|
||||||
|
windowsMu sync.Mutex
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
func (r *torrentReader) updatePiecePriorities(currentPiece int64) {
|
||||||
|
r.windowsMu.Lock()
|
||||||
|
defer r.windowsMu.Unlock()
|
||||||
|
|
||||||
|
numPieces := r.file.Torrent().NumPieces()
|
||||||
|
critical := currentPiece
|
||||||
|
criticalEnd := min(critical+int64(criticalWindow), int64(numPieces))
|
||||||
|
prefetch := criticalEnd
|
||||||
|
prefetchEnd := min(prefetch+int64(prefetchWindow), int64(numPieces))
|
||||||
|
|
||||||
|
for i := int64(0); i < int64(numPieces); i++ {
|
||||||
|
piece := r.file.Torrent().Piece(int(i))
|
||||||
|
if !piece.State().Complete {
|
||||||
|
piece.SetPriority(types.PiecePriorityNone)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for i := critical; i < criticalEnd; i++ {
|
||||||
|
piece := r.file.Torrent().Piece(int(i))
|
||||||
|
if !piece.State().Complete {
|
||||||
|
piece.SetPriority(types.PiecePriorityNow)
|
||||||
|
fs.Debugf(r.object, "Set critical priority for piece %d", i)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for i := prefetch; i < prefetchEnd; i++ {
|
||||||
|
piece := r.file.Torrent().Piece(int(i))
|
||||||
|
if !piece.State().Complete {
|
||||||
|
piece.SetPriority(types.PiecePriorityNormal)
|
||||||
|
fs.Debugf(r.object, "Set prefetch priority for piece %d", i)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update windows
|
||||||
|
r.windows = []pieceWindow{
|
||||||
|
{
|
||||||
|
start: critical,
|
||||||
|
end: criticalEnd,
|
||||||
|
priority: int(types.PiecePriorityNow),
|
||||||
|
lastRead: time.Now(),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
start: prefetch,
|
||||||
|
end: prefetchEnd,
|
||||||
|
priority: int(types.PiecePriorityNormal),
|
||||||
|
lastRead: time.Now(),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *torrentReader) logProgress() {
|
||||||
|
stats := r.file.Torrent().Stats()
|
||||||
|
bytesCompleted := r.file.BytesCompleted()
|
||||||
|
progress := float64(bytesCompleted) / float64(r.file.Length()) * 100
|
||||||
|
|
||||||
|
fs.Debugf(r.object, "Progress: %.1f%%, Active peers: %d, Total peers: %d, Current offset: %d/%d",
|
||||||
|
progress,
|
||||||
|
stats.ActivePeers,
|
||||||
|
stats.TotalPeers,
|
||||||
|
atomic.LoadInt64(&r.offset),
|
||||||
|
r.file.Length())
|
||||||
|
|
||||||
|
if r.windows != nil {
|
||||||
|
r.windowsMu.Lock()
|
||||||
|
for _, window := range r.windows {
|
||||||
|
completed := 0
|
||||||
|
for i := window.start; i < window.end; i++ {
|
||||||
|
if r.file.Torrent().Piece(int(i)).State().Complete {
|
||||||
|
completed++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
fs.Debugf(r.object, "Window [%d-%d]: %d/%d pieces complete, priority %d",
|
||||||
|
window.start, window.end, completed, window.end-window.start, window.priority)
|
||||||
|
}
|
||||||
|
r.windowsMu.Unlock()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *torrentReader) Read(p []byte) (n int, err error) {
|
||||||
|
if r.closed.Load() {
|
||||||
|
return 0, io.ErrClosedPipe
|
||||||
|
}
|
||||||
|
|
||||||
|
r.mu.Lock()
|
||||||
|
reader := r.reader
|
||||||
|
r.mu.Unlock()
|
||||||
|
|
||||||
|
if reader == nil {
|
||||||
|
return 0, io.ErrClosedPipe
|
||||||
|
}
|
||||||
|
|
||||||
|
currentPos := atomic.LoadInt64(&r.offset)
|
||||||
|
currentPiece := currentPos / r.pieceLength
|
||||||
|
|
||||||
|
needsUpdate := false
|
||||||
|
r.windowsMu.Lock()
|
||||||
|
for i := range r.windows {
|
||||||
|
if currentPiece >= r.windows[i].end {
|
||||||
|
needsUpdate = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
r.windowsMu.Unlock()
|
||||||
|
|
||||||
|
if needsUpdate {
|
||||||
|
r.updatePiecePriorities(currentPiece)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Perform the read
|
||||||
|
readCtx, cancel := context.WithTimeout(r.ctx, 30*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
readDone := make(chan struct {
|
||||||
|
n int
|
||||||
|
err error
|
||||||
|
}, 1)
|
||||||
|
|
||||||
|
go func() {
|
||||||
|
n, err := reader.Read(p)
|
||||||
|
readDone <- struct {
|
||||||
|
n int
|
||||||
|
err error
|
||||||
|
}{n, err}
|
||||||
|
}()
|
||||||
|
|
||||||
|
select {
|
||||||
|
case result := <-readDone:
|
||||||
|
if result.err == nil {
|
||||||
|
atomic.AddInt64(&r.offset, int64(result.n))
|
||||||
|
|
||||||
|
// Log progress periodically
|
||||||
|
if time.Now().Unix()%5 == 0 {
|
||||||
|
r.logProgress()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result.n, result.err
|
||||||
|
case <-readCtx.Done():
|
||||||
|
return 0, readCtx.Err()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *torrentReader) Seek(offset int64, whence int) (int64, error) {
|
||||||
|
if r.closed.Load() {
|
||||||
|
return 0, io.ErrClosedPipe
|
||||||
|
}
|
||||||
|
|
||||||
|
r.mu.Lock()
|
||||||
|
defer r.mu.Unlock()
|
||||||
|
|
||||||
|
if r.reader == nil {
|
||||||
|
return 0, io.ErrClosedPipe
|
||||||
|
}
|
||||||
|
|
||||||
|
var abs int64
|
||||||
|
switch whence {
|
||||||
|
case io.SeekStart:
|
||||||
|
abs = offset
|
||||||
|
case io.SeekCurrent:
|
||||||
|
abs = atomic.LoadInt64(&r.offset) + offset
|
||||||
|
case io.SeekEnd:
|
||||||
|
abs = r.file.Length() + offset
|
||||||
|
default:
|
||||||
|
return 0, fmt.Errorf("invalid whence: %d", whence)
|
||||||
|
}
|
||||||
|
|
||||||
|
if abs < 0 {
|
||||||
|
return 0, fmt.Errorf("negative seek position: %d", abs)
|
||||||
|
}
|
||||||
|
if abs > r.file.Length() {
|
||||||
|
return 0, fmt.Errorf("seek beyond end: %d > %d", abs, r.file.Length())
|
||||||
|
}
|
||||||
|
|
||||||
|
newPiece := abs / r.pieceLength
|
||||||
|
r.updatePiecePriorities(newPiece)
|
||||||
|
|
||||||
|
pos, err := r.reader.Seek(abs, io.SeekStart)
|
||||||
|
if err == nil {
|
||||||
|
atomic.StoreInt64(&r.offset, pos)
|
||||||
|
fs.Debugf(r.object, "Seeked to offset %d (piece %d)", pos, newPiece)
|
||||||
|
}
|
||||||
|
|
||||||
|
return pos, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *torrentReader) RangeSeek(ctx context.Context, offset int64, whence int, length int64) (int64, error) {
|
||||||
|
newReader := r.file.NewReader()
|
||||||
|
|
||||||
|
pos, err := newReader.Seek(offset, whence)
|
||||||
|
if err != nil {
|
||||||
|
newReader.Close()
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
|
||||||
|
r.mu.Lock()
|
||||||
|
if oldReader := r.reader; oldReader != nil {
|
||||||
|
oldReader.Close()
|
||||||
|
}
|
||||||
|
r.reader = newReader
|
||||||
|
atomic.StoreInt64(&r.offset, pos)
|
||||||
|
r.mu.Unlock()
|
||||||
|
|
||||||
|
newPiece := pos / r.pieceLength
|
||||||
|
r.updatePiecePriorities(newPiece)
|
||||||
|
|
||||||
|
return pos, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *torrentReader) Close() error {
|
||||||
|
if !r.closed.CompareAndSwap(false, true) {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
r.cancel()
|
||||||
|
r.mu.Lock()
|
||||||
|
reader := r.reader
|
||||||
|
r.reader = nil
|
||||||
|
r.mu.Unlock()
|
||||||
|
|
||||||
|
fs.Debugf(r.object, "Closed reader after %v", time.Since(r.startTime))
|
||||||
|
|
||||||
|
if reader != nil {
|
||||||
|
return reader.Close()
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (o *Object) Open(ctx context.Context, options ...fs.OpenOption) (io.ReadCloser, error) {
|
||||||
|
fs.Debugf(o, "Opening file: %q", o.virtualPath)
|
||||||
|
|
||||||
|
t, err := o.fs.getTorrent(o.sourcePath)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to load torrent: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var targetFile *torrent.File
|
||||||
|
for _, file := range t.Files() {
|
||||||
|
if o.torrentPath == file.DisplayPath() {
|
||||||
|
targetFile = file
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if targetFile == nil {
|
||||||
|
return nil, fmt.Errorf("file not found in torrent: %s", o.virtualPath)
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx, cancel := context.WithCancel(ctx)
|
||||||
|
tReader := targetFile.NewReader()
|
||||||
|
readAhead := defaultReadAhead
|
||||||
|
|
||||||
|
if targetFile.Length() > 1<<30 { // > 1GB
|
||||||
|
readAhead = largeFileReadAhead
|
||||||
|
}
|
||||||
|
tReader.SetReadahead(readAhead)
|
||||||
|
|
||||||
|
tr := &torrentReader{
|
||||||
|
ctx: ctx,
|
||||||
|
cancel: cancel,
|
||||||
|
object: o,
|
||||||
|
file: targetFile,
|
||||||
|
reader: tReader,
|
||||||
|
startTime: time.Now(),
|
||||||
|
readAhead: readAhead,
|
||||||
|
pieceLength: int64(targetFile.Torrent().Info().PieceLength),
|
||||||
|
}
|
||||||
|
|
||||||
|
// Initialize first piece window
|
||||||
|
tr.updatePiecePriorities(0)
|
||||||
|
|
||||||
|
// Handle initial seek
|
||||||
|
for _, option := range options {
|
||||||
|
switch opt := option.(type) {
|
||||||
|
case *fs.SeekOption:
|
||||||
|
_, err = tr.Seek(opt.Offset, io.SeekStart)
|
||||||
|
if err != nil {
|
||||||
|
tr.Close()
|
||||||
|
return nil, fmt.Errorf("initial seek failed: %w", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fs.Debugf(o, "Opened with read-ahead size: %d bytes", readAhead)
|
||||||
|
return tr, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helper functions
|
||||||
|
func min(a, b int64) int64 {
|
||||||
|
if a < b {
|
||||||
|
return a
|
||||||
|
}
|
||||||
|
return b
|
||||||
|
}
|
||||||
|
|
||||||
|
func max(a, b int) int {
|
||||||
|
if a > b {
|
||||||
|
return a
|
||||||
|
}
|
||||||
|
return b
|
||||||
|
}
|
||||||
|
|
||||||
|
// Interface checks
|
||||||
|
var (
|
||||||
|
_ io.Reader = (*torrentReader)(nil)
|
||||||
|
_ io.Closer = (*torrentReader)(nil)
|
||||||
|
_ io.Seeker = (*torrentReader)(nil)
|
||||||
|
_ fs.RangeSeeker = (*torrentReader)(nil)
|
||||||
|
)
|
644
backend/torrent/torrent.go
Normal file
644
backend/torrent/torrent.go
Normal file
@ -0,0 +1,644 @@
|
|||||||
|
package torrent
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/anacrolix/torrent"
|
||||||
|
"github.com/anacrolix/torrent/metainfo"
|
||||||
|
"github.com/rclone/rclone/fs"
|
||||||
|
"github.com/rclone/rclone/fs/config/configmap"
|
||||||
|
"github.com/rclone/rclone/fs/config/configstruct"
|
||||||
|
"github.com/rclone/rclone/fs/hash"
|
||||||
|
"golang.org/x/time/rate"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
defaultCleanupTimeout = 0
|
||||||
|
defaultHandshakeTimeout = 30 * time.Second
|
||||||
|
maxConnectionsPerTorrent = 50
|
||||||
|
defaultPendingPeers = 25
|
||||||
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
// Registry info for this backend
|
||||||
|
fsInfo = &fs.RegInfo{
|
||||||
|
Name: "torrent",
|
||||||
|
Description: "Read-only torrent backend for accessing torrent contents",
|
||||||
|
NewFs: NewFs,
|
||||||
|
Options: []fs.Option{{
|
||||||
|
Name: "root_directory",
|
||||||
|
Help: "Local directory containing torrent files.",
|
||||||
|
Required: true,
|
||||||
|
}, {
|
||||||
|
Name: "max_download_speed",
|
||||||
|
Help: "Maximum download speed (kBytes/s).",
|
||||||
|
Default: 0,
|
||||||
|
}, {
|
||||||
|
Name: "max_upload_speed",
|
||||||
|
Help: "Maximum upload speed (kBytes/s).",
|
||||||
|
Default: 0,
|
||||||
|
}, {
|
||||||
|
Name: "cache_dir",
|
||||||
|
Help: "Directory to store downloaded torrent data.",
|
||||||
|
Default: "",
|
||||||
|
Advanced: true,
|
||||||
|
}, {
|
||||||
|
Name: "cleanup_timeout",
|
||||||
|
Help: "Remove inactive torrents after X minutes (0 to disable).",
|
||||||
|
Default: defaultCleanupTimeout,
|
||||||
|
Advanced: true,
|
||||||
|
}},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
func init() {
|
||||||
|
fs.Register(fsInfo)
|
||||||
|
}
|
||||||
|
|
||||||
|
type Options struct {
|
||||||
|
RootDirectory string `config:"root_directory"`
|
||||||
|
MaxDownloadSpeed int `config:"max_download_speed"`
|
||||||
|
MaxUploadSpeed int `config:"max_upload_speed"`
|
||||||
|
CacheDir string `config:"cache_dir"`
|
||||||
|
CleanupTimeout int `config:"cleanup_timeout"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fs implements a read-only torrent filesystem
|
||||||
|
type Fs struct {
|
||||||
|
name string
|
||||||
|
root string
|
||||||
|
opt Options
|
||||||
|
features *fs.Features
|
||||||
|
client *torrent.Client
|
||||||
|
baseFs fs.Fs
|
||||||
|
|
||||||
|
// Track active torrents with concurrent map
|
||||||
|
activeTorrents sync.Map // map[string]*torrentInfo
|
||||||
|
}
|
||||||
|
|
||||||
|
// torrentInfo tracks metadata for active torrents
|
||||||
|
type torrentInfo struct {
|
||||||
|
torrent *torrent.Torrent
|
||||||
|
lastAccess time.Time
|
||||||
|
mu sync.RWMutex // Protects lastAccess
|
||||||
|
}
|
||||||
|
|
||||||
|
// Directory represents a virtual directory in the torrent filesystem
|
||||||
|
type Directory struct {
|
||||||
|
fs *Fs
|
||||||
|
remote string
|
||||||
|
modTime time.Time
|
||||||
|
size int64
|
||||||
|
items int64
|
||||||
|
}
|
||||||
|
|
||||||
|
// Common directory interface implementations
|
||||||
|
func (d *Directory) String() string { return d.remote }
|
||||||
|
func (d *Directory) Remote() string { return d.remote }
|
||||||
|
func (d *Directory) ModTime(context.Context) time.Time { return d.modTime }
|
||||||
|
func (d *Directory) Size() int64 { return d.size }
|
||||||
|
func (d *Directory) Items() int64 { return d.items }
|
||||||
|
func (d *Directory) ID() string { return "torrentdir:" + d.remote }
|
||||||
|
func (d *Directory) SetID(string) {}
|
||||||
|
func (d *Directory) Fs() fs.Info { return d.fs }
|
||||||
|
|
||||||
|
// Standard Fs interface implementations
|
||||||
|
func (f *Fs) Name() string { return f.name }
|
||||||
|
func (f *Fs) Root() string { return f.root }
|
||||||
|
func (f *Fs) String() string { return fmt.Sprintf("torrent root '%s'", f.root) }
|
||||||
|
func (f *Fs) Features() *fs.Features { return f.features }
|
||||||
|
func (f *Fs) Precision() time.Duration { return time.Second }
|
||||||
|
func (f *Fs) Hashes() hash.Set { return hash.Set(hash.None) }
|
||||||
|
|
||||||
|
// Read-only operation errors
|
||||||
|
func (f *Fs) Put(ctx context.Context, in io.Reader, src fs.ObjectInfo, options ...fs.OpenOption) (fs.Object, error) {
|
||||||
|
return nil, fs.ErrorPermissionDenied
|
||||||
|
}
|
||||||
|
func (f *Fs) Copy(ctx context.Context, src fs.Object, remote string) (fs.Object, error) {
|
||||||
|
return nil, fs.ErrorPermissionDenied
|
||||||
|
}
|
||||||
|
func (f *Fs) Move(ctx context.Context, src fs.Object, remote string) (fs.Object, error) {
|
||||||
|
return nil, fs.ErrorPermissionDenied
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pass-through operations to base filesystem
|
||||||
|
func (f *Fs) Mkdir(ctx context.Context, dir string) error { return f.baseFs.Mkdir(ctx, dir) }
|
||||||
|
func (f *Fs) Rmdir(ctx context.Context, dir string) error { return f.baseFs.Rmdir(ctx, dir) }
|
||||||
|
|
||||||
|
// DirMove handles directory movement in the base filesystem
|
||||||
|
func (f *Fs) DirMove(ctx context.Context, src fs.Fs, srcRemote, dstRemote string) error {
|
||||||
|
srcFs, ok := src.(*Fs)
|
||||||
|
if !ok {
|
||||||
|
fs.Debugf(srcRemote, "Can't move directory - not same remote type")
|
||||||
|
return fs.ErrorCantDirMove
|
||||||
|
}
|
||||||
|
return srcFs.baseFs.Features().DirMove(ctx, srcFs.baseFs, srcRemote, dstRemote)
|
||||||
|
}
|
||||||
|
|
||||||
|
// getTorrent loads or retrieves a torrent and updates its access time
|
||||||
|
func (f *Fs) getTorrent(path string) (*torrent.Torrent, error) {
|
||||||
|
// Try to get existing torrent
|
||||||
|
if v, ok := f.activeTorrents.Load(path); ok {
|
||||||
|
info := v.(*torrentInfo)
|
||||||
|
info.mu.Lock()
|
||||||
|
info.lastAccess = time.Now()
|
||||||
|
info.mu.Unlock()
|
||||||
|
return info.torrent, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
fs.Debugf(nil, "Loading new torrent: %s", path)
|
||||||
|
|
||||||
|
// Load new torrent
|
||||||
|
t, err := f.client.AddTorrentFromFile(path)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to load torrent: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add standard public trackers
|
||||||
|
t.AddTrackers([][]string{
|
||||||
|
{"udp://tracker.opentrackr.org:1337/announce"},
|
||||||
|
{"udp://tracker.openbittorrent.com:6969/announce"},
|
||||||
|
{"udp://exodus.desync.com:6969/announce"},
|
||||||
|
{"udp://tracker.torrent.eu.org:451/announce"},
|
||||||
|
})
|
||||||
|
|
||||||
|
// Wait for metadata with timeout
|
||||||
|
select {
|
||||||
|
case <-t.GotInfo():
|
||||||
|
fs.Debugf(nil, "Got torrent metadata for: %s", t.Name())
|
||||||
|
case <-time.After(defaultHandshakeTimeout):
|
||||||
|
t.Drop()
|
||||||
|
return nil, fmt.Errorf("timeout waiting for torrent metadata")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Log torrent details
|
||||||
|
fs.Debugf(nil, "Torrent loaded: %s (pieces: %d, length: %d, trackers: %d)",
|
||||||
|
t.Name(), t.Info().NumPieces(), t.Length(), len(t.Metainfo().AnnounceList))
|
||||||
|
|
||||||
|
// Start downloading
|
||||||
|
t.DownloadAll()
|
||||||
|
|
||||||
|
// Store in active torrents map
|
||||||
|
info := &torrentInfo{
|
||||||
|
torrent: t,
|
||||||
|
lastAccess: time.Now(),
|
||||||
|
}
|
||||||
|
f.activeTorrents.Store(path, info)
|
||||||
|
|
||||||
|
// Start cleanup if enabled
|
||||||
|
if f.opt.CleanupTimeout > 0 {
|
||||||
|
go f.cleanupTorrent(path)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Start peer stats monitoring
|
||||||
|
go f.monitorPeerStats(t, path)
|
||||||
|
|
||||||
|
return t, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// monitorPeerStats periodically logs peer statistics
|
||||||
|
func (f *Fs) monitorPeerStats(t *torrent.Torrent, path string) {
|
||||||
|
ticker := time.NewTicker(5 * time.Second)
|
||||||
|
defer ticker.Stop()
|
||||||
|
|
||||||
|
for range ticker.C {
|
||||||
|
if _, exists := f.activeTorrents.Load(path); !exists {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
stats := t.Stats()
|
||||||
|
fs.Debugf(nil, "Peer stats for %s - Active: %d, Total: %d, Pending: %d",
|
||||||
|
t.Name(), stats.ActivePeers, stats.TotalPeers, stats.PendingPeers)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// cleanupTorrent monitors torrent activity and removes inactive ones
|
||||||
|
func (f *Fs) cleanupTorrent(path string) {
|
||||||
|
if f.opt.CleanupTimeout <= 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
ticker := time.NewTicker(time.Minute)
|
||||||
|
defer ticker.Stop()
|
||||||
|
|
||||||
|
timeout := time.Duration(f.opt.CleanupTimeout) * time.Minute
|
||||||
|
|
||||||
|
for range ticker.C {
|
||||||
|
v, ok := f.activeTorrents.Load(path)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
info := v.(*torrentInfo)
|
||||||
|
info.mu.RLock()
|
||||||
|
inactive := time.Since(info.lastAccess) > timeout
|
||||||
|
info.mu.RUnlock()
|
||||||
|
|
||||||
|
if inactive {
|
||||||
|
if v, ok := f.activeTorrents.LoadAndDelete(path); ok {
|
||||||
|
info := v.(*torrentInfo)
|
||||||
|
info.torrent.Drop()
|
||||||
|
fs.Debugf(nil, "Dropped inactive torrent: %s", path)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// List implements directory listing with virtual torrent directories
|
||||||
|
func (f *Fs) List(ctx context.Context, dir string) (entries fs.DirEntries, err error) {
|
||||||
|
fs.Debugf(dir, "Listing directory")
|
||||||
|
|
||||||
|
// Get base directory contents
|
||||||
|
baseEntries, err := f.baseFs.List(ctx, dir)
|
||||||
|
if err != nil {
|
||||||
|
// Check if it's a virtual torrent directory
|
||||||
|
if torrentPath, isVirtual := f.findTorrentForPath(dir); isVirtual {
|
||||||
|
fs.Debugf(dir, "Found torrent file: %s", torrentPath)
|
||||||
|
return f.listTorrentContents(ctx, torrentPath, dir)
|
||||||
|
}
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Track seen names to avoid duplicates
|
||||||
|
seen := make(map[string]bool)
|
||||||
|
entries = make(fs.DirEntries, 0, len(baseEntries))
|
||||||
|
|
||||||
|
// Add regular non-torrent entries
|
||||||
|
for _, entry := range baseEntries {
|
||||||
|
name := entry.Remote()
|
||||||
|
if !isTorrentFile(name) {
|
||||||
|
entries = append(entries, entry)
|
||||||
|
seen[name] = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add virtual torrent directories
|
||||||
|
for _, entry := range baseEntries {
|
||||||
|
if o, ok := entry.(fs.Object); ok && isTorrentFile(o.Remote()) {
|
||||||
|
virtualName := strings.TrimSuffix(o.Remote(), filepath.Ext(o.Remote()))
|
||||||
|
if !seen[virtualName] {
|
||||||
|
if info, modTime, err := f.getTorrentInfo(o.Remote()); err == nil {
|
||||||
|
size, items := f.getTorrentSize(info)
|
||||||
|
entries = append(entries, &Directory{
|
||||||
|
fs: f,
|
||||||
|
remote: virtualName,
|
||||||
|
modTime: modTime,
|
||||||
|
size: size,
|
||||||
|
items: items,
|
||||||
|
})
|
||||||
|
seen[virtualName] = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fs.Debugf(dir, "Listed %d entries", len(entries))
|
||||||
|
return entries, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// findTorrentForPath locates the .torrent file for a given path
|
||||||
|
func (f *Fs) findTorrentForPath(path string) (string, bool) {
|
||||||
|
if path == "" {
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
|
||||||
|
current := path
|
||||||
|
for {
|
||||||
|
torrentPath := filepath.Join(f.opt.RootDirectory, current+".torrent")
|
||||||
|
if _, err := os.Stat(torrentPath); err == nil {
|
||||||
|
fs.Debugf(path, "Found torrent at: %s", torrentPath)
|
||||||
|
return torrentPath, true
|
||||||
|
}
|
||||||
|
|
||||||
|
parent := filepath.Dir(current)
|
||||||
|
if parent == "." || parent == current {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
current = parent
|
||||||
|
}
|
||||||
|
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
|
||||||
|
// getTorrentInfo reads and parses a torrent file's metadata
|
||||||
|
func (f *Fs) getTorrentInfo(path string) (*metainfo.Info, time.Time, error) {
|
||||||
|
absPath := path
|
||||||
|
if !filepath.IsAbs(path) {
|
||||||
|
absPath = filepath.Join(f.opt.RootDirectory, path)
|
||||||
|
}
|
||||||
|
|
||||||
|
mi, err := metainfo.LoadFromFile(absPath)
|
||||||
|
if err != nil {
|
||||||
|
return nil, time.Time{}, fmt.Errorf("failed to read torrent info: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
info, err := mi.UnmarshalInfo()
|
||||||
|
if err != nil {
|
||||||
|
return nil, time.Time{}, fmt.Errorf("failed to unmarshal torrent info: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
stat, err := os.Stat(absPath)
|
||||||
|
if err != nil {
|
||||||
|
return nil, time.Time{}, fmt.Errorf("failed to stat torrent file: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return &info, stat.ModTime(), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// listTorrentContents returns the contents of a torrent as directory entries
|
||||||
|
func (f *Fs) listTorrentContents(ctx context.Context, torrentPath, virtualPath string) (fs.DirEntries, error) {
|
||||||
|
// Get torrent info
|
||||||
|
info, modTime, err := f.getTorrentInfo(torrentPath)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to read torrent info: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Calculate paths
|
||||||
|
torrentName := strings.TrimSuffix(filepath.Base(torrentPath), ".torrent")
|
||||||
|
relTorrentDir, err := filepath.Rel(f.opt.RootDirectory, filepath.Dir(torrentPath))
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to get relative torrent dir: %w", err)
|
||||||
|
}
|
||||||
|
virtualTorrentDir := filepath.Join(relTorrentDir, torrentName)
|
||||||
|
|
||||||
|
// Get relative path within torrent
|
||||||
|
var internalPath string
|
||||||
|
switch {
|
||||||
|
case virtualPath == virtualTorrentDir:
|
||||||
|
internalPath = ""
|
||||||
|
case strings.HasPrefix(virtualPath, virtualTorrentDir+string(filepath.Separator)):
|
||||||
|
internalPath = strings.TrimPrefix(virtualPath, virtualTorrentDir+string(filepath.Separator))
|
||||||
|
default:
|
||||||
|
return nil, fmt.Errorf("path %q is not within torrent directory %q", virtualPath, virtualTorrentDir)
|
||||||
|
}
|
||||||
|
|
||||||
|
entries := make(fs.DirEntries, 0)
|
||||||
|
seen := make(map[string]bool)
|
||||||
|
|
||||||
|
// Handle single file torrents
|
||||||
|
if len(info.Files) == 0 {
|
||||||
|
if internalPath == "" {
|
||||||
|
entries = append(entries, &Object{
|
||||||
|
fs: f,
|
||||||
|
virtualPath: filepath.Join(virtualPath, info.Name),
|
||||||
|
torrentPath: info.Name,
|
||||||
|
size: info.Length,
|
||||||
|
modTime: modTime,
|
||||||
|
sourcePath: torrentPath,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return entries, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle multi-file torrents efficiently
|
||||||
|
prefix := ""
|
||||||
|
if internalPath != "" {
|
||||||
|
prefix = internalPath + string(filepath.Separator)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Use map to track directory sizes
|
||||||
|
dirSizes := make(map[string]int64)
|
||||||
|
dirItems := make(map[string]int64)
|
||||||
|
|
||||||
|
// Process all files in a single pass
|
||||||
|
for _, file := range info.Files {
|
||||||
|
filePath := filepath.Join(file.Path...)
|
||||||
|
|
||||||
|
// Skip files not in current directory
|
||||||
|
if !strings.HasPrefix(filePath, prefix) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get path relative to current directory
|
||||||
|
relPath := strings.TrimPrefix(filePath, prefix)
|
||||||
|
if relPath == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// Split into components
|
||||||
|
components := strings.Split(relPath, string(filepath.Separator))
|
||||||
|
firstComponent := components[0]
|
||||||
|
|
||||||
|
if len(components) == 1 {
|
||||||
|
// File in current directory
|
||||||
|
entries = append(entries, &Object{
|
||||||
|
fs: f,
|
||||||
|
virtualPath: filepath.Join(virtualPath, firstComponent),
|
||||||
|
torrentPath: filePath,
|
||||||
|
size: file.Length,
|
||||||
|
modTime: modTime,
|
||||||
|
sourcePath: torrentPath,
|
||||||
|
})
|
||||||
|
} else if !seen[firstComponent] {
|
||||||
|
// Directory - accumulate sizes
|
||||||
|
currentPath := firstComponent
|
||||||
|
for i := 0; i < len(components)-1; i++ {
|
||||||
|
dirSizes[currentPath] += file.Length
|
||||||
|
dirItems[currentPath]++
|
||||||
|
if i < len(components)-2 {
|
||||||
|
currentPath = filepath.Join(currentPath, components[i+1])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add directory entry if not already seen
|
||||||
|
entries = append(entries, &Directory{
|
||||||
|
fs: f,
|
||||||
|
remote: filepath.Join(virtualPath, firstComponent),
|
||||||
|
modTime: modTime,
|
||||||
|
size: dirSizes[firstComponent],
|
||||||
|
items: dirItems[firstComponent],
|
||||||
|
})
|
||||||
|
seen[firstComponent] = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fs.Debugf(virtualPath, "Listed %d entries", len(entries))
|
||||||
|
return entries, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// getTorrentSize returns total size and item count for a torrent
|
||||||
|
func (f *Fs) getTorrentSize(info *metainfo.Info) (size, items int64) {
|
||||||
|
if len(info.Files) == 0 {
|
||||||
|
return info.Length, 1
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, file := range info.Files {
|
||||||
|
size += file.Length
|
||||||
|
items++
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// isTorrentFile checks if a file is a torrent based on extension
|
||||||
|
func isTorrentFile(path string) bool {
|
||||||
|
return strings.ToLower(filepath.Ext(path)) == ".torrent"
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewObject finds an Object at the given remote path
|
||||||
|
func (f *Fs) NewObject(ctx context.Context, remote string) (fs.Object, error) {
|
||||||
|
// Try regular filesystem first
|
||||||
|
obj, err := f.baseFs.NewObject(ctx, remote)
|
||||||
|
if err == nil && !isTorrentFile(remote) {
|
||||||
|
return obj, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Look for the file in a torrent
|
||||||
|
if torrentPath, isVirtual := f.findTorrentForPath(remote); isVirtual {
|
||||||
|
info, modTime, err := f.getTorrentInfo(torrentPath)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Calculate relative path
|
||||||
|
torrentRoot := strings.TrimSuffix(filepath.Base(torrentPath), ".torrent")
|
||||||
|
relPath, err := filepath.Rel(torrentRoot, remote)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fs.ErrorObjectNotFound
|
||||||
|
}
|
||||||
|
|
||||||
|
// Find file in torrent
|
||||||
|
switch {
|
||||||
|
case len(info.Files) == 0:
|
||||||
|
// Single file torrent
|
||||||
|
if relPath == info.Name {
|
||||||
|
return &Object{
|
||||||
|
fs: f,
|
||||||
|
virtualPath: remote,
|
||||||
|
torrentPath: info.Name,
|
||||||
|
size: info.Length,
|
||||||
|
modTime: modTime,
|
||||||
|
sourcePath: torrentPath,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
// Multi-file torrent - look for exact match
|
||||||
|
for _, file := range info.Files {
|
||||||
|
if relPath == filepath.Join(file.Path...) {
|
||||||
|
return &Object{
|
||||||
|
fs: f,
|
||||||
|
virtualPath: remote,
|
||||||
|
torrentPath: filepath.Join(file.Path...),
|
||||||
|
size: file.Length,
|
||||||
|
modTime: modTime,
|
||||||
|
sourcePath: torrentPath,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil, fs.ErrorObjectNotFound
|
||||||
|
}
|
||||||
|
|
||||||
|
// getCacheDir determines the directory for storing downloaded data
|
||||||
|
func getCacheDir(opt Options) string {
|
||||||
|
if opt.CacheDir != "" {
|
||||||
|
return opt.CacheDir
|
||||||
|
}
|
||||||
|
return filepath.Join(os.TempDir(), "rclone-torrent-cache")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Shutdown cleanly shuts down the filesystem
|
||||||
|
func (f *Fs) Shutdown(ctx context.Context) error {
|
||||||
|
// Drop all active torrents
|
||||||
|
f.activeTorrents.Range(func(key, value interface{}) bool {
|
||||||
|
if info, ok := value.(*torrentInfo); ok {
|
||||||
|
info.mu.Lock()
|
||||||
|
if info.torrent != nil {
|
||||||
|
info.torrent.Drop()
|
||||||
|
}
|
||||||
|
info.mu.Unlock()
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
})
|
||||||
|
|
||||||
|
// Close torrent client
|
||||||
|
if f.client != nil {
|
||||||
|
f.client.Close()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Shutdown base filesystem if supported
|
||||||
|
if shutdowner, ok := f.baseFs.(fs.Shutdowner); ok {
|
||||||
|
return shutdowner.Shutdown(ctx)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewFs creates a new Fs instance
|
||||||
|
func NewFs(ctx context.Context, name, root string, m configmap.Mapper) (fs.Fs, error) {
|
||||||
|
// Parse config
|
||||||
|
opt := new(Options)
|
||||||
|
err := configstruct.Set(m, opt)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Configure torrent client
|
||||||
|
cfg := torrent.NewDefaultClientConfig()
|
||||||
|
cfg.DataDir = getCacheDir(*opt)
|
||||||
|
|
||||||
|
// Set bandwidth limits
|
||||||
|
if opt.MaxDownloadSpeed > 0 {
|
||||||
|
cfg.DownloadRateLimiter = rate.NewLimiter(rate.Limit(opt.MaxDownloadSpeed*1024), 256*1024)
|
||||||
|
}
|
||||||
|
if opt.MaxUploadSpeed > 0 {
|
||||||
|
cfg.UploadRateLimiter = rate.NewLimiter(rate.Limit(opt.MaxUploadSpeed*1024), 256*1024)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Configure for read-only operation
|
||||||
|
cfg.NoUpload = true
|
||||||
|
cfg.Seed = false
|
||||||
|
|
||||||
|
// Network settings
|
||||||
|
cfg.HandshakesTimeout = defaultHandshakeTimeout
|
||||||
|
cfg.HalfOpenConnsPerTorrent = defaultPendingPeers
|
||||||
|
cfg.EstablishedConnsPerTorrent = maxConnectionsPerTorrent
|
||||||
|
cfg.DisableUTP = false
|
||||||
|
cfg.DisableTCP = false
|
||||||
|
cfg.NoDHT = false
|
||||||
|
cfg.DisableIPv6 = false
|
||||||
|
|
||||||
|
// Create torrent client
|
||||||
|
client, err := torrent.NewClient(cfg)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to create torrent client: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create base filesystem
|
||||||
|
baseFs, err := fs.NewFs(ctx, opt.RootDirectory)
|
||||||
|
if err != nil {
|
||||||
|
client.Close()
|
||||||
|
return nil, fmt.Errorf("failed to create base filesystem: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set up features
|
||||||
|
features := &fs.Features{
|
||||||
|
CaseInsensitive: false,
|
||||||
|
DuplicateFiles: false,
|
||||||
|
ReadMimeType: false,
|
||||||
|
WriteMimeType: false,
|
||||||
|
CanHaveEmptyDirectories: true,
|
||||||
|
BucketBased: false,
|
||||||
|
BucketBasedRootOK: false,
|
||||||
|
SetTier: false,
|
||||||
|
GetTier: false,
|
||||||
|
}
|
||||||
|
|
||||||
|
return &Fs{
|
||||||
|
name: name,
|
||||||
|
root: root,
|
||||||
|
opt: *opt,
|
||||||
|
client: client,
|
||||||
|
features: features,
|
||||||
|
baseFs: baseFs,
|
||||||
|
}, nil
|
||||||
|
}
|
Loading…
Reference in New Issue
Block a user