node build fixed

This commit is contained in:
ra_ma
2025-09-20 14:08:38 +01:00
parent c6ebbe069d
commit 3d298fa434
1516 changed files with 535727 additions and 2 deletions

View 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
}