392 lines
13 KiB
Go
392 lines
13 KiB
Go
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
|
|
}
|