427 lines
14 KiB
Go
427 lines
14 KiB
Go
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()
|
|
}
|