package directstream import ( "context" "fmt" "io" "net/http" "path/filepath" "seanime/internal/api/anilist" "seanime/internal/continuity" discordrpc_presence "seanime/internal/discordrpc/presence" "seanime/internal/library/anime" "seanime/internal/mkvparser" "seanime/internal/nativeplayer" "seanime/internal/util/result" "sync" "github.com/rs/zerolog" "github.com/samber/mo" ) // Stream is the common interface for all stream types. type Stream interface { // Type returns the type of the stream. Type() nativeplayer.StreamType // LoadContentType loads and returns the content type of the stream. // e.g. "video/mp4", "video/webm", "video/x-matroska" LoadContentType() string // ClientId returns the client ID of the current stream. ClientId() string // Media returns the media of the current stream. Media() *anilist.BaseAnime // Episode returns the episode of the current stream. Episode() *anime.Episode // ListEntryData returns the list entry data for the current stream. ListEntryData() *anime.EntryListData // EpisodeCollection returns the episode collection for the media of the current stream. EpisodeCollection() *anime.EpisodeCollection // LoadPlaybackInfo loads and returns the playback info. LoadPlaybackInfo() (*nativeplayer.PlaybackInfo, error) // GetAttachmentByName returns the attachment by name for the stream. // It is used to serve fonts and other attachments. GetAttachmentByName(filename string) (*mkvparser.AttachmentInfo, bool) // GetStreamHandler returns the stream handler. GetStreamHandler() http.Handler // StreamError is called when an error occurs while streaming. // This is used to notify the native player that an error occurred. // It will close the stream. StreamError(err error) // Terminate ends the stream. // Once this is called, the stream should not be used anymore. Terminate() // GetSubtitleEventCache accesses the subtitle event cache. GetSubtitleEventCache() *result.Map[string, *mkvparser.SubtitleEvent] // OnSubtitleFileUploaded is called when a subtitle file is uploaded. OnSubtitleFileUploaded(filename string, content string) } func (m *Manager) getStreamHandler() http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { stream, ok := m.currentStream.Get() if !ok { http.Error(w, "no stream", http.StatusInternalServerError) return } stream.GetStreamHandler().ServeHTTP(w, r) }) } func (m *Manager) PrepareNewStream(clientId string, step string) { m.prepareNewStream(clientId, step) } func (m *Manager) prepareNewStream(clientId string, step string) { // Cancel the previous playback if m.playbackCtxCancelFunc != nil { m.Logger.Trace().Msgf("directstream: Cancelling previous playback") m.playbackCtxCancelFunc() m.playbackCtxCancelFunc = nil } // Clear the current stream if it exists if stream, ok := m.currentStream.Get(); ok { m.Logger.Debug().Msgf("directstream: Terminating previous stream before preparing new stream") stream.Terminate() m.currentStream = mo.None[Stream]() } m.Logger.Debug().Msgf("directstream: Signaling native player that a new stream is starting") // Signal the native player that a new stream is starting m.nativePlayer.OpenAndAwait(clientId, step) } // loadStream loads a new stream and cancels the previous one. // Caller should use mutex to lock the manager. func (m *Manager) loadStream(stream Stream) { m.prepareNewStream(stream.ClientId(), "Loading stream...") m.Logger.Debug().Msgf("directstream: Loading stream") m.currentStream = mo.Some(stream) // Create a new context ctx, cancel := context.WithCancel(context.Background()) m.playbackCtx = ctx m.playbackCtxCancelFunc = cancel m.Logger.Debug().Msgf("directstream: Loading content type") m.nativePlayer.OpenAndAwait(stream.ClientId(), "Loading metadata...") // Load the content type contentType := stream.LoadContentType() if contentType == "" { m.Logger.Error().Msg("directstream: Failed to load content type") m.preStreamError(stream, fmt.Errorf("failed to load content type")) return } m.Logger.Debug().Msgf("directstream: Signaling native player that metadata is being loaded") // Load the playback info // If EBML, it will block until the metadata is parsed playbackInfo, err := stream.LoadPlaybackInfo() if err != nil { m.Logger.Error().Err(err).Msg("directstream: Failed to load playback info") m.preStreamError(stream, fmt.Errorf("failed to load playback info: %w", err)) return } // Shut the mkv parser logger //parser, ok := playbackInfo.MkvMetadataParser.Get() //if ok { // parser.SetLoggerEnabled(false) //} m.Logger.Debug().Msgf("directstream: Signaling native player that stream is ready") m.nativePlayer.Watch(stream.ClientId(), playbackInfo) } func (m *Manager) listenToNativePlayerEvents() { go func() { defer func() { m.Logger.Trace().Msg("directstream: Stream loop goroutine exited") }() for { select { case event := <-m.nativePlayerSubscriber.Events(): cs, ok := m.currentStream.Get() if !ok { continue } if event.GetClientId() != "" && event.GetClientId() != cs.ClientId() { continue } switch event := event.(type) { case *nativeplayer.VideoPausedEvent: m.Logger.Debug().Msgf("directstream: Video paused") // Discord if m.discordPresence != nil && !*m.isOffline { go m.discordPresence.UpdateAnimeActivity(int(event.CurrentTime), int(event.Duration), true) } case *nativeplayer.VideoResumedEvent: m.Logger.Debug().Msgf("directstream: Video resumed") // Discord if m.discordPresence != nil && !*m.isOffline { go m.discordPresence.UpdateAnimeActivity(int(event.CurrentTime), int(event.Duration), false) } case *nativeplayer.VideoEndedEvent: m.Logger.Debug().Msgf("directstream: Video ended") // Discord if m.discordPresence != nil && !*m.isOffline { go m.discordPresence.Close() } case *nativeplayer.VideoSeekedEvent: m.Logger.Debug().Msgf("directstream: Video seeked, CurrentTime: %f", event.CurrentTime) // Convert video timestamp to byte offset for subtitle extraction // if event.CurrentTime > 0 { // cs.ServeSubtitlesFromTime(event.CurrentTime) // } case *nativeplayer.VideoLoadedMetadataEvent: m.Logger.Debug().Msgf("directstream: Video loaded metadata") // Start subtitle extraction from the beginning // cs.ServeSubtitlesFromTime(0.0) if lfStream, ok := cs.(*LocalFileStream); ok { subReader, err := lfStream.newReader() if err != nil { m.Logger.Error().Err(err).Msg("directstream: Failed to create subtitle reader") cs.StreamError(fmt.Errorf("failed to create subtitle reader: %w", err)) return } lfStream.StartSubtitleStream(lfStream, m.playbackCtx, subReader, 0) } else if ts, ok := cs.(*TorrentStream); ok { subReader := ts.file.NewReader() subReader.SetResponsive() ts.StartSubtitleStream(ts, m.playbackCtx, subReader, 0) } // Discord if m.discordPresence != nil && !*m.isOffline { go m.discordPresence.SetAnimeActivity(&discordrpc_presence.AnimeActivity{ ID: cs.Media().GetID(), Title: cs.Media().GetPreferredTitle(), Image: cs.Media().GetCoverImageSafe(), IsMovie: cs.Media().IsMovie(), EpisodeNumber: cs.Episode().ProgressNumber, Progress: int(event.CurrentTime), Duration: int(event.Duration), }) } case *nativeplayer.VideoErrorEvent: m.Logger.Debug().Msgf("directstream: Video error, Error: %s", event.Error) cs.StreamError(fmt.Errorf(event.Error)) // Discord if m.discordPresence != nil && !*m.isOffline { go m.discordPresence.Close() } case *nativeplayer.SubtitleFileUploadedEvent: m.Logger.Debug().Msgf("directstream: Subtitle file uploaded, Filename: %s", event.Filename) cs.OnSubtitleFileUploaded(event.Filename, event.Content) case *nativeplayer.VideoTerminatedEvent: m.Logger.Debug().Msgf("directstream: Video terminated") cs.Terminate() // Discord if m.discordPresence != nil && !*m.isOffline { go m.discordPresence.Close() } case *nativeplayer.VideoStatusEvent: _ = m.continuityManager.UpdateWatchHistoryItem(&continuity.UpdateWatchHistoryItemOptions{ CurrentTime: event.Status.CurrentTime, Duration: event.Status.Duration, MediaId: cs.Media().GetID(), EpisodeNumber: cs.Episode().GetEpisodeNumber(), Kind: continuity.MediastreamKind, }) // Discord if m.discordPresence != nil && !*m.isOffline { go m.discordPresence.UpdateAnimeActivity(int(event.Status.CurrentTime), int(event.Status.Duration), event.Status.Paused) } case *nativeplayer.VideoCompletedEvent: m.Logger.Debug().Msgf("directstream: Video completed") if baseStream, ok := cs.(*BaseStream); ok { baseStream.updateProgress.Do(func() { mediaId := baseStream.media.GetID() epNum := baseStream.episode.GetProgressNumber() totalEpisodes := baseStream.media.GetTotalEpisodeCount() // total episode count or -1 _ = baseStream.manager.platform.UpdateEntryProgress(context.Background(), mediaId, epNum, &totalEpisodes) }) } } } } }() } func (m *Manager) unloadStream() { m.playbackMu.Lock() defer m.playbackMu.Unlock() m.Logger.Debug().Msg("directstream: Unloading current stream") // Cancel any existing playback context first if m.playbackCtxCancelFunc != nil { m.Logger.Trace().Msg("directstream: Cancelling playback context") m.playbackCtxCancelFunc() m.playbackCtxCancelFunc = nil } // Clear the current stream if stream, ok := m.currentStream.Get(); ok { m.Logger.Debug().Msg("directstream: Terminating current stream") stream.Terminate() } m.currentStream = mo.None[Stream]() m.Logger.Debug().Msg("directstream: Stream unloaded successfully") } /////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// type BaseStream struct { logger *zerolog.Logger clientId string contentType string contentTypeOnce sync.Once episode *anime.Episode media *anilist.BaseAnime listEntryData *anime.EntryListData episodeCollection *anime.EpisodeCollection playbackInfo *nativeplayer.PlaybackInfo playbackInfoErr error playbackInfoOnce sync.Once subtitleEventCache *result.Map[string, *mkvparser.SubtitleEvent] terminateOnce sync.Once serveContentCancelFunc context.CancelFunc filename string // Name of the file being streamed, if applicable // Subtitle stream management activeSubtitleStreams *result.Map[string, *SubtitleStream] manager *Manager updateProgress sync.Once } var _ Stream = (*BaseStream)(nil) func (s *BaseStream) GetAttachmentByName(filename string) (*mkvparser.AttachmentInfo, bool) { return nil, false } func (s *BaseStream) GetStreamHandler() http.Handler { return nil } func (s *BaseStream) LoadContentType() string { return s.contentType } func (s *BaseStream) LoadPlaybackInfo() (*nativeplayer.PlaybackInfo, error) { return s.playbackInfo, s.playbackInfoErr } func (s *BaseStream) Type() nativeplayer.StreamType { return "" } func (s *BaseStream) Media() *anilist.BaseAnime { return s.media } func (s *BaseStream) Episode() *anime.Episode { return s.episode } func (s *BaseStream) ListEntryData() *anime.EntryListData { return s.listEntryData } func (s *BaseStream) EpisodeCollection() *anime.EpisodeCollection { return s.episodeCollection } func (s *BaseStream) ClientId() string { return s.clientId } func (s *BaseStream) Terminate() { s.terminateOnce.Do(func() { // Cancel the playback context // This will snowball and cancel other stuff if s.manager.playbackCtxCancelFunc != nil { s.manager.playbackCtxCancelFunc() } // Cancel all active subtitle streams s.activeSubtitleStreams.Range(func(_ string, s *SubtitleStream) bool { s.cleanupFunc() return true }) s.activeSubtitleStreams.Clear() s.subtitleEventCache.Clear() }) } func (s *BaseStream) StreamError(err error) { s.logger.Error().Err(err).Msg("directstream: Stream error occurred") s.manager.nativePlayer.Error(s.clientId, err) s.Terminate() s.manager.unloadStream() } func (s *BaseStream) GetSubtitleEventCache() *result.Map[string, *mkvparser.SubtitleEvent] { return s.subtitleEventCache } //////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// // Helpers //////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// // loadContentType loads the content type of the file. // If the content type cannot be determined from the file extension, // the first reader will be used to determine the content type. func loadContentType(path string, reader ...io.ReadSeekCloser) string { ext := filepath.Ext(path) switch ext { case ".mp4": return "video/mp4" case ".mkv": //return "video/x-matroska" return "video/webm" case ".webm", ".m4v": return "video/webm" case ".avi": return "video/x-msvideo" case ".mov": return "video/quicktime" case ".flv": return "video/x-flv" default: } // No extension found // Read the first 1KB to determine the content type if len(reader) > 0 { if mimeType, ok := mkvparser.ReadIsMkvOrWebm(reader[0]); ok { return mimeType } } return "" } func (m *Manager) preStreamError(stream Stream, err error) { stream.Terminate() m.nativePlayer.Error(stream.ClientId(), err) m.unloadStream() }