node build fixed

This commit is contained in:
ra_ma
2025-09-20 14:08:38 +01:00
parent c6ebbe069d
commit 3d298fa434
1516 changed files with 535727 additions and 2 deletions

View File

@@ -0,0 +1,731 @@
package playbackmanager
import (
"context"
"errors"
"fmt"
"seanime/internal/api/anilist"
"seanime/internal/api/metadata"
"seanime/internal/continuity"
"seanime/internal/database/db"
"seanime/internal/database/db_bridge"
discordrpc_presence "seanime/internal/discordrpc/presence"
"seanime/internal/events"
"seanime/internal/hook"
"seanime/internal/library/anime"
"seanime/internal/mediaplayers/mediaplayer"
"seanime/internal/platforms/platform"
"seanime/internal/util"
"seanime/internal/util/result"
"sync"
"sync/atomic"
"github.com/google/uuid"
"github.com/rs/zerolog"
"github.com/samber/mo"
)
const (
LocalFilePlayback PlaybackType = "localfile"
StreamPlayback PlaybackType = "stream"
ManualTrackingPlayback PlaybackType = "manual"
)
var playbackStatePool = sync.Pool{
New: func() interface{} {
return &PlaybackState{}
},
}
type (
PlaybackType string
// PlaybackManager manages video playback (local and stream) and progress tracking for desktop media players.
// It receives and dispatch appropriate events for:
// - Syncing progress with AniList, etc.
// - Sending notifications to the client
PlaybackManager struct {
Logger *zerolog.Logger
Database *db.Database
MediaPlayerRepository *mediaplayer.Repository // MediaPlayerRepository is used to control the media player
continuityManager *continuity.Manager
settings *Settings
discordPresence *discordrpc_presence.Presence // DiscordPresence is used to update the user's Discord presence
mediaPlayerRepoSubscriber *mediaplayer.RepositorySubscriber // Used to listen for media player events
wsEventManager events.WSEventManagerInterface
platform platform.Platform
metadataProvider metadata.Provider
refreshAnimeCollectionFunc func() // This function is called to refresh the AniList collection
mu sync.Mutex
eventMu sync.RWMutex
cancel context.CancelFunc
// historyMap stores a PlaybackState whose state is "completed"
// Since PlaybackState is sent to client continuously, once a PlaybackState is stored in historyMap, only IT will be sent to the client.
// This is so when the user seeks back to a video, the client can show the last known "completed" state of the video
historyMap map[string]PlaybackState
currentPlaybackType PlaybackType
currentMediaPlaybackStatus *mediaplayer.PlaybackStatus // The current video playback status (can be nil)
autoPlayMu sync.Mutex
nextEpisodeLocalFile mo.Option[*anime.LocalFile] // The next episode's local file (for local file playback)
// currentMediaListEntry for Local file playback & stream playback
// For Local file playback, it MUST be set
// For Stream playback, it is optional
// See [progress_tracking.go] for how it is handled
currentMediaListEntry mo.Option[*anilist.AnimeListEntry] // List Entry for the current video playback
// \/ Local file playback
currentLocalFile mo.Option[*anime.LocalFile] // Local file for the current video playback
currentLocalFileWrapperEntry mo.Option[*anime.LocalFileWrapperEntry] // This contains the current media entry local file data
// \/ Stream playback
// The current episode being streamed, set in [StartStreamingUsingMediaPlayer] by finding the episode in currentStreamEpisodeCollection
currentStreamEpisode mo.Option[*anime.Episode]
// The current media being streamed, set in [StartStreamingUsingMediaPlayer]
currentStreamMedia mo.Option[*anilist.BaseAnime]
currentStreamAniDbEpisode mo.Option[string]
// \/ Manual progress tracking (non-integrated external player)
manualTrackingCtx context.Context
manualTrackingCtxCancel context.CancelFunc
manualTrackingPlaybackState PlaybackState
currentManualTrackingState mo.Option[*ManualTrackingState]
manualTrackingWg sync.WaitGroup
// \/ Playlist
playlistHub *playlistHub // The playlist hub
isOffline *bool
animeCollection mo.Option[*anilist.AnimeCollection]
playbackStatusSubscribers *result.Map[string, *PlaybackStatusSubscriber]
}
// PlaybackStatusSubscriber provides a single event channel for all playback events
PlaybackStatusSubscriber struct {
EventCh chan PlaybackEvent
canceled atomic.Bool
}
// PlaybackEvent is the base interface for all playback events
PlaybackEvent interface {
Type() string
}
PlaybackStartingEvent struct {
Filepath string
PlaybackType PlaybackType
Media *anilist.BaseAnime
AniDbEpisode string
EpisodeNumber int
WindowTitle string
}
// Local file playback events
PlaybackStatusChangedEvent struct {
Status mediaplayer.PlaybackStatus
State PlaybackState
}
VideoStartedEvent struct {
Filename string
Filepath string
}
VideoStoppedEvent struct {
Reason string
}
VideoCompletedEvent struct {
Filename string
}
// Stream playback events
StreamStateChangedEvent struct {
State PlaybackState
}
StreamStatusChangedEvent struct {
Status mediaplayer.PlaybackStatus
}
StreamStartedEvent struct {
Filename string
Filepath string
}
StreamStoppedEvent struct {
Reason string
}
StreamCompletedEvent struct {
Filename string
}
PlaybackStateType string
// PlaybackState is used to keep track of the user's current video playback
// It is sent to the client each time the video playback state is picked up -- this is used to update the client's UI
PlaybackState struct {
EpisodeNumber int `json:"episodeNumber"` // The episode number
AniDbEpisode string `json:"aniDbEpisode"` // The AniDB episode number
MediaTitle string `json:"mediaTitle"` // The title of the media
MediaCoverImage string `json:"mediaCoverImage"` // The cover image of the media
MediaTotalEpisodes int `json:"mediaTotalEpisodes"` // The total number of episodes
Filename string `json:"filename"` // The filename
CompletionPercentage float64 `json:"completionPercentage"` // The completion percentage
CanPlayNext bool `json:"canPlayNext"` // Whether the next episode can be played
ProgressUpdated bool `json:"progressUpdated"` // Whether the progress has been updated
MediaId int `json:"mediaId"` // The media ID
}
NewPlaybackManagerOptions struct {
WSEventManager events.WSEventManagerInterface
Logger *zerolog.Logger
Platform platform.Platform
MetadataProvider metadata.Provider
Database *db.Database
RefreshAnimeCollectionFunc func() // This function is called to refresh the AniList collection
DiscordPresence *discordrpc_presence.Presence
IsOffline *bool
ContinuityManager *continuity.Manager
}
Settings struct {
AutoPlayNextEpisode bool
}
)
// Event type implementations
func (e PlaybackStatusChangedEvent) Type() string { return "playback_status_changed" }
func (e VideoStartedEvent) Type() string { return "video_started" }
func (e VideoStoppedEvent) Type() string { return "video_stopped" }
func (e VideoCompletedEvent) Type() string { return "video_completed" }
func (e StreamStateChangedEvent) Type() string { return "stream_state_changed" }
func (e StreamStatusChangedEvent) Type() string { return "stream_status_changed" }
func (e StreamStartedEvent) Type() string { return "stream_started" }
func (e StreamStoppedEvent) Type() string { return "stream_stopped" }
func (e StreamCompletedEvent) Type() string { return "stream_completed" }
func (e PlaybackStartingEvent) Type() string { return "playback_starting" }
func New(opts *NewPlaybackManagerOptions) *PlaybackManager {
pm := &PlaybackManager{
Logger: opts.Logger,
Database: opts.Database,
settings: &Settings{},
discordPresence: opts.DiscordPresence,
wsEventManager: opts.WSEventManager,
platform: opts.Platform,
metadataProvider: opts.MetadataProvider,
refreshAnimeCollectionFunc: opts.RefreshAnimeCollectionFunc,
mu: sync.Mutex{},
autoPlayMu: sync.Mutex{},
eventMu: sync.RWMutex{},
historyMap: make(map[string]PlaybackState),
isOffline: opts.IsOffline,
nextEpisodeLocalFile: mo.None[*anime.LocalFile](),
currentStreamEpisode: mo.None[*anime.Episode](),
currentStreamMedia: mo.None[*anilist.BaseAnime](),
currentStreamAniDbEpisode: mo.None[string](),
animeCollection: mo.None[*anilist.AnimeCollection](),
currentManualTrackingState: mo.None[*ManualTrackingState](),
currentLocalFile: mo.None[*anime.LocalFile](),
currentLocalFileWrapperEntry: mo.None[*anime.LocalFileWrapperEntry](),
currentMediaListEntry: mo.None[*anilist.AnimeListEntry](),
continuityManager: opts.ContinuityManager,
playbackStatusSubscribers: result.NewResultMap[string, *PlaybackStatusSubscriber](),
}
pm.playlistHub = newPlaylistHub(pm)
return pm
}
func (pm *PlaybackManager) SetAnimeCollection(ac *anilist.AnimeCollection) {
pm.animeCollection = mo.Some(ac)
}
func (pm *PlaybackManager) SetSettings(s *Settings) {
pm.settings = s
}
// SetMediaPlayerRepository sets the media player repository and starts listening to media player events
// - This method is called when the media player is mounted (due to settings change or when the app starts)
func (pm *PlaybackManager) SetMediaPlayerRepository(mediaPlayerRepository *mediaplayer.Repository) {
go func() {
// If a previous context exists, cancel it
if pm.cancel != nil {
pm.cancel()
}
pm.playlistHub.reset()
// Create a new context for listening to the MediaPlayer instance's event
// When this is canceled above, the previous listener goroutine will stop -- this is done to prevent multiple listeners
var ctx context.Context
ctx, pm.cancel = context.WithCancel(context.Background())
pm.mu.Lock()
// Set the new media player repository instance
pm.MediaPlayerRepository = mediaPlayerRepository
// Set up event listeners for the media player instance
pm.mediaPlayerRepoSubscriber = pm.MediaPlayerRepository.Subscribe("playbackmanager")
pm.mu.Unlock()
// Start listening to new media player events
pm.listenToMediaPlayerEvents(ctx)
// DEVNOTE: pm.listenToClientPlayerEvents()
}()
}
//////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
type StartPlayingOptions struct {
Payload string // url or path
UserAgent string
ClientId string
}
func (pm *PlaybackManager) StartPlayingUsingMediaPlayer(opts *StartPlayingOptions) error {
event := &LocalFilePlaybackRequestedEvent{
Path: opts.Payload,
}
err := hook.GlobalHookManager.OnLocalFilePlaybackRequested().Trigger(event)
if err != nil {
return err
}
opts.Payload = event.Path
if event.DefaultPrevented {
pm.Logger.Debug().Msg("playback manager: Local file playback prevented by hook")
return nil
}
pm.playlistHub.reset()
if err := pm.checkOrLoadAnimeCollection(); err != nil {
return err
}
// Cancel manual tracking if active
if pm.manualTrackingCtxCancel != nil {
pm.manualTrackingCtxCancel()
}
// Send the media file to the media player
err = pm.MediaPlayerRepository.Play(opts.Payload)
if err != nil {
return err
}
trackingEvent := &PlaybackBeforeTrackingEvent{
IsStream: false,
}
err = hook.GlobalHookManager.OnPlaybackBeforeTracking().Trigger(trackingEvent)
if err != nil {
return err
}
if trackingEvent.DefaultPrevented {
return nil
}
// Start tracking
pm.MediaPlayerRepository.StartTracking()
return nil
}
// StartUntrackedStreamingUsingMediaPlayer starts a stream using the media player without any tracking.
func (pm *PlaybackManager) StartUntrackedStreamingUsingMediaPlayer(windowTitle string, opts *StartPlayingOptions) (err error) {
defer util.HandlePanicInModuleWithError("library/playbackmanager/StartUntrackedStreamingUsingMediaPlayer", &err)
event := &StreamPlaybackRequestedEvent{
WindowTitle: windowTitle,
Payload: opts.Payload,
Media: nil,
AniDbEpisode: "",
}
err = hook.GlobalHookManager.OnStreamPlaybackRequested().Trigger(event)
if err != nil {
return err
}
if event.DefaultPrevented {
pm.Logger.Debug().Msg("playback manager: Stream playback prevented by hook")
return nil
}
pm.Logger.Trace().Msg("playback manager: Starting the media player")
pm.mu.Lock()
defer pm.mu.Unlock()
episodeNumber := 0
err = pm.MediaPlayerRepository.Stream(opts.Payload, episodeNumber, 0, windowTitle)
if err != nil {
pm.Logger.Error().Err(err).Msg("playback manager: Failed to start streaming")
return err
}
pm.Logger.Trace().Msg("playback manager: Sent stream to media player")
return nil
}
// StartStreamingUsingMediaPlayer starts streaming a video using the media player.
// This sets PlaybackManager.currentStreamMedia and PlaybackManager.currentStreamEpisode used for progress tracking.
// Note that PlaybackManager.currentStreamEpisodeCollection is not required to start streaming but is needed for progress tracking.
func (pm *PlaybackManager) StartStreamingUsingMediaPlayer(windowTitle string, opts *StartPlayingOptions, media *anilist.BaseAnime, aniDbEpisode string) (err error) {
defer util.HandlePanicInModuleWithError("library/playbackmanager/StartStreamingUsingMediaPlayer", &err)
event := &StreamPlaybackRequestedEvent{
WindowTitle: windowTitle,
Payload: opts.Payload,
Media: media,
AniDbEpisode: aniDbEpisode,
}
err = hook.GlobalHookManager.OnStreamPlaybackRequested().Trigger(event)
if err != nil {
return err
}
aniDbEpisode = event.AniDbEpisode
windowTitle = event.WindowTitle
if event.DefaultPrevented {
pm.Logger.Debug().Msg("playback manager: Stream playback prevented by hook")
return nil
}
pm.playlistHub.reset()
if *pm.isOffline {
return errors.New("cannot stream when offline")
}
if event.Media == nil || aniDbEpisode == "" {
pm.Logger.Error().Msg("playback manager: cannot start streaming, missing options [StartStreamingUsingMediaPlayer]")
return errors.New("cannot start streaming, not enough data provided")
}
pm.Logger.Trace().Msg("playback manager: Starting the media player")
pm.mu.Lock()
defer pm.mu.Unlock()
// Cancel manual tracking if active
if pm.manualTrackingCtxCancel != nil {
pm.manualTrackingCtxCancel()
}
pm.currentStreamMedia = mo.Some(event.Media)
episodeNumber := 0
// Find the current episode being stream
episodeCollection, err := anime.NewEpisodeCollection(anime.NewEpisodeCollectionOptions{
AnimeMetadata: nil,
Media: event.Media,
MetadataProvider: pm.metadataProvider,
Logger: pm.Logger,
})
pm.currentStreamAniDbEpisode = mo.Some(aniDbEpisode)
if episode, ok := episodeCollection.FindEpisodeByAniDB(aniDbEpisode); ok {
episodeNumber = episode.EpisodeNumber
pm.currentStreamEpisode = mo.Some(episode)
} else {
pm.Logger.Warn().Str("episode", aniDbEpisode).Msg("playback manager: Failed to find episode in episode collection")
}
err = pm.MediaPlayerRepository.Stream(event.Payload, episodeNumber, event.Media.ID, windowTitle)
if err != nil {
pm.Logger.Error().Err(err).Msg("playback manager: Failed to start streaming")
return err
}
pm.Logger.Trace().Msg("playback manager: Sent stream to media player")
trackingEvent := &PlaybackBeforeTrackingEvent{
IsStream: true,
}
err = hook.GlobalHookManager.OnPlaybackBeforeTracking().Trigger(trackingEvent)
if err != nil {
return err
}
if trackingEvent.DefaultPrevented {
return nil
}
pm.MediaPlayerRepository.StartTrackingTorrentStream()
pm.Logger.Trace().Msg("playback manager: Started tracking torrent stream")
return nil
}
// PlayNextEpisode plays the next episode of the local media that is being watched
// - Called when the user clicks on the "Next" button in the client
// - Should not be called when the user is watching a playlist
// - Should not be called when no next episode is available
func (pm *PlaybackManager) PlayNextEpisode() (err error) {
defer util.HandlePanicInModuleWithError("library/playbackmanager/PlayNextEpisode", &err)
switch pm.currentPlaybackType {
case LocalFilePlayback:
if pm.currentLocalFile.IsAbsent() || pm.currentMediaListEntry.IsAbsent() || pm.currentLocalFileWrapperEntry.IsAbsent() {
return errors.New("could not play next episode")
}
nextLf, found := pm.currentLocalFileWrapperEntry.MustGet().FindNextEpisode(pm.currentLocalFile.MustGet())
if !found {
return errors.New("could not play next episode")
}
err = pm.MediaPlayerRepository.Play(nextLf.Path)
if err != nil {
return err
}
// Start tracking the video
pm.MediaPlayerRepository.StartTracking()
case StreamPlayback:
// TODO: Implement it for torrentstream
// Check if torrent stream etc...
}
return nil
}
// GetNextEpisode gets the next [anime.LocalFile] of the local media that is being watched.
// It will return nil if there is no next episode.
// This is used by the client's "Auto Play" feature.
func (pm *PlaybackManager) GetNextEpisode() (ret *anime.LocalFile) {
defer util.HandlePanicInModuleThen("library/playbackmanager/GetNextEpisode", func() {
ret = nil
})
switch pm.currentPlaybackType {
case LocalFilePlayback:
if lf, found := pm.nextEpisodeLocalFile.Get(); found {
ret = lf
}
return
}
return nil
}
// AutoPlayNextEpisode will play the next episode of the local media that is being watched.
// This calls [PlaybackManager.PlayNextEpisode] only once if multiple clients made the request.
func (pm *PlaybackManager) AutoPlayNextEpisode() error {
pm.autoPlayMu.Lock()
defer pm.autoPlayMu.Unlock()
pm.Logger.Trace().Msg("playback manager: Auto play request received")
if !pm.settings.AutoPlayNextEpisode {
return nil
}
lf := pm.GetNextEpisode()
// This shouldn't happen because the client should check if there is a next episode before sending the request.
// However, it will happen if there are multiple clients launching the request.
if lf == nil {
pm.Logger.Warn().Msg("playback manager: No next episode to play")
return nil
}
if err := pm.PlayNextEpisode(); err != nil {
pm.Logger.Error().Err(err).Msg("playback manager: Failed to auto play next episode")
return fmt.Errorf("failed to auto play next episode: %w", err)
}
// Remove the next episode from the queue
pm.nextEpisodeLocalFile = mo.None[*anime.LocalFile]()
return nil
}
//////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
// Pause pauses the current media player playback.
func (pm *PlaybackManager) Pause() error {
return pm.MediaPlayerRepository.Pause()
}
// Resume resumes the current media player playback.
func (pm *PlaybackManager) Resume() error {
return pm.MediaPlayerRepository.Resume()
}
// Seek seeks to the specified time in the current media.
func (pm *PlaybackManager) Seek(seconds float64) error {
return pm.MediaPlayerRepository.Seek(seconds)
}
// PullStatus pulls the current media player playback status at the time of the call.
func (pm *PlaybackManager) PullStatus() (*mediaplayer.PlaybackStatus, bool) {
return pm.MediaPlayerRepository.PullStatus()
}
// Cancel stops the current media player playback and publishes a "normal" event.
func (pm *PlaybackManager) Cancel() error {
pm.MediaPlayerRepository.Stop()
return nil
}
//////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
// Playlist
//////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
// CancelCurrentPlaylist cancels the current playlist.
// This is an action triggered by the client.
func (pm *PlaybackManager) CancelCurrentPlaylist() error {
go pm.playlistHub.reset()
return nil
}
// RequestNextPlaylistFile will play the next file in the playlist.
// This is an action triggered by the client.
func (pm *PlaybackManager) RequestNextPlaylistFile() error {
go pm.playlistHub.playNextFile()
return nil
}
// StartPlaylist starts a playlist.
// This action is triggered by the client.
func (pm *PlaybackManager) StartPlaylist(playlist *anime.Playlist) (err error) {
defer util.HandlePanicInModuleWithError("library/playbackmanager/StartPlaylist", &err)
pm.playlistHub.loadPlaylist(playlist)
_ = pm.checkOrLoadAnimeCollection()
// Play the first video in the playlist
firstVidPath := playlist.LocalFiles[0].Path
err = pm.MediaPlayerRepository.Play(firstVidPath)
if err != nil {
return err
}
// Start tracking the video
pm.MediaPlayerRepository.StartTracking()
// Create a new context for the playlist hub
var ctx context.Context
ctx, pm.playlistHub.cancel = context.WithCancel(context.Background())
// Listen to new play requests
go func() {
pm.Logger.Debug().Msg("playback manager: Listening for new file requests")
for {
select {
// When the playlist hub context is cancelled (No playlist is being played)
case <-ctx.Done():
pm.Logger.Debug().Msg("playback manager: Playlist context cancelled")
// Send event to the client -- nil signals that no playlist is being played
pm.wsEventManager.SendEvent(events.PlaybackManagerPlaylistState, nil)
return
case path := <-pm.playlistHub.requestNewFileCh:
// requestNewFileCh receives the path of the next video to play
// The channel is fed when it's time to play the next video or when the client requests the next video
// see: RequestNextPlaylistFile, playlistHub code
pm.Logger.Debug().Str("path", path).Msg("playback manager: Playing next file")
// Send notification to the client
pm.wsEventManager.SendEvent(events.InfoToast, "Playing next file in playlist")
// Play the requested video
err := pm.MediaPlayerRepository.Play(path)
if err != nil {
pm.Logger.Error().Err(err).Msg("playback manager: Failed to play next file in playlist")
pm.playlistHub.cancel()
return
}
// Start tracking the video
pm.MediaPlayerRepository.StartTracking()
case <-pm.playlistHub.endOfPlaylistCh:
pm.Logger.Debug().Msg("playback manager: End of playlist")
pm.wsEventManager.SendEvent(events.InfoToast, "End of playlist")
// Send event to the client -- nil signals that no playlist is being played
pm.wsEventManager.SendEvent(events.PlaybackManagerPlaylistState, nil)
go pm.MediaPlayerRepository.Stop()
pm.playlistHub.cancel()
return
}
}
}()
// Delete playlist in goroutine
go func() {
err := db_bridge.DeletePlaylist(pm.Database, playlist.DbId)
if err != nil {
pm.Logger.Error().Err(err).Str("name", playlist.Name).Msgf("playback manager: Failed to delete playlist")
return
}
pm.Logger.Debug().Str("name", playlist.Name).Msgf("playback manager: Deleted playlist")
}()
return nil
}
//////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
func (pm *PlaybackManager) checkOrLoadAnimeCollection() (err error) {
defer util.HandlePanicInModuleWithError("library/playbackmanager/checkOrLoadAnimeCollection", &err)
if pm.animeCollection.IsAbsent() {
// If the anime collection is not present, we retrieve it from the platform
collection, err := pm.platform.GetAnimeCollection(context.Background(), false)
if err != nil {
return err
}
pm.animeCollection = mo.Some(collection)
}
return nil
}
func (pm *PlaybackManager) SubscribeToPlaybackStatus(id string) *PlaybackStatusSubscriber {
subscriber := &PlaybackStatusSubscriber{
EventCh: make(chan PlaybackEvent, 100),
}
pm.playbackStatusSubscribers.Set(id, subscriber)
return subscriber
}
func (pm *PlaybackManager) RegisterMediaPlayerCallback(callback func(event PlaybackEvent, cancelFunc func())) (cancel func()) {
id := uuid.NewString()
playbackSubscriber := pm.SubscribeToPlaybackStatus(id)
cancel = func() {
pm.UnsubscribeFromPlaybackStatus(id)
}
go func(playbackSubscriber *PlaybackStatusSubscriber) {
for event := range playbackSubscriber.EventCh {
callback(event, cancel)
}
}(playbackSubscriber)
return cancel
}
func (pm *PlaybackManager) UnsubscribeFromPlaybackStatus(id string) {
defer func() {
if r := recover(); r != nil {
pm.Logger.Warn().Msg("playback manager: Failed to unsubscribe from playback status")
}
}()
subscriber, ok := pm.playbackStatusSubscribers.Get(id)
if !ok {
return
}
subscriber.canceled.Store(true)
pm.playbackStatusSubscribers.Delete(id)
close(subscriber.EventCh)
}