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
}

View File

@@ -0,0 +1,235 @@
package torrentstream
import (
"fmt"
"seanime/internal/api/anilist"
"seanime/internal/api/metadata"
"seanime/internal/hook"
"seanime/internal/library/anime"
"strconv"
"sync"
"github.com/samber/lo"
)
type (
// StreamCollection is used to "complete" the anime.LibraryCollection if the user chooses
// to include torrent streams in the library view.
StreamCollection struct {
ContinueWatchingList []*anime.Episode `json:"continueWatchingList"`
Anime []*anilist.BaseAnime `json:"anime"`
ListData map[int]*anime.EntryListData `json:"listData"`
}
HydrateStreamCollectionOptions struct {
AnimeCollection *anilist.AnimeCollection
LibraryCollection *anime.LibraryCollection
MetadataProvider metadata.Provider
}
)
func (r *Repository) HydrateStreamCollection(opts *HydrateStreamCollectionOptions) {
reqEvent := new(anime.AnimeLibraryStreamCollectionRequestedEvent)
reqEvent.AnimeCollection = opts.AnimeCollection
reqEvent.LibraryCollection = opts.LibraryCollection
err := hook.GlobalHookManager.OnAnimeLibraryStreamCollectionRequested().Trigger(reqEvent)
if err != nil {
return
}
opts.AnimeCollection = reqEvent.AnimeCollection
opts.LibraryCollection = reqEvent.LibraryCollection
lists := opts.AnimeCollection.MediaListCollection.GetLists()
// Get the anime that are currently being watched
var currentlyWatching *anilist.AnimeCollection_MediaListCollection_Lists
for _, list := range lists {
if list.Status == nil {
continue
}
if *list.Status == anilist.MediaListStatusCurrent {
//currentlyWatching = list.CopyT()
currentlyWatching = &anilist.AnimeCollection_MediaListCollection_Lists{
Status: lo.ToPtr(anilist.MediaListStatusCurrent),
Name: lo.ToPtr("CURRENT"),
IsCustomList: lo.ToPtr(false),
Entries: list.Entries,
}
continue
}
}
for _, list := range lists {
if list.Status == nil {
continue
}
if *list.Status == anilist.MediaListStatusRepeating {
if currentlyWatching == nil {
currentlyWatching = list
} else {
for _, entry := range list.Entries {
currentlyWatching.Entries = append(currentlyWatching.Entries, entry)
}
}
break
}
}
if currentlyWatching == nil {
return
}
ret := &StreamCollection{
ContinueWatchingList: make([]*anime.Episode, 0),
Anime: make([]*anilist.BaseAnime, 0),
ListData: make(map[int]*anime.EntryListData),
}
visitedMediaIds := make(map[int]struct{})
animeAdded := make(map[int]*anilist.AnimeListEntry)
// Go through each entry in the currently watching list
wg := sync.WaitGroup{}
mu := sync.Mutex{}
wg.Add(len(currentlyWatching.Entries))
for _, entry := range currentlyWatching.Entries {
go func(entry *anilist.AnimeListEntry) {
defer wg.Done()
mu.Lock()
if _, found := visitedMediaIds[entry.GetMedia().GetID()]; found {
mu.Unlock()
return
}
// Get the next episode to watch
// i.e. if the user has watched episode 1, the next episode to watch is 2
nextEpisodeToWatch := entry.GetProgressSafe() + 1
if nextEpisodeToWatch > entry.GetMedia().GetCurrentEpisodeCount() {
mu.Unlock()
return // Skip this entry if the user has watched all episodes
}
mediaId := entry.GetMedia().GetID()
visitedMediaIds[mediaId] = struct{}{}
mu.Unlock()
// Check if the anime's "next episode to watch" is already in the library collection
// If it is, we don't need to add it to the stream collection
for _, libraryEp := range opts.LibraryCollection.ContinueWatchingList {
if libraryEp.BaseAnime.ID == mediaId && libraryEp.GetProgressNumber() == nextEpisodeToWatch {
return
}
}
if *entry.GetMedia().GetStatus() == anilist.MediaStatusNotYetReleased {
return
}
// Get the media info
animeMetadata, err := opts.MetadataProvider.GetAnimeMetadata(metadata.AnilistPlatform, mediaId)
if err != nil {
r.logger.Error().Err(err).Msg("torrentstream: could not fetch AniDB media")
return
}
_, found := animeMetadata.FindEpisode(strconv.Itoa(nextEpisodeToWatch))
//if !found {
// r.logger.Error().Msg("torrentstream: could not find episode in AniDB")
// return
//}
progressOffset := 0
anidbEpisode := strconv.Itoa(nextEpisodeToWatch)
if anime.FindDiscrepancy(entry.GetMedia(), animeMetadata) == anime.DiscrepancyAniListCountsEpisodeZero {
progressOffset = 1
if nextEpisodeToWatch == 1 {
anidbEpisode = "S1"
}
}
// Add the anime & episode
episode := anime.NewEpisode(&anime.NewEpisodeOptions{
LocalFile: nil,
OptionalAniDBEpisode: anidbEpisode,
AnimeMetadata: animeMetadata,
Media: entry.GetMedia(),
ProgressOffset: progressOffset,
IsDownloaded: false,
MetadataProvider: r.metadataProvider,
})
if !found {
episode.EpisodeTitle = entry.GetMedia().GetPreferredTitle()
episode.DisplayTitle = fmt.Sprintf("Episode %d", nextEpisodeToWatch)
episode.ProgressNumber = nextEpisodeToWatch
episode.EpisodeNumber = nextEpisodeToWatch
episode.EpisodeMetadata = &anime.EpisodeMetadata{
Image: entry.GetMedia().GetBannerImageSafe(),
}
}
if episode == nil {
r.logger.Error().Msg("torrentstream: could not get anime entry episode")
return
}
mu.Lock()
ret.ContinueWatchingList = append(ret.ContinueWatchingList, episode)
animeAdded[mediaId] = entry
mu.Unlock()
}(entry)
}
wg.Wait()
libraryAnimeMap := make(map[int]struct{})
// Remove anime that are already in the library collection
for _, list := range opts.LibraryCollection.Lists {
if list.Status == anilist.MediaListStatusCurrent {
for _, entry := range list.Entries {
libraryAnimeMap[entry.MediaId] = struct{}{}
if _, found := animeAdded[entry.MediaId]; found {
delete(animeAdded, entry.MediaId)
}
}
}
}
for _, entry := range currentlyWatching.Entries {
if _, found := libraryAnimeMap[entry.GetMedia().GetID()]; found {
continue
}
if *entry.GetMedia().GetStatus() == anilist.MediaStatusNotYetReleased {
continue
}
animeAdded[entry.GetMedia().GetID()] = entry
}
for _, a := range animeAdded {
ret.Anime = append(ret.Anime, a.GetMedia())
ret.ListData[a.GetMedia().GetID()] = &anime.EntryListData{
Progress: a.GetProgressSafe(),
Score: a.GetScoreSafe(),
Status: a.GetStatus(),
Repeat: a.GetRepeatSafe(),
StartedAt: anilist.FuzzyDateToString(a.StartedAt),
CompletedAt: anilist.FuzzyDateToString(a.CompletedAt),
}
}
if len(ret.ContinueWatchingList) == 0 && len(ret.Anime) == 0 {
return
}
sc := &anime.StreamCollection{
ContinueWatchingList: ret.ContinueWatchingList,
Anime: ret.Anime,
ListData: ret.ListData,
}
event := new(anime.AnimeLibraryStreamCollectionEvent)
event.StreamCollection = sc
err = hook.GlobalHookManager.OnAnimeLibraryStreamCollection().Trigger(event)
if err != nil {
return
}
opts.LibraryCollection.Stream = event.StreamCollection
}

View File

@@ -0,0 +1,82 @@
package torrentstream
import (
"seanime/internal/api/anilist"
"seanime/internal/api/metadata"
"seanime/internal/events"
"seanime/internal/library/anime"
"seanime/internal/platforms/anilist_platform"
"seanime/internal/test_utils"
"seanime/internal/util"
"testing"
"github.com/davecgh/go-spew/spew"
"github.com/samber/lo"
"github.com/stretchr/testify/require"
)
func TestStreamCollection(t *testing.T) {
t.Skip("Incomplete")
test_utils.SetTwoLevelDeep()
test_utils.InitTestProvider(t, test_utils.Anilist())
logger := util.NewLogger()
metadataProvider := metadata.GetMockProvider(t)
anilistClient := anilist.TestGetMockAnilistClient()
anilistPlatform := anilist_platform.NewAnilistPlatform(anilistClient, logger)
anilistPlatform.SetUsername(test_utils.ConfigData.Provider.AnilistUsername)
animeCollection, err := anilistPlatform.GetAnimeCollection(t.Context(), false)
require.NoError(t, err)
require.NotNil(t, animeCollection)
repo := NewRepository(&NewRepositoryOptions{
Logger: logger,
BaseAnimeCache: anilist.NewBaseAnimeCache(),
CompleteAnimeCache: anilist.NewCompleteAnimeCache(),
Platform: anilistPlatform,
MetadataProvider: metadataProvider,
WSEventManager: events.NewMockWSEventManager(logger),
TorrentRepository: nil,
PlaybackManager: nil,
Database: nil,
})
// Mock Anilist collection and local files
// User is currently watching Sousou no Frieren and One Piece
lfs := make([]*anime.LocalFile, 0)
// Sousou no Frieren
// 7 episodes downloaded, 4 watched
mediaId := 154587
lfs = append(lfs, anime.MockHydratedLocalFiles(
anime.MockGenerateHydratedLocalFileGroupOptions("E:/Anime", "E:\\Anime\\Sousou no Frieren\\[SubsPlease] Sousou no Frieren - %ep (1080p) [F02B9CEE].mkv", mediaId, []anime.MockHydratedLocalFileWrapperOptionsMetadata{
{MetadataEpisode: 1, MetadataAniDbEpisode: "1", MetadataType: anime.LocalFileTypeMain},
{MetadataEpisode: 2, MetadataAniDbEpisode: "2", MetadataType: anime.LocalFileTypeMain},
{MetadataEpisode: 3, MetadataAniDbEpisode: "3", MetadataType: anime.LocalFileTypeMain},
{MetadataEpisode: 4, MetadataAniDbEpisode: "4", MetadataType: anime.LocalFileTypeMain},
{MetadataEpisode: 5, MetadataAniDbEpisode: "5", MetadataType: anime.LocalFileTypeMain},
{MetadataEpisode: 6, MetadataAniDbEpisode: "6", MetadataType: anime.LocalFileTypeMain},
{MetadataEpisode: 7, MetadataAniDbEpisode: "7", MetadataType: anime.LocalFileTypeMain},
}),
)...)
anilist.TestModifyAnimeCollectionEntry(animeCollection, mediaId, anilist.TestModifyAnimeCollectionEntryInput{
Status: lo.ToPtr(anilist.MediaListStatusCurrent),
Progress: lo.ToPtr(4), // Mock progress
})
libraryCollection, err := anime.NewLibraryCollection(t.Context(), &anime.NewLibraryCollectionOptions{
AnimeCollection: animeCollection,
LocalFiles: lfs,
Platform: anilistPlatform,
MetadataProvider: metadataProvider,
})
require.NoError(t, err)
// Create the stream collection
repo.HydrateStreamCollection(&HydrateStreamCollectionOptions{
AnimeCollection: animeCollection,
LibraryCollection: libraryCollection,
})
spew.Dump(libraryCollection)
}

View File

@@ -0,0 +1,50 @@
package torrentstream
import "seanime/internal/events"
const (
eventLoading = "loading"
eventLoadingFailed = "loading-failed"
eventTorrentLoaded = "loaded"
eventTorrentStartedPlaying = "started-playing"
eventTorrentStatus = "status"
eventTorrentStopped = "stopped"
)
type TorrentLoadingStatusState string
const (
TLSStateLoading TorrentLoadingStatusState = "LOADING"
TLSStateSearchingTorrents TorrentLoadingStatusState = "SEARCHING_TORRENTS"
TLSStateCheckingTorrent TorrentLoadingStatusState = "CHECKING_TORRENT"
TLSStateAddingTorrent TorrentLoadingStatusState = "ADDING_TORRENT"
TLSStateSelectingFile TorrentLoadingStatusState = "SELECTING_FILE"
TLSStateStartingServer TorrentLoadingStatusState = "STARTING_SERVER"
TLSStateSendingStreamToMediaPlayer TorrentLoadingStatusState = "SENDING_STREAM_TO_MEDIA_PLAYER"
)
type TorrentStreamState struct {
State string `json:"state"`
}
func (r *Repository) sendStateEvent(event string, data ...interface{}) {
var dataToSend interface{}
if len(data) > 0 {
dataToSend = data[0]
}
r.wsEventManager.SendEvent(events.TorrentStreamState, struct {
State string `json:"state"`
Data interface{} `json:"data"`
}{
State: event,
Data: dataToSend,
})
}
//func (r *Repository) sendTorrentLoadingStatus(event TorrentLoadingStatusState, checking string) {
// r.wsEventManager.SendEvent(eventTorrentLoadingStatus, &TorrentLoadingStatus{
// TorrentBeingChecked: checking,
// State: event,
// })
//}

View File

@@ -0,0 +1,414 @@
package torrentstream
import (
"cmp"
"context"
"fmt"
"seanime/internal/api/anilist"
hibiketorrent "seanime/internal/extension/hibike/torrent"
"seanime/internal/hook"
torrentanalyzer "seanime/internal/torrents/analyzer"
itorrent "seanime/internal/torrents/torrent"
"seanime/internal/util"
"seanime/internal/util/torrentutil"
"slices"
"time"
"github.com/anacrolix/torrent"
"github.com/samber/lo"
)
var (
ErrNoTorrentsFound = fmt.Errorf("no torrents found, please select manually")
ErrNoEpisodeFound = fmt.Errorf("could not select episode from torrents, please select manually")
)
type (
playbackTorrent struct {
Torrent *torrent.Torrent
File *torrent.File
}
)
// setPriorityDownloadStrategy sets piece priorities for optimal streaming experience
// This helps to optimize initial buffering, seeking, and end-of-file playback
func (r *Repository) setPriorityDownloadStrategy(t *torrent.Torrent, file *torrent.File) {
torrentutil.PrioritizeDownloadPieces(t, file, r.logger)
}
func (r *Repository) findBestTorrent(media *anilist.CompleteAnime, aniDbEpisode string, episodeNumber int) (ret *playbackTorrent, err error) {
defer util.HandlePanicInModuleWithError("torrentstream/findBestTorrent", &err)
r.logger.Debug().Msgf("torrentstream: Finding best torrent for %s, Episode %d", media.GetTitleSafe(), episodeNumber)
providerId := itorrent.ProviderAnimeTosho // todo: get provider from settings
fallbackProviderId := itorrent.ProviderNyaa
// Get AnimeTosho provider extension
providerExtension, ok := r.torrentRepository.GetAnimeProviderExtension(providerId)
if !ok {
r.logger.Error().Str("provider", itorrent.ProviderAnimeTosho).Msg("torrentstream: AnimeTosho provider extension not found")
return nil, fmt.Errorf("provider extension not found")
}
searchBatch := false
// Search batch if not a movie and finished
yearsSinceStart := 999
if media.StartDate != nil && *media.StartDate.Year > 0 {
yearsSinceStart = time.Now().Year() - *media.StartDate.Year // e.g. 2024 - 2020 = 4
}
if !media.IsMovie() && media.IsFinished() && yearsSinceStart > 4 {
searchBatch = true
}
r.sendStateEvent(eventLoading, TLSStateSearchingTorrents)
var data *itorrent.SearchData
var currentProvider string = providerId
searchLoop:
for {
var err error
data, err = r.torrentRepository.SearchAnime(context.Background(), itorrent.AnimeSearchOptions{
Provider: currentProvider,
Type: itorrent.AnimeSearchTypeSmart,
Media: media.ToBaseAnime(),
Query: "",
Batch: searchBatch,
EpisodeNumber: episodeNumber,
BestReleases: false,
Resolution: r.settings.MustGet().PreferredResolution,
})
// If we are searching for batches, we don't want to return an error if no torrents are found
// We will just search again without the batch flag
if err != nil && !searchBatch {
r.logger.Error().Err(err).Msg("torrentstream: Error searching torrents")
// Try fallback provider if we're still on primary provider
if currentProvider == providerId {
r.logger.Debug().Msgf("torrentstream: Primary provider failed, trying fallback provider %s", fallbackProviderId)
currentProvider = fallbackProviderId
// Get fallback provider extension
providerExtension, ok = r.torrentRepository.GetAnimeProviderExtension(currentProvider)
if !ok {
r.logger.Error().Str("provider", fallbackProviderId).Msg("torrentstream: Fallback provider extension not found")
return nil, fmt.Errorf("fallback provider extension not found")
}
continue
}
return nil, err
} else if err != nil {
searchBatch = false
continue
}
// This whole thing below just means that
// If we are looking for batches, there should be at least 3 torrents found or the max seeders should be at least 15
if searchBatch {
nbFound := len(data.Torrents)
seedersArr := lo.Map(data.Torrents, func(t *hibiketorrent.AnimeTorrent, _ int) int {
return t.Seeders
})
if len(seedersArr) == 0 {
searchBatch = false
continue
}
maxSeeders := slices.Max(seedersArr)
if maxSeeders >= 15 || nbFound > 2 {
break searchLoop
} else {
searchBatch = false
}
} else {
break searchLoop
}
}
if data == nil || len(data.Torrents) == 0 {
// Try fallback provider if we're still on primary provider
if currentProvider == providerId {
r.logger.Debug().Msgf("torrentstream: No torrents found with primary provider, trying fallback provider %s", fallbackProviderId)
currentProvider = fallbackProviderId
// Get fallback provider extension
providerExtension, ok = r.torrentRepository.GetAnimeProviderExtension(currentProvider)
if !ok {
r.logger.Error().Str("provider", fallbackProviderId).Msg("torrentstream: Fallback provider extension not found")
return nil, fmt.Errorf("fallback provider extension not found")
}
// Try searching with fallback provider (reset searchBatch)
searchBatch = false
if !media.IsMovie() && media.IsFinished() && yearsSinceStart > 4 {
searchBatch = true
}
// Restart the search with fallback provider
goto searchLoop
}
r.logger.Error().Msg("torrentstream: No torrents found")
return nil, ErrNoTorrentsFound
}
// Sort by seeders from highest to lowest
slices.SortStableFunc(data.Torrents, func(a, b *hibiketorrent.AnimeTorrent) int {
return cmp.Compare(b.Seeders, a.Seeders)
})
// Trigger hook
fetchedEvent := &TorrentStreamAutoSelectTorrentsFetchedEvent{
Torrents: data.Torrents,
}
_ = hook.GlobalHookManager.OnTorrentStreamAutoSelectTorrentsFetched().Trigger(fetchedEvent)
data.Torrents = fetchedEvent.Torrents
r.logger.Debug().Msgf("torrentstream: Found %d torrents", len(data.Torrents))
// Go through the top 3 torrents
// - For each torrent, add it, get the files, and check if it has the episode
// - If it does, return the magnet link
var selectedTorrent *torrent.Torrent
var selectedFile *torrent.File
tries := 0
for _, searchT := range data.Torrents {
if tries >= 2 {
break
}
r.sendStateEvent(eventLoading, struct {
State any `json:"state"`
TorrentBeingLoaded string `json:"torrentBeingLoaded"`
}{
State: TLSStateAddingTorrent,
TorrentBeingLoaded: searchT.Name,
})
r.logger.Trace().Msgf("torrentstream: Getting torrent magnet")
magnet, err := providerExtension.GetProvider().GetTorrentMagnetLink(searchT)
if err != nil {
r.logger.Warn().Err(err).Msgf("torrentstream: Error scraping magnet link for %s", searchT.Link)
tries++
continue
}
r.logger.Debug().Msgf("torrentstream: Adding torrent %s from magnet", searchT.Link)
t, err := r.client.AddTorrent(magnet)
if err != nil {
r.logger.Warn().Err(err).Msgf("torrentstream: Error adding torrent %s", searchT.Link)
tries++
continue
}
r.sendStateEvent(eventLoading, struct {
State any `json:"state"`
TorrentBeingLoaded string `json:"torrentBeingLoaded"`
}{
State: TLSStateCheckingTorrent,
TorrentBeingLoaded: searchT.Name,
})
// If the torrent has only one file, return it
if len(t.Files()) == 1 {
tFile := t.Files()[0]
tFile.Download()
r.setPriorityDownloadStrategy(t, tFile)
r.logger.Debug().Msgf("torrentstream: Found single file torrent: %s", tFile.DisplayPath())
return &playbackTorrent{
Torrent: t,
File: tFile,
}, nil
}
r.sendStateEvent(eventLoading, TLSStateSelectingFile)
// DEVNOTE: The gap between adding the torrent and file analysis causes some pieces to be downloaded
// We currently can't Pause/Resume torrents so :shrug:
filepaths := lo.Map(t.Files(), func(f *torrent.File, _ int) string {
return f.DisplayPath()
})
if len(filepaths) == 0 {
r.logger.Error().Msg("torrentstream: No files found in the torrent")
return nil, fmt.Errorf("no files found in the torrent")
}
// Create a new Torrent Analyzer
analyzer := torrentanalyzer.NewAnalyzer(&torrentanalyzer.NewAnalyzerOptions{
Logger: r.logger,
Filepaths: filepaths,
Media: media,
Platform: r.platform,
MetadataProvider: r.metadataProvider,
ForceMatch: true,
})
r.logger.Debug().Msgf("torrentstream: Analyzing torrent %s", searchT.Link)
// Analyze torrent files
analysis, err := analyzer.AnalyzeTorrentFiles()
if err != nil {
r.logger.Warn().Err(err).Msg("torrentstream: Error analyzing torrent files")
// Remove torrent on failure
go func() {
_ = r.client.RemoveTorrent(t.InfoHash().AsString())
}()
tries++
continue
}
analysisFile, found := analysis.GetFileByAniDBEpisode(aniDbEpisode)
// Check if analyzer found the episode
if !found {
r.logger.Error().Msgf("torrentstream: Failed to auto-select episode from torrent %s", searchT.Link)
// Remove torrent on failure
go func() {
_ = r.client.RemoveTorrent(t.InfoHash().AsString())
}()
tries++
continue
}
r.logger.Debug().Msgf("torrentstream: Found corresponding file for episode %s: %s", aniDbEpisode, analysisFile.GetLocalFile().Name)
// Download the file and unselect the rest
for i, f := range t.Files() {
if i != analysisFile.GetIndex() {
f.SetPriority(torrent.PiecePriorityNone)
}
}
tFile := t.Files()[analysisFile.GetIndex()]
r.logger.Debug().Msgf("torrentstream: Selecting file %s", tFile.DisplayPath())
r.setPriorityDownloadStrategy(t, tFile)
selectedTorrent = t
selectedFile = tFile
break
}
if selectedTorrent == nil {
return nil, ErrNoEpisodeFound
}
ret = &playbackTorrent{
Torrent: selectedTorrent,
File: selectedFile,
}
return ret, nil
}
// findBestTorrentFromManualSelection is like findBestTorrent but no need to search for the best torrent first
func (r *Repository) findBestTorrentFromManualSelection(t *hibiketorrent.AnimeTorrent, media *anilist.CompleteAnime, aniDbEpisode string, chosenFileIndex *int) (*playbackTorrent, error) {
r.logger.Debug().Msgf("torrentstream: Analyzing torrent from %s for %s", t.Link, media.GetTitleSafe())
// Get the torrent's provider extension
providerExtension, ok := r.torrentRepository.GetAnimeProviderExtension(t.Provider)
if !ok {
r.logger.Error().Str("provider", t.Provider).Msg("torrentstream: provider extension not found")
return nil, fmt.Errorf("provider extension not found")
}
// First, add the torrent
magnet, err := providerExtension.GetProvider().GetTorrentMagnetLink(t)
if err != nil {
r.logger.Error().Err(err).Msgf("torrentstream: Error scraping magnet link for %s", t.Link)
return nil, fmt.Errorf("could not get magnet link from %s", t.Link)
}
selectedTorrent, err := r.client.AddTorrent(magnet)
if err != nil {
r.logger.Error().Err(err).Msgf("torrentstream: Error adding torrent %s", t.Link)
return nil, err
}
// If the torrent has only one file, return it
if len(selectedTorrent.Files()) == 1 {
tFile := selectedTorrent.Files()[0]
tFile.Download()
r.setPriorityDownloadStrategy(selectedTorrent, tFile)
r.logger.Debug().Msgf("torrentstream: Found single file torrent: %s", tFile.DisplayPath())
return &playbackTorrent{
Torrent: selectedTorrent,
File: tFile,
}, nil
}
var fileIndex int
// If the file index is already selected
if chosenFileIndex != nil {
fileIndex = *chosenFileIndex
} else {
// We know the torrent has multiple files, so we'll need to analyze it
filepaths := lo.Map(selectedTorrent.Files(), func(f *torrent.File, _ int) string {
return f.DisplayPath()
})
if len(filepaths) == 0 {
r.logger.Error().Msg("torrentstream: No files found in the torrent")
return nil, fmt.Errorf("no files found in the torrent")
}
// Create a new Torrent Analyzer
analyzer := torrentanalyzer.NewAnalyzer(&torrentanalyzer.NewAnalyzerOptions{
Logger: r.logger,
Filepaths: filepaths,
Media: media,
Platform: r.platform,
MetadataProvider: r.metadataProvider,
ForceMatch: true,
})
// Analyze torrent files
analysis, err := analyzer.AnalyzeTorrentFiles()
if err != nil {
r.logger.Warn().Err(err).Msg("torrentstream: Error analyzing torrent files")
// Remove torrent on failure
go func() {
_ = r.client.RemoveTorrent(selectedTorrent.InfoHash().AsString())
}()
return nil, err
}
analysisFile, found := analysis.GetFileByAniDBEpisode(aniDbEpisode)
// Check if analyzer found the episode
if !found {
r.logger.Error().Msgf("torrentstream: Failed to auto-select episode from torrent %s", selectedTorrent.Info().Name)
// Remove torrent on failure
go func() {
_ = r.client.RemoveTorrent(selectedTorrent.InfoHash().AsString())
}()
return nil, ErrNoEpisodeFound
}
r.logger.Debug().Msgf("torrentstream: Found corresponding file for episode %s: %s", aniDbEpisode, analysisFile.GetLocalFile().Name)
fileIndex = analysisFile.GetIndex()
}
// Download the file and unselect the rest
for i, f := range selectedTorrent.Files() {
if i != fileIndex {
f.SetPriority(torrent.PiecePriorityNone)
}
}
//selectedTorrent.Files()[fileIndex].SetPriority(torrent.PiecePriorityNormal)
r.logger.Debug().Msgf("torrentstream: Selected torrent %s", selectedTorrent.Files()[fileIndex].DisplayPath())
tFile := selectedTorrent.Files()[fileIndex]
tFile.Download()
r.setPriorityDownloadStrategy(selectedTorrent, tFile)
ret := &playbackTorrent{
Torrent: selectedTorrent,
File: selectedTorrent.Files()[fileIndex],
}
return ret, nil
}

View File

@@ -0,0 +1,82 @@
package torrentstream
import (
"net/http"
"seanime/internal/util/torrentutil"
"strconv"
"time"
"github.com/anacrolix/torrent"
)
var _ = http.Handler(&handler{})
type (
// handler serves the torrent stream
handler struct {
repository *Repository
}
)
func newHandler(repository *Repository) *handler {
return &handler{
repository: repository,
}
}
func (h *handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
h.repository.logger.Trace().Str("range", r.Header.Get("Range")).Msg("torrentstream: Stream endpoint hit")
if h.repository.client.currentFile.IsAbsent() || h.repository.client.currentTorrent.IsAbsent() {
h.repository.logger.Error().Msg("torrentstream: No torrent to stream")
http.Error(w, "No torrent to stream", http.StatusNotFound)
return
}
if r.Method == http.MethodHead {
r.Response.Header.Set("Content-Type", "video/mp4")
r.Response.Header.Set("Content-Length", strconv.Itoa(int(h.repository.client.currentFile.MustGet().Length())))
r.Response.Header.Set("Content-Disposition", "inline; filename="+h.repository.client.currentFile.MustGet().DisplayPath())
r.Response.Header.Set("Accept-Ranges", "bytes")
r.Response.Header.Set("Cache-Control", "no-cache")
r.Response.Header.Set("Pragma", "no-cache")
r.Response.Header.Set("Expires", "0")
r.Response.Header.Set("X-Content-Type-Options", "nosniff")
// No content, just headers
w.WriteHeader(http.StatusOK)
return
}
file := h.repository.client.currentFile.MustGet()
h.repository.logger.Trace().Str("file", file.DisplayPath()).Msg("torrentstream: New reader")
tr := file.NewReader()
defer func(tr torrent.Reader) {
h.repository.logger.Trace().Msg("torrentstream: Closing reader")
_ = tr.Close()
}(tr)
tr.SetResponsive()
// Read ahead 5MB for better streaming performance
// DEVNOTE: Not sure if dynamic prioritization overwrites this but whatever
tr.SetReadahead(5 * 1024 * 1024)
// If this is a range request for a later part of the file, prioritize those pieces
rangeHeader := r.Header.Get("Range")
if rangeHeader != "" && h.repository.client.currentTorrent.IsPresent() {
t := h.repository.client.currentTorrent.MustGet()
// Attempt to prioritize the pieces requested in the range
torrentutil.PrioritizeRangeRequestPieces(rangeHeader, t, file, h.repository.logger)
}
h.repository.logger.Trace().Str("file", file.DisplayPath()).Msg("torrentstream: Serving file content")
w.Header().Set("Content-Type", "video/mp4")
http.ServeContent(
w,
r,
file.DisplayPath(),
time.Now(),
tr,
)
h.repository.logger.Trace().Msg("torrentstream: File content served")
}

View File

@@ -0,0 +1,34 @@
package torrentstream
import (
"seanime/internal/database/db_bridge"
hibiketorrent "seanime/internal/extension/hibike/torrent"
"seanime/internal/util"
)
type BatchHistoryResponse struct {
Torrent *hibiketorrent.AnimeTorrent `json:"torrent"`
}
func (r *Repository) GetBatchHistory(mId int) (ret *BatchHistoryResponse) {
defer util.HandlePanicInModuleThen("torrentstream/GetBatchHistory", func() {
ret = &BatchHistoryResponse{}
})
torrent, err := db_bridge.GetTorrentstreamHistory(r.db, mId)
if err != nil {
return &BatchHistoryResponse{}
}
return &BatchHistoryResponse{
torrent,
}
}
func (r *Repository) AddBatchHistory(mId int, torrent *hibiketorrent.AnimeTorrent) {
go func() {
defer util.HandlePanicInModuleThen("torrentstream/AddBatchHistory", func() {})
_ = db_bridge.InsertTorrentstreamHistory(r.db, mId, torrent)
}()
}

View File

@@ -0,0 +1,26 @@
package torrentstream
import (
"seanime/internal/api/anilist"
hibiketorrent "seanime/internal/extension/hibike/torrent"
"seanime/internal/hook_resolver"
)
// TorrentStreamAutoSelectTorrentsFetchedEvent is triggered when the torrents are fetched for auto select.
// The torrents are sorted by seeders from highest to lowest.
// This event is triggered before the top 3 torrents are analyzed.
type TorrentStreamAutoSelectTorrentsFetchedEvent struct {
hook_resolver.Event
Torrents []*hibiketorrent.AnimeTorrent
}
// TorrentStreamSendStreamToMediaPlayerEvent is triggered when the torrent stream is about to send a stream to the media player.
// Prevent default to skip the default playback and override the playback.
type TorrentStreamSendStreamToMediaPlayerEvent struct {
hook_resolver.Event
WindowTitle string `json:"windowTitle"`
StreamURL string `json:"streamURL"`
Media *anilist.BaseAnime `json:"media"`
AniDbEpisode string `json:"aniDbEpisode"`
PlaybackType string `json:"playbackType"`
}

View File

@@ -0,0 +1,112 @@
package torrentstream
import (
"context"
"seanime/internal/mediaplayers/mediaplayer"
"seanime/internal/nativeplayer"
)
type (
playback struct {
mediaPlayerCtxCancelFunc context.CancelFunc
// Stores the video duration returned by the media player
// When this is greater than 0, the video is considered to be playing
currentVideoDuration int
}
)
func (r *Repository) listenToMediaPlayerEvents() {
r.mediaPlayerRepositorySubscriber = r.mediaPlayerRepository.Subscribe("torrentstream")
if r.playback.mediaPlayerCtxCancelFunc != nil {
r.playback.mediaPlayerCtxCancelFunc()
}
var ctx context.Context
ctx, r.playback.mediaPlayerCtxCancelFunc = context.WithCancel(context.Background())
go func(ctx context.Context) {
for {
select {
case <-ctx.Done():
r.logger.Debug().Msg("torrentstream: Media player context cancelled")
return
case event := <-r.mediaPlayerRepositorySubscriber.EventCh:
switch e := event.(type) {
case mediaplayer.StreamingTrackingStartedEvent:
// Reset the current video duration, as the video has stopped
// DEVNOTE: This is changed in client.go as well when the duration is updated over 0
r.playback.currentVideoDuration = 0
case mediaplayer.StreamingVideoCompletedEvent:
case mediaplayer.StreamingTrackingStoppedEvent:
if r.client.currentTorrent.IsPresent() {
go func() {
defer func() {
if r := recover(); r != nil {
}
}()
r.logger.Debug().Msg("torrentstream: Media player stopped event received")
// Stop the stream
_ = r.StopStream()
}()
}
case mediaplayer.StreamingPlaybackStatusEvent:
go func() {
if e.Status != nil && r.client.currentTorrent.IsPresent() {
r.client.mediaPlayerPlaybackStatusCh <- e.Status
}
}()
}
}
}
}(ctx)
}
func (r *Repository) listenToNativePlayerEvents() {
r.nativePlayerSubscriber = r.nativePlayer.Subscribe("torrentstream")
go func() {
for {
select {
case event, ok := <-r.nativePlayerSubscriber.Events():
if !ok { // shouldn't happen
r.logger.Debug().Msg("torrentstream: Native player subscriber channel closed")
return
}
switch event := event.(type) {
case *nativeplayer.VideoLoadedMetadataEvent:
go func() {
if r.client.currentFile.IsPresent() && r.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 r.playback.currentVideoDuration == 0 && event.Duration > 0 {
// The media player has started playing the video
r.logger.Debug().Msg("torrentstream: Media player started playing the video, sending event")
r.sendStateEvent(eventTorrentStartedPlaying)
// Update the stored video duration
r.playback.currentVideoDuration = int(event.Duration)
}
}
}()
case *nativeplayer.VideoTerminatedEvent:
r.logger.Debug().Msg("torrentstream: Native player terminated event received")
r.playback.currentVideoDuration = 0
// Only handle the event if we actually have a current torrent to avoid unnecessary cleanup
if r.client.currentTorrent.IsPresent() {
go func() {
defer func() {
if rec := recover(); rec != nil {
r.logger.Error().Msg("torrentstream: Recovered from panic in VideoTerminatedEvent handler")
}
}()
r.logger.Debug().Msg("torrentstream: Stopping stream due to native player termination")
// Stop the stream
_ = r.StopStream()
}()
}
}
}
}
}()
}

View File

@@ -0,0 +1,130 @@
package torrentstream
import (
"fmt"
"github.com/5rahim/habari"
"github.com/anacrolix/torrent"
"seanime/internal/api/anilist"
hibiketorrent "seanime/internal/extension/hibike/torrent"
"seanime/internal/util"
"seanime/internal/util/comparison"
"sync"
)
type (
FilePreview struct {
Path string `json:"path"`
DisplayPath string `json:"displayPath"`
DisplayTitle string `json:"displayTitle"`
EpisodeNumber int `json:"episodeNumber"`
RelativeEpisodeNumber int `json:"relativeEpisodeNumber"`
IsLikely bool `json:"isLikely"`
Index int `json:"index"`
}
GetTorrentFilePreviewsOptions struct {
Torrent *hibiketorrent.AnimeTorrent
Magnet string
EpisodeNumber int
AbsoluteOffset int
Media *anilist.BaseAnime
}
)
func (r *Repository) GetTorrentFilePreviewsFromManualSelection(opts *GetTorrentFilePreviewsOptions) (ret []*FilePreview, err error) {
defer util.HandlePanicInModuleWithError("torrentstream/GetTorrentFilePreviewsFromManualSelection", &err)
if opts.Torrent == nil || opts.Magnet == "" || opts.Media == nil {
return nil, fmt.Errorf("torrentstream: Invalid options")
}
r.logger.Trace().Str("hash", opts.Torrent.InfoHash).Msg("torrentstream: Getting file previews for torrent selection")
selectedTorrent, err := r.client.AddTorrent(opts.Magnet)
if err != nil {
r.logger.Error().Err(err).Msgf("torrentstream: Error adding torrent %s", opts.Magnet)
return nil, err
}
fileMetadataMap := make(map[string]*habari.Metadata)
wg := sync.WaitGroup{}
mu := sync.RWMutex{}
wg.Add(len(selectedTorrent.Files()))
for _, file := range selectedTorrent.Files() {
go func(file *torrent.File) {
defer wg.Done()
defer util.HandlePanicInModuleThen("debridstream/GetTorrentFilePreviewsFromManualSelection", func() {})
metadata := habari.Parse(file.DisplayPath())
mu.Lock()
fileMetadataMap[file.Path()] = metadata
mu.Unlock()
}(file)
}
wg.Wait()
containsAbsoluteEps := false
for _, metadata := range fileMetadataMap {
if len(metadata.EpisodeNumber) == 1 {
ep := util.StringToIntMust(metadata.EpisodeNumber[0])
if ep > opts.Media.GetTotalEpisodeCount() {
containsAbsoluteEps = true
break
}
}
}
wg = sync.WaitGroup{}
mu2 := sync.Mutex{}
for i, file := range selectedTorrent.Files() {
wg.Add(1)
go func(i int, file *torrent.File) {
defer wg.Done()
defer util.HandlePanicInModuleThen("torrentstream/GetTorrentFilePreviewsFromManualSelection", func() {})
mu.RLock()
metadata := fileMetadataMap[file.Path()]
mu.RUnlock()
displayTitle := file.DisplayPath()
isLikely := false
parsedEpisodeNumber := -1
if metadata != nil && !comparison.ValueContainsSpecial(displayTitle) && !comparison.ValueContainsNC(displayTitle) {
if len(metadata.EpisodeNumber) == 1 {
ep := util.StringToIntMust(metadata.EpisodeNumber[0])
parsedEpisodeNumber = ep
displayTitle = fmt.Sprintf("Episode %d", ep)
if metadata.EpisodeTitle != "" {
displayTitle = fmt.Sprintf("%s - %s", displayTitle, metadata.EpisodeTitle)
}
}
}
if !containsAbsoluteEps {
isLikely = parsedEpisodeNumber == opts.EpisodeNumber
}
mu2.Lock()
// Get the file preview
ret = append(ret, &FilePreview{
Path: file.Path(),
DisplayPath: file.DisplayPath(),
DisplayTitle: displayTitle,
EpisodeNumber: parsedEpisodeNumber,
IsLikely: isLikely,
Index: i,
})
mu2.Unlock()
}(i, file)
}
wg.Wait()
r.logger.Debug().Str("hash", opts.Torrent.InfoHash).Msg("torrentstream: Got file previews for torrent selection, dropping torrent")
go selectedTorrent.Drop()
return
}

View File

@@ -0,0 +1,231 @@
package torrentstream
import (
"errors"
"net/http"
"os"
"path/filepath"
"seanime/internal/api/anilist"
"seanime/internal/api/metadata"
"seanime/internal/database/db"
"seanime/internal/database/models"
"seanime/internal/directstream"
"seanime/internal/events"
hibiketorrent "seanime/internal/extension/hibike/torrent"
"seanime/internal/library/anime"
"seanime/internal/library/playbackmanager"
"seanime/internal/mediaplayers/mediaplayer"
"seanime/internal/nativeplayer"
"seanime/internal/platforms/platform"
"seanime/internal/torrents/torrent"
"seanime/internal/util"
"seanime/internal/util/result"
"github.com/rs/zerolog"
"github.com/samber/mo"
)
type (
Repository struct {
client *Client
handler *handler
playback playback
settings mo.Option[Settings] // None by default, set and refreshed by [SetSettings]
selectionHistoryMap *result.Map[int, *hibiketorrent.AnimeTorrent] // Key: AniList media ID
// Injected dependencies
torrentRepository *torrent.Repository
baseAnimeCache *anilist.BaseAnimeCache
completeAnimeCache *anilist.CompleteAnimeCache
platform platform.Platform
wsEventManager events.WSEventManagerInterface
metadataProvider metadata.Provider
playbackManager *playbackmanager.PlaybackManager
mediaPlayerRepository *mediaplayer.Repository
mediaPlayerRepositorySubscriber *mediaplayer.RepositorySubscriber
nativePlayerSubscriber *nativeplayer.Subscriber
directStreamManager *directstream.Manager
nativePlayer *nativeplayer.NativePlayer
logger *zerolog.Logger
db *db.Database
onEpisodeCollectionChanged func(ec *anime.EpisodeCollection)
previousStreamOptions mo.Option[*StartStreamOptions]
}
Settings struct {
models.TorrentstreamSettings
Host string
Port int
}
NewRepositoryOptions struct {
Logger *zerolog.Logger
TorrentRepository *torrent.Repository
BaseAnimeCache *anilist.BaseAnimeCache
CompleteAnimeCache *anilist.CompleteAnimeCache
Platform platform.Platform
MetadataProvider metadata.Provider
PlaybackManager *playbackmanager.PlaybackManager
WSEventManager events.WSEventManagerInterface
Database *db.Database
DirectStreamManager *directstream.Manager
NativePlayer *nativeplayer.NativePlayer
}
)
// NewRepository creates a new injectable Repository instance
func NewRepository(opts *NewRepositoryOptions) *Repository {
ret := &Repository{
client: nil,
handler: nil,
settings: mo.Option[Settings]{},
selectionHistoryMap: result.NewResultMap[int, *hibiketorrent.AnimeTorrent](),
torrentRepository: opts.TorrentRepository,
baseAnimeCache: opts.BaseAnimeCache,
completeAnimeCache: opts.CompleteAnimeCache,
platform: opts.Platform,
wsEventManager: opts.WSEventManager,
metadataProvider: opts.MetadataProvider,
playbackManager: opts.PlaybackManager,
mediaPlayerRepository: nil,
mediaPlayerRepositorySubscriber: nil,
logger: opts.Logger,
db: opts.Database,
directStreamManager: opts.DirectStreamManager,
nativePlayer: opts.NativePlayer,
previousStreamOptions: mo.None[*StartStreamOptions](),
}
ret.client = NewClient(ret)
ret.handler = newHandler(ret)
return ret
}
func (r *Repository) IsEnabled() bool {
return r.settings.IsPresent() && r.settings.MustGet().Enabled && r.client != nil
}
func (r *Repository) GetPreviousStreamOptions() (*StartStreamOptions, bool) {
return r.previousStreamOptions.OrElse(nil), r.previousStreamOptions.IsPresent()
}
// SetMediaPlayerRepository sets the mediaplayer repository and listens to events.
// This MUST be called after instantiating the repository and will run even if the module is disabled.
//
// // Note: This is also used for Debrid streaming
func (r *Repository) SetMediaPlayerRepository(mediaPlayerRepository *mediaplayer.Repository) {
r.mediaPlayerRepository = mediaPlayerRepository
r.listenToMediaPlayerEvents()
}
// InitModules sets the settings for the torrentstream module.
// It should be called before any other method, to ensure the module is active.
func (r *Repository) InitModules(settings *models.TorrentstreamSettings, host string, port int) (err error) {
r.client.Shutdown()
defer util.HandlePanicInModuleWithError("torrentstream/InitModules", &err)
if settings == nil {
r.logger.Error().Msg("torrentstream: Cannot initialize module, no settings provided")
r.settings = mo.None[Settings]()
return errors.New("torrentstream: Cannot initialize module, no settings provided")
}
s := *settings
if s.Enabled == false {
r.logger.Info().Msg("torrentstream: Module is disabled")
r.Shutdown()
r.settings = mo.None[Settings]()
return nil
}
// Set default download directory, which is a temporary directory
if s.DownloadDir == "" {
s.DownloadDir = r.getDefaultDownloadPath()
_ = os.MkdirAll(s.DownloadDir, os.ModePerm) // Create the directory if it doesn't exist
}
// DEVNOTE: Commented code below causes error log after initializing the client
//// Empty the download directory
//_ = os.RemoveAll(s.DownloadDir)
if s.StreamingServerPort == 0 {
s.StreamingServerPort = 43214
}
if s.TorrentClientPort == 0 {
s.TorrentClientPort = 43213
}
if s.StreamingServerHost == "" {
s.StreamingServerHost = "127.0.0.1"
}
// Set the settings
r.settings = mo.Some(Settings{
TorrentstreamSettings: s,
Host: host,
Port: port,
})
// Initialize the torrent client
err = r.client.initializeClient()
if err != nil {
return err
}
// Start listening to native player events
r.listenToNativePlayerEvents()
r.logger.Info().Msg("torrentstream: Module initialized")
return nil
}
func (r *Repository) HTTPStreamHandler() http.Handler {
return r.handler
}
func (r *Repository) FailIfNoSettings() error {
if r.settings.IsAbsent() {
return errors.New("torrentstream: no settings provided, the module is dormant")
}
return nil
}
// Shutdown closes the torrent client and streaming server
// TEST-ONLY
func (r *Repository) Shutdown() {
r.logger.Debug().Msg("torrentstream: Shutting down module")
r.client.Shutdown()
}
//// Cleanup shuts down the module and removes the download directory
//func (r *Repository) Cleanup() {
// if r.settings.IsAbsent() {
// return
// }
// r.client.Close()
//
// // Remove the download directory
// downloadDir := r.GetDownloadDir()
// _ = os.RemoveAll(downloadDir)
//}
//////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
func (r *Repository) GetDownloadDir() string {
if r.settings.IsAbsent() {
return r.getDefaultDownloadPath()
}
if r.settings.MustGet().DownloadDir == "" {
return r.getDefaultDownloadPath()
}
return r.settings.MustGet().DownloadDir
}
func (r *Repository) getDefaultDownloadPath() string {
tempDir := os.TempDir()
downloadDirPath := filepath.Join(tempDir, "seanime", "torrentstream")
return downloadDirPath
}

View File

@@ -0,0 +1,391 @@
package torrentstream
import (
"context"
"fmt"
"seanime/internal/api/anilist"
"seanime/internal/api/metadata"
"seanime/internal/directstream"
"seanime/internal/events"
hibiketorrent "seanime/internal/extension/hibike/torrent"
"seanime/internal/hook"
"seanime/internal/library/playbackmanager"
"seanime/internal/util"
"strconv"
"time"
"github.com/anacrolix/torrent"
"github.com/samber/mo"
)
type PlaybackType string
const (
PlaybackTypeExternal PlaybackType = "default" // External player
PlaybackTypeExternalPlayerLink PlaybackType = "externalPlayerLink"
PlaybackTypeNativePlayer PlaybackType = "nativeplayer"
PlaybackTypeNone PlaybackType = "none"
PlaybackTypeNoneAndAwait PlaybackType = "noneAndAwait"
)
type StartStreamOptions struct {
MediaId int
EpisodeNumber int // RELATIVE Episode number to identify the file
AniDBEpisode string // Animap episode
AutoSelect bool // Automatically select the best file to stream
Torrent *hibiketorrent.AnimeTorrent // Selected torrent (Manual selection)
FileIndex *int // Index of the file to stream (Manual selection)
UserAgent string
ClientId string
PlaybackType PlaybackType
}
// StartStream is called by the client to start streaming a torrent
func (r *Repository) StartStream(ctx context.Context, opts *StartStreamOptions) (err error) {
defer util.HandlePanicInModuleWithError("torrentstream/stream/StartStream", &err)
// DEVNOTE: Do not
//r.Shutdown()
r.previousStreamOptions = mo.Some(opts)
r.logger.Info().
Str("clientId", opts.ClientId).
Any("playbackType", opts.PlaybackType).
Int("mediaId", opts.MediaId).Msgf("torrentstream: Starting stream for episode %s", opts.AniDBEpisode)
r.sendStateEvent(eventLoading)
r.wsEventManager.SendEvent(events.ShowIndefiniteLoader, "torrentstream")
defer func() {
r.wsEventManager.SendEvent(events.HideIndefiniteLoader, "torrentstream")
}()
if opts.PlaybackType == PlaybackTypeNativePlayer {
r.directStreamManager.PrepareNewStream(opts.ClientId, "Selecting torrent...")
}
//
// Get the media info
//
media, _, err := r.GetMediaInfo(ctx, opts.MediaId)
if err != nil {
return err
}
episodeNumber := opts.EpisodeNumber
aniDbEpisode := strconv.Itoa(episodeNumber)
//
// Find the best torrent / Select the torrent
//
var torrentToStream *playbackTorrent
if opts.AutoSelect {
torrentToStream, err = r.findBestTorrent(media, aniDbEpisode, episodeNumber)
if err != nil {
r.sendStateEvent(eventLoadingFailed)
return err
}
} else {
if opts.Torrent == nil {
return fmt.Errorf("torrentstream: No torrent provided")
}
torrentToStream, err = r.findBestTorrentFromManualSelection(opts.Torrent, media, aniDbEpisode, opts.FileIndex)
if err != nil {
r.sendStateEvent(eventLoadingFailed)
return err
}
}
if torrentToStream == nil {
r.sendStateEvent(eventLoadingFailed)
return fmt.Errorf("torrentstream: No torrent selected")
}
//
// Set current file & torrent
//
r.client.currentFile = mo.Some(torrentToStream.File)
r.client.currentTorrent = mo.Some(torrentToStream.Torrent)
r.sendStateEvent(eventLoading, TLSStateSendingStreamToMediaPlayer)
go func() {
// Add the torrent to the history if it is a batch & manually selected
if len(r.client.currentTorrent.MustGet().Files()) > 1 && opts.Torrent != nil {
r.AddBatchHistory(opts.MediaId, opts.Torrent) // ran in goroutine
}
}()
//
// Start the playback
//
go func() {
switch opts.PlaybackType {
case PlaybackTypeNone:
r.logger.Warn().Msg("torrentstream: Playback type is set to 'none'")
// Signal to the client that the torrent has started playing (remove loading status)
// There will be no tracking
r.sendStateEvent(eventTorrentStartedPlaying)
case PlaybackTypeNoneAndAwait:
r.logger.Warn().Msg("torrentstream: Playback type is set to 'noneAndAwait'")
// Signal to the client that the torrent has started playing (remove loading status)
// There will be no tracking
for {
if r.client.readyToStream() {
break
}
time.Sleep(3 * time.Second) // Wait for 3 secs before checking again
}
r.sendStateEvent(eventTorrentStartedPlaying)
//
// External player
//
case PlaybackTypeExternal, PlaybackTypeExternalPlayerLink:
r.sendStreamToExternalPlayer(opts, media, aniDbEpisode)
//
// Direct stream
//
case PlaybackTypeNativePlayer:
readyCh, err := r.directStreamManager.PlayTorrentStream(ctx, directstream.PlayTorrentStreamOptions{
ClientId: opts.ClientId,
EpisodeNumber: opts.EpisodeNumber,
AnidbEpisode: opts.AniDBEpisode,
Media: media.ToBaseAnime(),
Torrent: r.client.currentTorrent.MustGet(),
File: r.client.currentFile.MustGet(),
})
if err != nil {
r.logger.Error().Err(err).Msg("torrentstream: Failed to prepare new stream")
r.sendStateEvent(eventLoadingFailed)
return
}
if opts.PlaybackType == PlaybackTypeNativePlayer {
r.directStreamManager.PrepareNewStream(opts.ClientId, "Downloading metadata...")
}
// Make sure the client is ready and the torrent is partially downloaded
for {
if r.client.readyToStream() {
break
}
// If for some reason the torrent is dropped, we kill the goroutine
if r.client.torrentClient.IsAbsent() || r.client.currentTorrent.IsAbsent() {
return
}
r.logger.Debug().Msg("torrentstream: Waiting for playable threshold to be reached")
time.Sleep(3 * time.Second) // Wait for 3 secs before checking again
}
close(readyCh)
}
}()
r.sendStateEvent(eventTorrentLoaded)
r.logger.Info().Msg("torrentstream: Stream started")
return nil
}
// sendStreamToExternalPlayer sends the stream to the desktop player or external player link.
// It blocks until the some pieces have been downloaded before sending the stream for faster playback.
func (r *Repository) sendStreamToExternalPlayer(opts *StartStreamOptions, completeAnime *anilist.CompleteAnime, aniDbEpisode string) {
baseAnime := completeAnime.ToBaseAnime()
r.wsEventManager.SendEvent(events.ShowIndefiniteLoader, "torrentstream")
defer func() {
r.wsEventManager.SendEvent(events.HideIndefiniteLoader, "torrentstream")
}()
// Make sure the client is ready and the torrent is partially downloaded
for {
if r.client.readyToStream() {
break
}
// If for some reason the torrent is dropped, we kill the goroutine
if r.client.torrentClient.IsAbsent() || r.client.currentTorrent.IsAbsent() {
return
}
r.logger.Debug().Msg("torrentstream: Waiting for playable threshold to be reached")
time.Sleep(3 * time.Second) // Wait for 3 secs before checking again
}
event := &TorrentStreamSendStreamToMediaPlayerEvent{
WindowTitle: "",
StreamURL: r.client.GetStreamingUrl(),
Media: baseAnime,
AniDbEpisode: aniDbEpisode,
PlaybackType: string(opts.PlaybackType),
}
err := hook.GlobalHookManager.OnTorrentStreamSendStreamToMediaPlayer().Trigger(event)
if err != nil {
r.logger.Error().Err(err).Msg("torrentstream: Failed to trigger hook")
return
}
windowTitle := event.WindowTitle
streamURL := event.StreamURL
baseAnime = event.Media
aniDbEpisode = event.AniDbEpisode
playbackType := PlaybackType(event.PlaybackType)
if event.DefaultPrevented {
r.logger.Debug().Msg("torrentstream: Stream prevented by hook")
return
}
switch playbackType {
//
// Desktop player
//
case PlaybackTypeExternal:
r.logger.Debug().Msgf("torrentstream: Starting the media player %s", streamURL)
err = r.playbackManager.StartStreamingUsingMediaPlayer(windowTitle, &playbackmanager.StartPlayingOptions{
Payload: streamURL,
UserAgent: opts.UserAgent,
ClientId: opts.ClientId,
}, baseAnime, aniDbEpisode)
if err != nil {
// Failed to start the stream, we'll drop the torrents and stop the server
r.sendStateEvent(eventLoadingFailed)
_ = r.StopStream()
r.logger.Error().Err(err).Msg("torrentstream: Failed to start the stream")
r.wsEventManager.SendEventTo(opts.ClientId, events.ErrorToast, err.Error())
}
r.wsEventManager.SendEvent(events.ShowIndefiniteLoader, "torrentstream")
defer func() {
r.wsEventManager.SendEvent(events.HideIndefiniteLoader, "torrentstream")
}()
r.playbackManager.RegisterMediaPlayerCallback(func(event playbackmanager.PlaybackEvent, cancelFunc func()) {
switch event.(type) {
case playbackmanager.StreamStartedEvent:
r.logger.Debug().Msg("torrentstream: Media player started playing")
r.wsEventManager.SendEvent(events.HideIndefiniteLoader, "torrentstream")
cancelFunc()
}
})
//
// External player link
//
case PlaybackTypeExternalPlayerLink:
r.logger.Debug().Msgf("torrentstream: Sending stream to external player %s", streamURL)
r.wsEventManager.SendEventTo(opts.ClientId, events.ExternalPlayerOpenURL, struct {
Url string `json:"url"`
MediaId int `json:"mediaId"`
EpisodeNumber int `json:"episodeNumber"`
MediaTitle string `json:"mediaTitle"`
}{
Url: r.client.GetExternalPlayerStreamingUrl(),
MediaId: opts.MediaId,
EpisodeNumber: opts.EpisodeNumber,
MediaTitle: baseAnime.GetPreferredTitle(),
})
// Signal to the client that the torrent has started playing (remove loading status)
// We can't know for sure
r.sendStateEvent(eventTorrentStartedPlaying)
}
}
////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
type StartUntrackedStreamOptions struct {
Magnet string
FileIndex int
WindowTitle string
UserAgent string
ClientId string
PlaybackType PlaybackType
}
func (r *Repository) StopStream() error {
defer func() {
if r := recover(); r != nil {
}
}()
r.logger.Info().Msg("torrentstream: Stopping stream")
// Stop the client
// This will stop the stream and close the server
// This also sends the eventTorrentStopped event
r.client.mu.Lock()
//r.client.stopCh = make(chan struct{})
r.client.repository.logger.Debug().Msg("torrentstream: Handling media player stopped event")
// This is to prevent the client from downloading the whole torrent when the user stops watching
// Also, the torrent might be a batch - so we don't want to download the whole thing
if r.client.currentTorrent.IsPresent() {
if r.client.currentTorrentStatus.ProgressPercentage < 70 {
r.client.repository.logger.Debug().Msg("torrentstream: Dropping torrent, completion is less than 70%")
r.client.dropTorrents()
}
r.client.repository.logger.Debug().Msg("torrentstream: Resetting current torrent and status")
}
r.client.currentTorrent = mo.None[*torrent.Torrent]() // Reset the current torrent
r.client.currentFile = mo.None[*torrent.File]() // Reset the current file
r.client.currentTorrentStatus = TorrentStatus{} // Reset the torrent status
r.client.repository.sendStateEvent(eventTorrentStopped, nil) // Send torrent stopped event
r.client.repository.mediaPlayerRepository.Stop() // Stop the media player gracefully if it's running
r.client.mu.Unlock()
go func() {
r.nativePlayer.Stop()
}()
r.logger.Info().Msg("torrentstream: Stream stopped")
return nil
}
func (r *Repository) DropTorrent() error {
r.logger.Info().Msg("torrentstream: Dropping last torrent")
if r.client.torrentClient.IsAbsent() {
return nil
}
for _, t := range r.client.torrentClient.MustGet().Torrents() {
t.Drop()
}
r.mediaPlayerRepository.Stop()
r.logger.Info().Msg("torrentstream: Dropped last torrent")
return nil
}
//////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
func (r *Repository) GetMediaInfo(ctx context.Context, mediaId int) (media *anilist.CompleteAnime, animeMetadata *metadata.AnimeMetadata, err error) {
// Get the media
var found bool
media, found = r.completeAnimeCache.Get(mediaId)
if !found {
// Fetch the media
media, err = r.platform.GetAnimeWithRelations(ctx, mediaId)
if err != nil {
return nil, nil, fmt.Errorf("torrentstream: Failed to fetch media: %w", err)
}
}
// Get the media
animeMetadata, err = r.metadataProvider.GetAnimeMetadata(metadata.AnilistPlatform, mediaId)
if err != nil {
//return nil, nil, fmt.Errorf("torrentstream: Could not fetch AniDB media: %w", err)
animeMetadata = &metadata.AnimeMetadata{
Titles: make(map[string]string),
Episodes: make(map[string]*metadata.EpisodeMetadata),
EpisodeCount: 0,
SpecialCount: 0,
Mappings: &metadata.AnimeMappings{
AnilistId: media.GetID(),
},
}
animeMetadata.Titles["en"] = media.GetTitleSafe()
animeMetadata.Titles["x-jat"] = media.GetRomajiTitleSafe()
err = nil
}
return
}