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") } } } //////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////