node build fixed
This commit is contained in:
489
seanime-2.9.10/internal/torrentstream/client.go
Normal file
489
seanime-2.9.10/internal/torrentstream/client.go
Normal file
@@ -0,0 +1,489 @@
|
||||
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
|
||||
}
|
||||
Reference in New Issue
Block a user