unarr/internal/engine/stream.go
Deivid Soto 29cf0a0126 feat: initial commit — unarr CLI
Search, inspect, stream, and download torrents from the terminal.
Replaces the entire *arr stack with a single binary.
2026-03-28 11:29:42 +01:00

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
}