Search, inspect, stream, and download torrents from the terminal. Replaces the entire *arr stack with a single binary.
316 lines
7.1 KiB
Go
316 lines
7.1 KiB
Go
package engine
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
|
|
alog "github.com/anacrolix/log"
|
|
"github.com/anacrolix/torrent"
|
|
)
|
|
|
|
|
|
|
|
// StreamConfig holds settings for the streaming engine.
|
|
type StreamConfig struct {
|
|
DataDir string
|
|
Port int
|
|
BufferBytes int64
|
|
MetaTimeout time.Duration
|
|
NoOpen bool
|
|
PlayerCmd string
|
|
}
|
|
|
|
// StreamStatus represents the current state of the streaming session.
|
|
type StreamStatus int
|
|
|
|
const (
|
|
StreamStatusMetadata StreamStatus = iota
|
|
StreamStatusBuffering
|
|
StreamStatusReady
|
|
StreamStatusError
|
|
)
|
|
|
|
// StreamProgress is a snapshot of current streaming stats.
|
|
type StreamProgress struct {
|
|
Status StreamStatus
|
|
DownloadedBytes int64
|
|
TotalBytes int64
|
|
SpeedBps int64
|
|
Peers int
|
|
Seeds int
|
|
FileName string
|
|
}
|
|
|
|
// StreamEngine manages a single streaming torrent session.
|
|
type StreamEngine struct {
|
|
client *torrent.Client
|
|
cfg StreamConfig
|
|
tor *torrent.Torrent
|
|
file *torrent.File
|
|
|
|
bufferTarget int64
|
|
totalBytes int64
|
|
fileName string
|
|
|
|
mu sync.RWMutex
|
|
status StreamStatus
|
|
lastBytes int64
|
|
lastTime time.Time
|
|
speedBps int64
|
|
}
|
|
|
|
// NewStreamEngine creates a streaming engine with its own torrent client.
|
|
func NewStreamEngine(cfg StreamConfig) (*StreamEngine, error) {
|
|
if cfg.MetaTimeout == 0 {
|
|
cfg.MetaTimeout = 60 * time.Second
|
|
}
|
|
|
|
if err := os.MkdirAll(cfg.DataDir, 0o755); err != nil {
|
|
return nil, fmt.Errorf("create data dir: %w", err)
|
|
}
|
|
|
|
tcfg := torrent.NewDefaultClientConfig()
|
|
tcfg.DataDir = cfg.DataDir
|
|
tcfg.Seed = false
|
|
tcfg.NoUpload = true
|
|
tcfg.ListenPort = 0
|
|
tcfg.Logger = alog.Default.FilterLevel(alog.Disabled)
|
|
|
|
client, err := torrent.NewClient(tcfg)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("create torrent client: %w", err)
|
|
}
|
|
|
|
return &StreamEngine{
|
|
client: client,
|
|
cfg: cfg,
|
|
status: StreamStatusMetadata,
|
|
}, nil
|
|
}
|
|
|
|
// Start adds the torrent, waits for metadata, selects the video file,
|
|
// and prepares for streaming.
|
|
func (s *StreamEngine) Start(ctx context.Context, magnetOrHash string) error {
|
|
magnet := magnetOrHash
|
|
if !strings.HasPrefix(magnet, "magnet:") {
|
|
magnet = buildMagnet(strings.TrimSpace(magnetOrHash))
|
|
}
|
|
|
|
t, err := s.client.AddMagnet(magnet)
|
|
if err != nil {
|
|
return fmt.Errorf("add magnet: %w", err)
|
|
}
|
|
s.tor = t
|
|
|
|
metaCtx, metaCancel := context.WithTimeout(ctx, s.cfg.MetaTimeout)
|
|
defer metaCancel()
|
|
|
|
select {
|
|
case <-t.GotInfo():
|
|
case <-metaCtx.Done():
|
|
return fmt.Errorf("metadata timeout after %s: no peers found", s.cfg.MetaTimeout)
|
|
}
|
|
|
|
if err := s.selectFile(); err != nil {
|
|
return err
|
|
}
|
|
|
|
s.totalBytes = s.file.Length()
|
|
s.fileName = filepath.Base(s.file.DisplayPath())
|
|
s.bufferTarget = s.calculateBufferTarget()
|
|
s.lastTime = time.Now()
|
|
|
|
s.mu.Lock()
|
|
s.status = StreamStatusBuffering
|
|
s.mu.Unlock()
|
|
|
|
return nil
|
|
}
|
|
|
|
// selectFile picks the best video file from the torrent.
|
|
// Falls back to the largest file if no video is found.
|
|
func (s *StreamEngine) selectFile() error {
|
|
files := s.tor.Files()
|
|
if len(files) == 0 {
|
|
return fmt.Errorf("torrent has no files")
|
|
}
|
|
|
|
var bestVideo *torrent.File
|
|
var bestAny *torrent.File
|
|
|
|
for _, f := range files {
|
|
ext := strings.ToLower(filepath.Ext(f.DisplayPath()))
|
|
if VideoExts[ext] {
|
|
if bestVideo == nil || f.Length() > bestVideo.Length() {
|
|
bestVideo = f
|
|
}
|
|
}
|
|
if bestAny == nil || f.Length() > bestAny.Length() {
|
|
bestAny = f
|
|
}
|
|
}
|
|
|
|
if bestVideo != nil {
|
|
s.file = bestVideo
|
|
} else {
|
|
s.file = bestAny
|
|
}
|
|
|
|
// Cancel all other files, download only the selected one
|
|
for _, f := range files {
|
|
if f == s.file {
|
|
f.Download()
|
|
} else {
|
|
f.SetPriority(torrent.PiecePriorityNone)
|
|
}
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// IsVideoFile returns true if the selected file has a video extension.
|
|
func (s *StreamEngine) IsVideoFile() bool {
|
|
ext := strings.ToLower(filepath.Ext(s.fileName))
|
|
return VideoExts[ext]
|
|
}
|
|
|
|
func (s *StreamEngine) calculateBufferTarget() int64 {
|
|
if s.cfg.BufferBytes > 0 {
|
|
return s.cfg.BufferBytes
|
|
}
|
|
fivePercent := s.totalBytes / 20
|
|
tenMB := int64(10 * 1024 * 1024)
|
|
if fivePercent < tenMB {
|
|
return fivePercent
|
|
}
|
|
return tenMB
|
|
}
|
|
|
|
// contiguousBytes returns the number of bytes completed contiguously
|
|
// from the start of the file.
|
|
func (s *StreamEngine) contiguousBytes() int64 {
|
|
states := s.file.State()
|
|
var total int64
|
|
for _, ps := range states {
|
|
if ps.Complete {
|
|
total += ps.Bytes
|
|
} else {
|
|
break
|
|
}
|
|
}
|
|
return total
|
|
}
|
|
|
|
// WaitBuffer blocks until enough contiguous bytes from the file start
|
|
// are downloaded, or the context is cancelled.
|
|
func (s *StreamEngine) WaitBuffer(ctx context.Context, progressFn func(buffered, target int64)) error {
|
|
ticker := time.NewTicker(500 * time.Millisecond)
|
|
defer ticker.Stop()
|
|
|
|
for {
|
|
select {
|
|
case <-ctx.Done():
|
|
return ctx.Err()
|
|
case <-ticker.C:
|
|
buffered := s.contiguousBytes()
|
|
if progressFn != nil {
|
|
progressFn(buffered, s.bufferTarget)
|
|
}
|
|
if buffered >= s.bufferTarget {
|
|
s.mu.Lock()
|
|
s.status = StreamStatusReady
|
|
s.mu.Unlock()
|
|
return nil
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// NewFileReader creates a new reader for the selected file.
|
|
// Each HTTP request should get its own reader (not safe for concurrent use).
|
|
func (s *StreamEngine) NewFileReader(ctx context.Context) torrent.Reader {
|
|
reader := s.file.NewReader()
|
|
reader.SetResponsive()
|
|
reader.SetReadahead(5 * 1024 * 1024) // 5MB readahead
|
|
reader.SetContext(ctx)
|
|
return reader
|
|
}
|
|
|
|
// StartProgressLoop starts a goroutine that updates speed/peer stats every second.
|
|
// It stops when the context is cancelled.
|
|
func (s *StreamEngine) StartProgressLoop(ctx context.Context) {
|
|
go func() {
|
|
ticker := time.NewTicker(1 * time.Second)
|
|
defer ticker.Stop()
|
|
|
|
for {
|
|
select {
|
|
case <-ctx.Done():
|
|
return
|
|
case <-ticker.C:
|
|
now := time.Now()
|
|
downloaded := s.file.BytesCompleted()
|
|
|
|
s.mu.Lock()
|
|
elapsed := now.Sub(s.lastTime).Seconds()
|
|
if elapsed > 0 {
|
|
s.speedBps = int64(float64(downloaded-s.lastBytes) / elapsed)
|
|
if s.speedBps < 0 {
|
|
s.speedBps = 0
|
|
}
|
|
}
|
|
s.lastBytes = downloaded
|
|
s.lastTime = now
|
|
s.mu.Unlock()
|
|
}
|
|
}
|
|
}()
|
|
}
|
|
|
|
// Progress returns a snapshot of the current streaming stats.
|
|
func (s *StreamEngine) Progress() StreamProgress {
|
|
s.mu.RLock()
|
|
status := s.status
|
|
speed := s.speedBps
|
|
s.mu.RUnlock()
|
|
|
|
stats := s.tor.Stats()
|
|
|
|
return StreamProgress{
|
|
Status: status,
|
|
DownloadedBytes: s.file.BytesCompleted(),
|
|
TotalBytes: s.totalBytes,
|
|
SpeedBps: speed,
|
|
Peers: stats.ActivePeers,
|
|
Seeds: stats.ConnectedSeeders,
|
|
FileName: s.fileName,
|
|
}
|
|
}
|
|
|
|
// FileName returns the name of the selected file.
|
|
func (s *StreamEngine) FileName() string { return s.fileName }
|
|
|
|
// FileLength returns the total size of the selected file in bytes.
|
|
func (s *StreamEngine) FileLength() int64 { return s.totalBytes }
|
|
|
|
// BufferTarget returns the buffer threshold in bytes.
|
|
func (s *StreamEngine) BufferTarget() int64 { return s.bufferTarget }
|
|
|
|
// Shutdown gracefully closes the torrent and client.
|
|
func (s *StreamEngine) Shutdown(_ context.Context) error {
|
|
if s.tor != nil {
|
|
s.tor.Drop()
|
|
}
|
|
if s.client != nil {
|
|
errs := s.client.Close()
|
|
if len(errs) > 0 {
|
|
return fmt.Errorf("close client: %v", errs[0])
|
|
}
|
|
}
|
|
return nil
|
|
}
|