568 lines
18 KiB
Go
568 lines
18 KiB
Go
package debrid_client
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"seanime/internal/database/db_bridge"
|
|
"seanime/internal/debrid/debrid"
|
|
"seanime/internal/directstream"
|
|
"seanime/internal/events"
|
|
hibiketorrent "seanime/internal/extension/hibike/torrent"
|
|
"seanime/internal/hook"
|
|
"seanime/internal/library/playbackmanager"
|
|
"seanime/internal/util"
|
|
"strconv"
|
|
"sync"
|
|
"time"
|
|
|
|
"github.com/samber/mo"
|
|
)
|
|
|
|
type (
|
|
StreamManager struct {
|
|
repository *Repository
|
|
currentTorrentItemId string
|
|
downloadCtxCancelFunc context.CancelFunc
|
|
|
|
currentStreamUrl string
|
|
|
|
playbackSubscriberCtxCancelFunc context.CancelFunc
|
|
}
|
|
|
|
StreamPlaybackType string
|
|
|
|
StreamStatus string
|
|
|
|
StreamState struct {
|
|
Status StreamStatus `json:"status"`
|
|
TorrentName string `json:"torrentName"`
|
|
Message string `json:"message"`
|
|
}
|
|
|
|
StartStreamOptions struct {
|
|
MediaId int
|
|
EpisodeNumber int // RELATIVE Episode number to identify the file
|
|
AniDBEpisode string // Anizip episode
|
|
Torrent *hibiketorrent.AnimeTorrent // Selected torrent
|
|
FileId string // File ID or index
|
|
FileIndex *int // Index of the file to stream (Manual selection)
|
|
UserAgent string
|
|
ClientId string
|
|
PlaybackType StreamPlaybackType
|
|
AutoSelect bool
|
|
}
|
|
|
|
CancelStreamOptions struct {
|
|
// Whether to remove the torrent from the debrid service
|
|
RemoveTorrent bool `json:"removeTorrent"`
|
|
}
|
|
)
|
|
|
|
const (
|
|
StreamStatusDownloading StreamStatus = "downloading"
|
|
StreamStatusReady StreamStatus = "ready"
|
|
StreamStatusFailed StreamStatus = "failed"
|
|
StreamStatusStarted StreamStatus = "started"
|
|
)
|
|
|
|
func NewStreamManager(repository *Repository) *StreamManager {
|
|
return &StreamManager{
|
|
repository: repository,
|
|
currentTorrentItemId: "",
|
|
}
|
|
}
|
|
|
|
//////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
|
|
|
|
const (
|
|
PlaybackTypeNone StreamPlaybackType = "none"
|
|
PlaybackTypeNoneAndAwait StreamPlaybackType = "noneAndAwait"
|
|
PlaybackTypeDefault StreamPlaybackType = "default"
|
|
PlaybackTypeNativePlayer StreamPlaybackType = "nativeplayer"
|
|
PlaybackTypeExternalPlayer StreamPlaybackType = "externalPlayerLink"
|
|
)
|
|
|
|
// startStream is called by the client to start streaming a torrent
|
|
func (s *StreamManager) startStream(ctx context.Context, opts *StartStreamOptions) (err error) {
|
|
defer util.HandlePanicInModuleWithError("debrid/client/StartStream", &err)
|
|
|
|
s.repository.previousStreamOptions = mo.Some(opts)
|
|
|
|
s.repository.logger.Info().
|
|
Str("clientId", opts.ClientId).
|
|
Any("playbackType", opts.PlaybackType).
|
|
Int("mediaId", opts.MediaId).Msgf("debridstream: Starting stream for episode %s", opts.AniDBEpisode)
|
|
|
|
// Cancel the download context if it's running
|
|
if s.downloadCtxCancelFunc != nil {
|
|
s.downloadCtxCancelFunc()
|
|
s.downloadCtxCancelFunc = nil
|
|
}
|
|
|
|
if s.playbackSubscriberCtxCancelFunc != nil {
|
|
s.playbackSubscriberCtxCancelFunc()
|
|
s.playbackSubscriberCtxCancelFunc = nil
|
|
}
|
|
|
|
provider, err := s.repository.GetProvider()
|
|
if err != nil {
|
|
return fmt.Errorf("debridstream: Failed to start stream: %w", err)
|
|
}
|
|
|
|
s.repository.wsEventManager.SendEvent(events.ShowIndefiniteLoader, "debridstream")
|
|
//defer func() {
|
|
// s.repository.wsEventManager.SendEvent(events.HideIndefiniteLoader, "debridstream")
|
|
//}()
|
|
|
|
if opts.PlaybackType == PlaybackTypeNativePlayer {
|
|
s.repository.directStreamManager.PrepareNewStream(opts.ClientId, "Selecting torrent...")
|
|
}
|
|
|
|
//
|
|
// Get the media info
|
|
//
|
|
media, _, err := s.getMediaInfo(ctx, opts.MediaId)
|
|
if err != nil {
|
|
s.repository.wsEventManager.SendEvent(events.HideIndefiniteLoader, "debridstream")
|
|
return err
|
|
}
|
|
|
|
episodeNumber := opts.EpisodeNumber
|
|
aniDbEpisode := strconv.Itoa(episodeNumber)
|
|
|
|
selectedTorrent := opts.Torrent
|
|
fileId := opts.FileId
|
|
|
|
if opts.AutoSelect {
|
|
|
|
s.repository.wsEventManager.SendEvent(events.DebridStreamState, StreamState{
|
|
Status: StreamStatusDownloading,
|
|
TorrentName: "-",
|
|
Message: "Selecting best torrent...",
|
|
})
|
|
|
|
st, fi, err := s.repository.findBestTorrent(provider, media, opts.EpisodeNumber)
|
|
if err != nil {
|
|
s.repository.wsEventManager.SendEvent(events.DebridStreamState, StreamState{
|
|
Status: StreamStatusFailed,
|
|
TorrentName: "-",
|
|
Message: fmt.Sprintf("Failed to select best torrent, %v", err),
|
|
})
|
|
s.repository.wsEventManager.SendEvent(events.HideIndefiniteLoader, "debridstream")
|
|
return fmt.Errorf("debridstream: Failed to start stream: %w", err)
|
|
}
|
|
selectedTorrent = st
|
|
fileId = fi
|
|
} else {
|
|
// Manual selection
|
|
if selectedTorrent == nil {
|
|
s.repository.wsEventManager.SendEvent(events.HideIndefiniteLoader, "debridstream")
|
|
return fmt.Errorf("debridstream: Failed to start stream, no torrent provided")
|
|
}
|
|
|
|
s.repository.wsEventManager.SendEvent(events.DebridStreamState, StreamState{
|
|
Status: StreamStatusDownloading,
|
|
TorrentName: selectedTorrent.Name,
|
|
Message: "Analyzing selected torrent...",
|
|
})
|
|
|
|
// If no fileId is provided, we need to analyze the torrent to find the correct file
|
|
if fileId == "" {
|
|
var chosenFileIndex *int
|
|
if opts.FileIndex != nil {
|
|
chosenFileIndex = opts.FileIndex
|
|
}
|
|
st, fi, err := s.repository.findBestTorrentFromManualSelection(provider, selectedTorrent, media, opts.EpisodeNumber, chosenFileIndex)
|
|
if err != nil {
|
|
s.repository.wsEventManager.SendEvent(events.DebridStreamState, StreamState{
|
|
Status: StreamStatusFailed,
|
|
TorrentName: selectedTorrent.Name,
|
|
Message: fmt.Sprintf("Failed to analyze torrent, %v", err),
|
|
})
|
|
s.repository.wsEventManager.SendEvent(events.HideIndefiniteLoader, "debridstream")
|
|
return fmt.Errorf("debridstream: Failed to analyze torrent: %w", err)
|
|
}
|
|
selectedTorrent = st
|
|
fileId = fi
|
|
}
|
|
}
|
|
|
|
if selectedTorrent == nil {
|
|
s.repository.wsEventManager.SendEvent(events.HideIndefiniteLoader, "debridstream")
|
|
return fmt.Errorf("debridstream: Failed to start stream, no torrent provided")
|
|
}
|
|
|
|
s.repository.wsEventManager.SendEvent(events.DebridStreamState, StreamState{
|
|
Status: StreamStatusDownloading,
|
|
TorrentName: selectedTorrent.Name,
|
|
Message: "Adding torrent...",
|
|
})
|
|
|
|
// Add the torrent to the debrid service
|
|
torrentItemId, err := provider.AddTorrent(debrid.AddTorrentOptions{
|
|
MagnetLink: selectedTorrent.MagnetLink,
|
|
InfoHash: selectedTorrent.InfoHash,
|
|
SelectFileId: fileId, // RD-only, download only the selected file
|
|
})
|
|
if err != nil {
|
|
s.repository.wsEventManager.SendEvent(events.DebridStreamState, StreamState{
|
|
Status: StreamStatusFailed,
|
|
TorrentName: selectedTorrent.Name,
|
|
Message: fmt.Sprintf("Failed to add torrent, %v", err),
|
|
})
|
|
s.repository.wsEventManager.SendEvent(events.HideIndefiniteLoader, "debridstream")
|
|
return fmt.Errorf("debridstream: Failed to add torrent: %w", err)
|
|
}
|
|
|
|
time.Sleep(1 * time.Second)
|
|
|
|
// Save the current torrent item id
|
|
s.currentTorrentItemId = torrentItemId
|
|
ctx, cancelCtx := context.WithCancel(context.Background())
|
|
s.downloadCtxCancelFunc = cancelCtx
|
|
|
|
readyCh := make(chan struct{})
|
|
readyOnce := sync.Once{}
|
|
ready := func() {
|
|
readyOnce.Do(func() {
|
|
close(readyCh)
|
|
})
|
|
}
|
|
|
|
// Launch a goroutine that will listen to the added torrent's status
|
|
go func(ctx context.Context) {
|
|
defer util.HandlePanicInModuleThen("debrid/client/StartStream", func() {})
|
|
defer func() {
|
|
s.repository.wsEventManager.SendEvent(events.HideIndefiniteLoader, "debridstream")
|
|
}()
|
|
|
|
defer func() {
|
|
// Cancel the context
|
|
if s.downloadCtxCancelFunc != nil {
|
|
s.downloadCtxCancelFunc()
|
|
s.downloadCtxCancelFunc = nil
|
|
}
|
|
}()
|
|
|
|
s.repository.logger.Debug().Msg("debridstream: Listening to torrent status")
|
|
|
|
s.repository.wsEventManager.SendEvent(events.DebridStreamState, StreamState{
|
|
Status: StreamStatusDownloading,
|
|
TorrentName: selectedTorrent.Name,
|
|
Message: fmt.Sprintf("Downloading torrent..."),
|
|
})
|
|
|
|
itemCh := make(chan debrid.TorrentItem, 1)
|
|
|
|
go func() {
|
|
for item := range itemCh {
|
|
if opts.PlaybackType == PlaybackTypeNativePlayer {
|
|
s.repository.directStreamManager.PrepareNewStream(opts.ClientId, fmt.Sprintf("Awaiting stream: %d%%", item.CompletionPercentage))
|
|
}
|
|
|
|
s.repository.wsEventManager.SendEvent(events.DebridStreamState, StreamState{
|
|
Status: StreamStatusDownloading,
|
|
TorrentName: item.Name,
|
|
Message: fmt.Sprintf("Downloading torrent: %d%%", item.CompletionPercentage),
|
|
})
|
|
}
|
|
}()
|
|
|
|
// Await the stream URL
|
|
// For Torbox, this will wait until the entire torrent is downloaded
|
|
streamUrl, err := provider.GetTorrentStreamUrl(ctx, debrid.StreamTorrentOptions{
|
|
ID: torrentItemId,
|
|
FileId: fileId,
|
|
}, itemCh)
|
|
|
|
go func() {
|
|
close(itemCh)
|
|
}()
|
|
|
|
if ctx.Err() != nil {
|
|
s.repository.logger.Debug().Msg("debridstream: Context cancelled, stopping stream")
|
|
ready()
|
|
return
|
|
}
|
|
|
|
if err != nil {
|
|
s.repository.logger.Err(err).Msg("debridstream: Failed to get stream URL")
|
|
if !errors.Is(err, context.Canceled) {
|
|
s.repository.wsEventManager.SendEvent(events.DebridStreamState, StreamState{
|
|
Status: StreamStatusFailed,
|
|
TorrentName: selectedTorrent.Name,
|
|
Message: fmt.Sprintf("Failed to get stream URL, %v", err),
|
|
})
|
|
}
|
|
ready()
|
|
return
|
|
}
|
|
|
|
skipCheckEvent := &DebridSkipStreamCheckEvent{
|
|
StreamURL: streamUrl,
|
|
Retries: 4,
|
|
RetryDelay: 8,
|
|
}
|
|
_ = hook.GlobalHookManager.OnDebridSkipStreamCheck().Trigger(skipCheckEvent)
|
|
streamUrl = skipCheckEvent.StreamURL
|
|
|
|
// Default prevented, we check if we can stream the file
|
|
if skipCheckEvent.DefaultPrevented {
|
|
s.repository.logger.Debug().Msg("debridstream: Stream URL received, checking stream file")
|
|
s.repository.wsEventManager.SendEvent(events.DebridStreamState, StreamState{
|
|
Status: StreamStatusDownloading,
|
|
TorrentName: selectedTorrent.Name,
|
|
Message: "Checking stream file...",
|
|
})
|
|
|
|
retries := 0
|
|
|
|
streamUrlCheckLoop:
|
|
for { // Retry loop for a total of 4 times (32 seconds)
|
|
select {
|
|
case <-ctx.Done():
|
|
s.repository.logger.Debug().Msg("debridstream: Context cancelled, stopping stream")
|
|
return
|
|
default:
|
|
// Check if we can stream the URL
|
|
if canStream, reason := CanStream(streamUrl); !canStream {
|
|
if retries >= skipCheckEvent.Retries {
|
|
s.repository.logger.Error().Msg("debridstream: Cannot stream the file")
|
|
|
|
s.repository.wsEventManager.SendEvent(events.DebridStreamState, StreamState{
|
|
Status: StreamStatusFailed,
|
|
TorrentName: selectedTorrent.Name,
|
|
Message: fmt.Sprintf("Cannot stream this file: %s", reason),
|
|
})
|
|
return
|
|
}
|
|
s.repository.logger.Warn().Msg("debridstream: Rechecking stream file in 8 seconds")
|
|
s.repository.wsEventManager.SendEvent(events.DebridStreamState, StreamState{
|
|
Status: StreamStatusDownloading,
|
|
TorrentName: selectedTorrent.Name,
|
|
Message: "Checking stream file...",
|
|
})
|
|
retries++
|
|
time.Sleep(time.Duration(skipCheckEvent.RetryDelay) * time.Second)
|
|
continue
|
|
}
|
|
break streamUrlCheckLoop
|
|
}
|
|
}
|
|
}
|
|
|
|
s.repository.logger.Debug().Msg("debridstream: Stream is ready")
|
|
|
|
// Signal to the client that the torrent is ready to stream
|
|
s.repository.wsEventManager.SendEvent(events.DebridStreamState, StreamState{
|
|
Status: StreamStatusReady,
|
|
TorrentName: selectedTorrent.Name,
|
|
Message: "Ready to stream the file",
|
|
})
|
|
|
|
if ctx.Err() != nil {
|
|
s.repository.logger.Debug().Msg("debridstream: Context cancelled, stopping stream")
|
|
ready()
|
|
return
|
|
}
|
|
|
|
windowTitle := media.GetPreferredTitle()
|
|
if !media.IsMovieOrSingleEpisode() {
|
|
windowTitle += fmt.Sprintf(" - Episode %s", aniDbEpisode)
|
|
}
|
|
|
|
event := &DebridSendStreamToMediaPlayerEvent{
|
|
WindowTitle: windowTitle,
|
|
StreamURL: streamUrl,
|
|
Media: media.ToBaseAnime(),
|
|
AniDbEpisode: aniDbEpisode,
|
|
PlaybackType: string(opts.PlaybackType),
|
|
}
|
|
err = hook.GlobalHookManager.OnDebridSendStreamToMediaPlayer().Trigger(event)
|
|
if err != nil {
|
|
s.repository.logger.Err(err).Msg("debridstream: Failed to send stream to media player")
|
|
}
|
|
windowTitle = event.WindowTitle
|
|
streamUrl = event.StreamURL
|
|
media := event.Media
|
|
aniDbEpisode := event.AniDbEpisode
|
|
playbackType := StreamPlaybackType(event.PlaybackType)
|
|
|
|
if event.DefaultPrevented {
|
|
s.repository.logger.Debug().Msg("debridstream: Stream prevented by hook")
|
|
ready()
|
|
return
|
|
}
|
|
|
|
s.currentStreamUrl = streamUrl
|
|
|
|
switch playbackType {
|
|
case PlaybackTypeNone:
|
|
// No playback type selected, just signal to the client that the stream is ready
|
|
s.repository.wsEventManager.SendEvent(events.DebridStreamState, StreamState{
|
|
Status: StreamStatusReady,
|
|
TorrentName: selectedTorrent.Name,
|
|
Message: "External player link sent",
|
|
})
|
|
case PlaybackTypeNoneAndAwait:
|
|
// No playback type selected, just signal to the client that the stream is ready
|
|
s.repository.wsEventManager.SendEvent(events.DebridStreamState, StreamState{
|
|
Status: StreamStatusReady,
|
|
TorrentName: selectedTorrent.Name,
|
|
Message: "External player link sent",
|
|
})
|
|
ready()
|
|
|
|
case PlaybackTypeDefault:
|
|
//
|
|
// Start the stream
|
|
//
|
|
s.repository.logger.Debug().Msg("debridstream: Starting the media player")
|
|
|
|
s.repository.wsEventManager.SendEvent(events.InfoToast, "Sending stream to media player...")
|
|
s.repository.wsEventManager.SendEvent(events.ShowIndefiniteLoader, "debridstream")
|
|
|
|
var playbackSubscriberCtx context.Context
|
|
playbackSubscriberCtx, s.playbackSubscriberCtxCancelFunc = context.WithCancel(context.Background())
|
|
playbackSubscriber := s.repository.playbackManager.SubscribeToPlaybackStatus("debridstream")
|
|
|
|
// Sends the stream to the media player
|
|
// DEVNOTE: Events are handled by the torrentstream.Repository module
|
|
err = s.repository.playbackManager.StartStreamingUsingMediaPlayer(windowTitle, &playbackmanager.StartPlayingOptions{
|
|
Payload: streamUrl,
|
|
UserAgent: opts.UserAgent,
|
|
ClientId: opts.ClientId,
|
|
}, media, aniDbEpisode)
|
|
if err != nil {
|
|
go s.repository.playbackManager.UnsubscribeFromPlaybackStatus("debridstream")
|
|
if s.playbackSubscriberCtxCancelFunc != nil {
|
|
s.playbackSubscriberCtxCancelFunc()
|
|
s.playbackSubscriberCtxCancelFunc = nil
|
|
}
|
|
// Failed to start the stream, we'll drop the torrents and stop the server
|
|
s.repository.wsEventManager.SendEvent(events.DebridStreamState, StreamState{
|
|
Status: StreamStatusFailed,
|
|
TorrentName: selectedTorrent.Name,
|
|
Message: fmt.Sprintf("Failed to send the stream to the media player, %v", err),
|
|
})
|
|
return
|
|
}
|
|
|
|
// Listen to the playback status
|
|
// Reset the current stream url when playback is stopped
|
|
go func() {
|
|
defer util.HandlePanicInModuleThen("debridstream/PlaybackSubscriber", func() {})
|
|
defer func() {
|
|
if s.playbackSubscriberCtxCancelFunc != nil {
|
|
s.playbackSubscriberCtxCancelFunc()
|
|
s.playbackSubscriberCtxCancelFunc = nil
|
|
}
|
|
}()
|
|
select {
|
|
case <-playbackSubscriberCtx.Done():
|
|
s.repository.wsEventManager.SendEvent(events.HideIndefiniteLoader, "debridstream")
|
|
s.repository.playbackManager.UnsubscribeFromPlaybackStatus("debridstream")
|
|
s.currentStreamUrl = ""
|
|
case event := <-playbackSubscriber.EventCh:
|
|
switch event.(type) {
|
|
case playbackmanager.StreamStartedEvent:
|
|
s.repository.wsEventManager.SendEvent(events.HideIndefiniteLoader, "debridstream")
|
|
case playbackmanager.StreamStoppedEvent:
|
|
go s.repository.playbackManager.UnsubscribeFromPlaybackStatus("debridstream")
|
|
s.currentStreamUrl = ""
|
|
}
|
|
}
|
|
}()
|
|
|
|
case PlaybackTypeExternalPlayer:
|
|
// Send the external player link
|
|
s.repository.wsEventManager.SendEventTo(opts.ClientId, events.ExternalPlayerOpenURL, struct {
|
|
Url string `json:"url"`
|
|
MediaId int `json:"mediaId"`
|
|
EpisodeNumber int `json:"episodeNumber"`
|
|
MediaTitle string `json:"mediaTitle"`
|
|
}{
|
|
Url: streamUrl,
|
|
MediaId: opts.MediaId,
|
|
EpisodeNumber: opts.EpisodeNumber,
|
|
MediaTitle: media.GetPreferredTitle(),
|
|
})
|
|
|
|
// Signal to the client that the torrent has started playing (remove loading status)
|
|
// We can't know for sure
|
|
s.repository.wsEventManager.SendEvent(events.DebridStreamState, StreamState{
|
|
Status: StreamStatusReady,
|
|
TorrentName: selectedTorrent.Name,
|
|
Message: "External player link sent",
|
|
})
|
|
case PlaybackTypeNativePlayer:
|
|
err := s.repository.directStreamManager.PlayDebridStream(ctx, directstream.PlayDebridStreamOptions{
|
|
StreamUrl: streamUrl,
|
|
MediaId: media.ID,
|
|
EpisodeNumber: opts.EpisodeNumber,
|
|
AnidbEpisode: opts.AniDBEpisode,
|
|
Media: media,
|
|
Torrent: selectedTorrent,
|
|
FileId: fileId,
|
|
UserAgent: opts.UserAgent,
|
|
ClientId: opts.ClientId,
|
|
AutoSelect: false,
|
|
})
|
|
if err != nil {
|
|
s.repository.logger.Error().Err(err).Msg("directstream: Failed to prepare new stream")
|
|
return
|
|
}
|
|
}
|
|
|
|
go func() {
|
|
defer util.HandlePanicInModuleThen("debridstream/AddBatchHistory", func() {})
|
|
|
|
_ = db_bridge.InsertTorrentstreamHistory(s.repository.db, media.GetID(), selectedTorrent)
|
|
}()
|
|
}(ctx)
|
|
|
|
s.repository.wsEventManager.SendEvent(events.DebridStreamState, StreamState{
|
|
Status: StreamStatusStarted,
|
|
TorrentName: selectedTorrent.Name,
|
|
Message: "Stream started",
|
|
})
|
|
s.repository.logger.Info().Msg("debridstream: Stream started")
|
|
|
|
if opts.PlaybackType == PlaybackTypeNoneAndAwait {
|
|
s.repository.logger.Debug().Msg("debridstream: Waiting for stream to be ready")
|
|
<-readyCh
|
|
s.repository.wsEventManager.SendEvent(events.HideIndefiniteLoader, "debridstream")
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
func (s *StreamManager) cancelStream(opts *CancelStreamOptions) {
|
|
if s.downloadCtxCancelFunc != nil {
|
|
s.downloadCtxCancelFunc()
|
|
s.downloadCtxCancelFunc = nil
|
|
}
|
|
|
|
s.repository.wsEventManager.SendEvent(events.ShowIndefiniteLoader, "debridstream")
|
|
|
|
s.currentStreamUrl = ""
|
|
|
|
if opts.RemoveTorrent && s.currentTorrentItemId != "" {
|
|
// Remove the torrent from the debrid service
|
|
provider, err := s.repository.GetProvider()
|
|
if err != nil {
|
|
s.repository.logger.Err(err).Msg("debridstream: Failed to remove torrent")
|
|
return
|
|
}
|
|
|
|
// Remove the torrent from the debrid service
|
|
err = provider.DeleteTorrent(s.currentTorrentItemId)
|
|
if err != nil {
|
|
s.repository.logger.Err(err).Msg("debridstream: Failed to remove torrent")
|
|
}
|
|
}
|
|
}
|
|
|
|
//////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
|