490 lines
15 KiB
Go
490 lines
15 KiB
Go
package torrentstream
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"net/http"
|
|
"net/url"
|
|
"os"
|
|
"path"
|
|
"seanime/internal/mediaplayers/mediaplayer"
|
|
"seanime/internal/util"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
|
|
alog "github.com/anacrolix/log"
|
|
"github.com/anacrolix/torrent"
|
|
"github.com/anacrolix/torrent/storage"
|
|
"github.com/samber/mo"
|
|
"golang.org/x/time/rate"
|
|
)
|
|
|
|
type (
|
|
Client struct {
|
|
repository *Repository
|
|
|
|
torrentClient mo.Option[*torrent.Client]
|
|
currentTorrent mo.Option[*torrent.Torrent]
|
|
currentFile mo.Option[*torrent.File]
|
|
currentTorrentStatus TorrentStatus
|
|
cancelFunc context.CancelFunc
|
|
|
|
mu sync.Mutex
|
|
stopCh chan struct{} // Closed when the media player stops
|
|
mediaPlayerPlaybackStatusCh chan *mediaplayer.PlaybackStatus // Continuously receives playback status
|
|
timeSinceLoggedSeeding time.Time
|
|
lastSpeedCheck time.Time // Track the last time we checked speeds
|
|
lastBytesCompleted int64 // Track the last bytes completed
|
|
lastBytesWrittenData int64 // Track the last bytes written data
|
|
}
|
|
|
|
TorrentStatus struct {
|
|
UploadProgress int64 `json:"uploadProgress"`
|
|
DownloadProgress int64 `json:"downloadProgress"`
|
|
ProgressPercentage float64 `json:"progressPercentage"`
|
|
DownloadSpeed string `json:"downloadSpeed"`
|
|
UploadSpeed string `json:"uploadSpeed"`
|
|
Size string `json:"size"`
|
|
Seeders int `json:"seeders"`
|
|
}
|
|
|
|
NewClientOptions struct {
|
|
Repository *Repository
|
|
}
|
|
)
|
|
|
|
func NewClient(repository *Repository) *Client {
|
|
ret := &Client{
|
|
repository: repository,
|
|
torrentClient: mo.None[*torrent.Client](),
|
|
currentFile: mo.None[*torrent.File](),
|
|
currentTorrent: mo.None[*torrent.Torrent](),
|
|
stopCh: make(chan struct{}),
|
|
mediaPlayerPlaybackStatusCh: make(chan *mediaplayer.PlaybackStatus, 1),
|
|
}
|
|
|
|
return ret
|
|
}
|
|
|
|
// initializeClient will create and torrent client.
|
|
// The client is designed to support only one torrent at a time, and seed it.
|
|
// Upon initialization, the client will drop all torrents.
|
|
func (c *Client) initializeClient() error {
|
|
// Fail if no settings
|
|
if err := c.repository.FailIfNoSettings(); err != nil {
|
|
return err
|
|
}
|
|
|
|
// Cancel the previous context, terminating the goroutine if it's running
|
|
if c.cancelFunc != nil {
|
|
c.cancelFunc()
|
|
}
|
|
|
|
// Context for the client's goroutine
|
|
var ctx context.Context
|
|
ctx, c.cancelFunc = context.WithCancel(context.Background())
|
|
|
|
// Get the settings
|
|
settings := c.repository.settings.MustGet()
|
|
|
|
// Define torrent client settings
|
|
cfg := torrent.NewDefaultClientConfig()
|
|
cfg.Seed = true
|
|
cfg.DisableIPv6 = settings.DisableIPV6
|
|
cfg.Logger = alog.Logger{}
|
|
|
|
// TEST ONLY: Limit download speed to 1mb/s
|
|
// cfg.DownloadRateLimiter = rate.NewLimiter(rate.Limit(1<<20), 1<<20)
|
|
|
|
if settings.SlowSeeding {
|
|
cfg.DialRateLimiter = rate.NewLimiter(rate.Limit(1), 1)
|
|
cfg.UploadRateLimiter = rate.NewLimiter(rate.Limit(1<<20), 2<<20)
|
|
}
|
|
|
|
if settings.TorrentClientHost != "" {
|
|
cfg.ListenHost = func(network string) string { return settings.TorrentClientHost }
|
|
}
|
|
|
|
if settings.TorrentClientPort == 0 {
|
|
settings.TorrentClientPort = 43213
|
|
}
|
|
cfg.ListenPort = settings.TorrentClientPort
|
|
// Set the download directory
|
|
// e.g. /path/to/temp/seanime/torrentstream/{infohash}
|
|
cfg.DefaultStorage = storage.NewFileByInfoHash(settings.DownloadDir)
|
|
|
|
c.mu.Lock()
|
|
// Create the torrent client
|
|
client, err := torrent.NewClient(cfg)
|
|
if err != nil {
|
|
c.mu.Unlock()
|
|
return fmt.Errorf("error creating a new torrent client: %v", err)
|
|
}
|
|
c.repository.logger.Info().Msgf("torrentstream: Initialized torrent client on port %d", settings.TorrentClientPort)
|
|
c.torrentClient = mo.Some(client)
|
|
c.dropTorrents()
|
|
c.mu.Unlock()
|
|
|
|
go func(ctx context.Context) {
|
|
|
|
for {
|
|
select {
|
|
case <-ctx.Done():
|
|
c.repository.logger.Debug().Msg("torrentstream: Context cancelled, stopping torrent client")
|
|
return
|
|
|
|
case status := <-c.mediaPlayerPlaybackStatusCh:
|
|
// DEVNOTE: When this is received, "default" case is executed right after
|
|
if status != nil && c.currentFile.IsPresent() && c.repository.playback.currentVideoDuration == 0 {
|
|
// If the stored video duration is 0 but the media player status shows a duration that is not 0
|
|
// we know that the video has been loaded and is playing
|
|
if c.repository.playback.currentVideoDuration == 0 && status.Duration > 0 {
|
|
// The media player has started playing the video
|
|
c.repository.logger.Debug().Msg("torrentstream: Media player started playing the video, sending event")
|
|
c.repository.sendStateEvent(eventTorrentStartedPlaying)
|
|
// Update the stored video duration
|
|
c.repository.playback.currentVideoDuration = status.Duration
|
|
}
|
|
}
|
|
default:
|
|
c.mu.Lock()
|
|
if c.torrentClient.IsPresent() && c.currentTorrent.IsPresent() && c.currentFile.IsPresent() {
|
|
t := c.currentTorrent.MustGet()
|
|
f := c.currentFile.MustGet()
|
|
|
|
// Get the current time
|
|
now := time.Now()
|
|
elapsed := now.Sub(c.lastSpeedCheck).Seconds()
|
|
|
|
// downloadProgress is the number of bytes downloaded
|
|
downloadProgress := t.BytesCompleted()
|
|
|
|
downloadSpeed := ""
|
|
if elapsed > 0 {
|
|
bytesPerSecond := float64(downloadProgress-c.lastBytesCompleted) / elapsed
|
|
if bytesPerSecond > 0 {
|
|
downloadSpeed = fmt.Sprintf("%s/s", util.Bytes(uint64(bytesPerSecond)))
|
|
}
|
|
}
|
|
size := util.Bytes(uint64(f.Length()))
|
|
|
|
bytesWrittenData := t.Stats().BytesWrittenData
|
|
uploadSpeed := ""
|
|
if elapsed > 0 {
|
|
bytesPerSecond := float64((&bytesWrittenData).Int64()-c.lastBytesWrittenData) / elapsed
|
|
if bytesPerSecond > 0 {
|
|
uploadSpeed = fmt.Sprintf("%s/s", util.Bytes(uint64(bytesPerSecond)))
|
|
}
|
|
}
|
|
|
|
// Update the stored values for next calculation
|
|
c.lastBytesCompleted = downloadProgress
|
|
c.lastBytesWrittenData = (&bytesWrittenData).Int64()
|
|
c.lastSpeedCheck = now
|
|
|
|
if t.PeerConns() != nil {
|
|
c.currentTorrentStatus.Seeders = len(t.PeerConns())
|
|
}
|
|
|
|
c.currentTorrentStatus = TorrentStatus{
|
|
Size: size,
|
|
UploadProgress: (&bytesWrittenData).Int64() - c.currentTorrentStatus.UploadProgress,
|
|
DownloadSpeed: downloadSpeed,
|
|
UploadSpeed: uploadSpeed,
|
|
DownloadProgress: downloadProgress,
|
|
ProgressPercentage: c.getTorrentPercentage(c.currentTorrent, c.currentFile),
|
|
Seeders: t.Stats().ConnectedSeeders,
|
|
}
|
|
c.repository.sendStateEvent(eventTorrentStatus, c.currentTorrentStatus)
|
|
// Always log the progress so the user knows what's happening
|
|
c.repository.logger.Trace().Msgf("torrentstream: Progress: %.2f%%, Download speed: %s, Upload speed: %s, Size: %s",
|
|
c.currentTorrentStatus.ProgressPercentage,
|
|
c.currentTorrentStatus.DownloadSpeed,
|
|
c.currentTorrentStatus.UploadSpeed,
|
|
c.currentTorrentStatus.Size)
|
|
c.timeSinceLoggedSeeding = time.Now()
|
|
}
|
|
c.mu.Unlock()
|
|
if c.torrentClient.IsPresent() {
|
|
if time.Since(c.timeSinceLoggedSeeding) > 20*time.Second {
|
|
c.timeSinceLoggedSeeding = time.Now()
|
|
for _, t := range c.torrentClient.MustGet().Torrents() {
|
|
if t.Seeding() {
|
|
c.repository.logger.Trace().Msgf("torrentstream: Seeding last torrent, %d peers", t.Stats().ActivePeers)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
time.Sleep(3 * time.Second)
|
|
}
|
|
}
|
|
}(ctx)
|
|
|
|
return nil
|
|
}
|
|
|
|
func (c *Client) GetStreamingUrl() string {
|
|
if c.torrentClient.IsAbsent() {
|
|
return ""
|
|
}
|
|
if c.currentFile.IsAbsent() {
|
|
return ""
|
|
}
|
|
settings, ok := c.repository.settings.Get()
|
|
if !ok {
|
|
return ""
|
|
}
|
|
|
|
host := settings.Host
|
|
if host == "0.0.0.0" {
|
|
host = "127.0.0.1"
|
|
}
|
|
address := fmt.Sprintf("%s:%d", host, settings.Port)
|
|
if settings.StreamUrlAddress != "" {
|
|
address = settings.StreamUrlAddress
|
|
}
|
|
ret := fmt.Sprintf("http://%s/api/v1/torrentstream/stream/%s", address, url.PathEscape(c.currentFile.MustGet().DisplayPath()))
|
|
if strings.HasPrefix(ret, "http://http") {
|
|
ret = strings.Replace(ret, "http://http", "http", 1)
|
|
}
|
|
return ret
|
|
}
|
|
|
|
func (c *Client) GetExternalPlayerStreamingUrl() string {
|
|
if c.torrentClient.IsAbsent() {
|
|
return ""
|
|
}
|
|
if c.currentFile.IsAbsent() {
|
|
return ""
|
|
}
|
|
|
|
ret := fmt.Sprintf("{{SCHEME}}://{{HOST}}/api/v1/torrentstream/stream/%s", url.PathEscape(c.currentFile.MustGet().DisplayPath()))
|
|
|
|
return ret
|
|
}
|
|
|
|
func (c *Client) AddTorrent(id string) (*torrent.Torrent, error) {
|
|
if c.torrentClient.IsAbsent() {
|
|
return nil, errors.New("torrent client is not initialized")
|
|
}
|
|
|
|
// Drop all torrents
|
|
for _, t := range c.torrentClient.MustGet().Torrents() {
|
|
t.Drop()
|
|
}
|
|
|
|
if strings.HasPrefix(id, "magnet") {
|
|
return c.addTorrentMagnet(id)
|
|
}
|
|
|
|
if strings.HasPrefix(id, "http") {
|
|
return c.addTorrentFromDownloadURL(id)
|
|
}
|
|
|
|
return c.addTorrentFromFile(id)
|
|
}
|
|
|
|
func (c *Client) addTorrentMagnet(magnet string) (*torrent.Torrent, error) {
|
|
if c.torrentClient.IsAbsent() {
|
|
return nil, errors.New("torrent client is not initialized")
|
|
}
|
|
|
|
t, err := c.torrentClient.MustGet().AddMagnet(magnet)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
c.repository.logger.Trace().Msgf("torrentstream: Waiting to retrieve torrent info")
|
|
select {
|
|
case <-t.GotInfo():
|
|
break
|
|
case <-t.Closed():
|
|
//t.Drop()
|
|
return nil, errors.New("torrent closed")
|
|
case <-time.After(1 * time.Minute):
|
|
t.Drop()
|
|
return nil, errors.New("timeout waiting for torrent info")
|
|
}
|
|
c.repository.logger.Info().Msgf("torrentstream: Torrent added: %s", t.InfoHash().AsString())
|
|
return t, nil
|
|
}
|
|
|
|
func (c *Client) addTorrentFromFile(fp string) (*torrent.Torrent, error) {
|
|
if c.torrentClient.IsAbsent() {
|
|
return nil, errors.New("torrent client is not initialized")
|
|
}
|
|
|
|
t, err := c.torrentClient.MustGet().AddTorrentFromFile(fp)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
c.repository.logger.Trace().Msgf("torrentstream: Waiting to retrieve torrent info")
|
|
<-t.GotInfo()
|
|
c.repository.logger.Info().Msgf("torrentstream: Torrent added: %s", t.InfoHash().AsString())
|
|
return t, nil
|
|
}
|
|
|
|
func (c *Client) addTorrentFromDownloadURL(url string) (*torrent.Torrent, error) {
|
|
if c.torrentClient.IsAbsent() {
|
|
return nil, errors.New("torrent client is not initialized")
|
|
}
|
|
|
|
resp, err := http.Get(url)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer resp.Body.Close()
|
|
|
|
filename := path.Base(url)
|
|
file, err := os.Create(path.Join(os.TempDir(), filename))
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer file.Close()
|
|
|
|
_, err = io.Copy(file, resp.Body)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
t, err := c.torrentClient.MustGet().AddTorrentFromFile(file.Name())
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
c.repository.logger.Trace().Msgf("torrentstream: Waiting to retrieve torrent info")
|
|
select {
|
|
case <-t.GotInfo():
|
|
break
|
|
case <-t.Closed():
|
|
t.Drop()
|
|
return nil, errors.New("torrent closed")
|
|
case <-time.After(1 * time.Minute):
|
|
t.Drop()
|
|
return nil, errors.New("timeout waiting for torrent info")
|
|
}
|
|
c.repository.logger.Info().Msgf("torrentstream: Added torrent: %s", t.InfoHash().AsString())
|
|
return t, nil
|
|
}
|
|
|
|
// Shutdown closes the torrent client and drops all torrents.
|
|
// This SHOULD NOT be called if you don't intend to reinitialize the client.
|
|
func (c *Client) Shutdown() (errs []error) {
|
|
if c.torrentClient.IsAbsent() {
|
|
return
|
|
}
|
|
c.dropTorrents()
|
|
c.currentTorrent = mo.None[*torrent.Torrent]()
|
|
c.currentTorrentStatus = TorrentStatus{}
|
|
c.repository.logger.Debug().Msg("torrentstream: Closing torrent client")
|
|
return c.torrentClient.MustGet().Close()
|
|
}
|
|
|
|
func (c *Client) FindTorrent(infoHash string) (*torrent.Torrent, error) {
|
|
if c.torrentClient.IsAbsent() {
|
|
return nil, errors.New("torrent client is not initialized")
|
|
}
|
|
|
|
torrents := c.torrentClient.MustGet().Torrents()
|
|
for _, t := range torrents {
|
|
if t.InfoHash().AsString() == infoHash {
|
|
c.repository.logger.Debug().Msgf("torrentstream: Found torrent: %s", infoHash)
|
|
return t, nil
|
|
}
|
|
}
|
|
return nil, fmt.Errorf("no torrent found")
|
|
}
|
|
|
|
func (c *Client) RemoveTorrent(infoHash string) error {
|
|
if c.torrentClient.IsAbsent() {
|
|
return errors.New("torrent client is not initialized")
|
|
}
|
|
|
|
c.repository.logger.Trace().Msgf("torrentstream: Removing torrent: %s", infoHash)
|
|
|
|
torrents := c.torrentClient.MustGet().Torrents()
|
|
for _, t := range torrents {
|
|
if t.InfoHash().AsString() == infoHash {
|
|
t.Drop()
|
|
c.repository.logger.Debug().Msgf("torrentstream: Removed torrent: %s", infoHash)
|
|
return nil
|
|
}
|
|
}
|
|
return fmt.Errorf("no torrent found")
|
|
}
|
|
|
|
func (c *Client) dropTorrents() {
|
|
if c.torrentClient.IsAbsent() {
|
|
return
|
|
}
|
|
c.repository.logger.Trace().Msg("torrentstream: Dropping all torrents")
|
|
|
|
for _, t := range c.torrentClient.MustGet().Torrents() {
|
|
t.Drop()
|
|
}
|
|
|
|
if c.repository.settings.IsPresent() {
|
|
// Delete all torrents
|
|
fe, err := os.ReadDir(c.repository.settings.MustGet().DownloadDir)
|
|
if err == nil {
|
|
for _, f := range fe {
|
|
if f.IsDir() {
|
|
_ = os.RemoveAll(path.Join(c.repository.settings.MustGet().DownloadDir, f.Name()))
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
c.repository.logger.Debug().Msg("torrentstream: Dropped all torrents")
|
|
}
|
|
|
|
//////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
|
|
|
|
// getTorrentPercentage returns the percentage of the current torrent file
|
|
// If no torrent is selected, it returns -1
|
|
func (c *Client) getTorrentPercentage(t mo.Option[*torrent.Torrent], f mo.Option[*torrent.File]) float64 {
|
|
if t.IsAbsent() || f.IsAbsent() {
|
|
return -1
|
|
}
|
|
|
|
if f.MustGet().Length() == 0 {
|
|
return 0
|
|
}
|
|
|
|
return float64(f.MustGet().BytesCompleted()) / float64(f.MustGet().Length()) * 100
|
|
}
|
|
|
|
// readyToStream determines if enough of the file has been downloaded to begin streaming
|
|
// Uses both absolute size (minimum buffer) and a percentage-based approach
|
|
func (c *Client) readyToStream() bool {
|
|
if c.currentTorrent.IsAbsent() || c.currentFile.IsAbsent() {
|
|
return false
|
|
}
|
|
|
|
file := c.currentFile.MustGet()
|
|
|
|
// Always need at least 1MB to start playback (typical header size for many formats)
|
|
const minimumBufferBytes int64 = 1 * 1024 * 1024 // 1MB
|
|
|
|
// For large files, use a smaller percentage
|
|
var percentThreshold float64
|
|
fileSize := file.Length()
|
|
|
|
switch {
|
|
case fileSize > 5*1024*1024*1024: // > 5GB
|
|
percentThreshold = 0.1 // 0.1% for very large files
|
|
case fileSize > 1024*1024*1024: // > 1GB
|
|
percentThreshold = 0.5 // 0.5% for large files
|
|
default:
|
|
percentThreshold = 0.5 // 0.5% for smaller files
|
|
}
|
|
|
|
bytesCompleted := file.BytesCompleted()
|
|
percentCompleted := float64(bytesCompleted) / float64(fileSize) * 100
|
|
|
|
// Ready when both minimum buffer is met AND percentage threshold is reached
|
|
return bytesCompleted >= minimumBufferBytes && percentCompleted >= percentThreshold
|
|
}
|