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,60 @@
package playbackmanager
import (
"seanime/internal/api/anilist"
"seanime/internal/hook_resolver"
"seanime/internal/library/anime"
)
// LocalFilePlaybackRequestedEvent is triggered when a local file is requested to be played.
// Prevent default to skip the default playback and override the playback.
type LocalFilePlaybackRequestedEvent struct {
hook_resolver.Event
Path string `json:"path"`
}
// StreamPlaybackRequestedEvent is triggered when a stream is requested to be played.
// Prevent default to skip the default playback and override the playback.
type StreamPlaybackRequestedEvent struct {
hook_resolver.Event
WindowTitle string `json:"windowTitle"`
Payload string `json:"payload"`
Media *anilist.BaseAnime `json:"media"`
AniDbEpisode string `json:"aniDbEpisode"`
}
// PlaybackBeforeTrackingEvent is triggered just before the playback tracking starts.
// Prevent default to skip playback tracking.
type PlaybackBeforeTrackingEvent struct {
hook_resolver.Event
IsStream bool `json:"isStream"`
}
// PlaybackLocalFileDetailsRequestedEvent is triggered when the local files details for a specific path are requested.
// This event is triggered right after the media player loads an episode.
// The playback manager uses the local files details to track the progress, propose next episodes, etc.
// In the current implementation, the details are fetched by selecting the local file from the database and making requests to retrieve the media and anime list entry.
// Prevent default to skip the default fetching and override the details.
type PlaybackLocalFileDetailsRequestedEvent struct {
hook_resolver.Event
Path string `json:"path"`
// List of all local files
LocalFiles []*anime.LocalFile `json:"localFiles"`
// Empty anime list entry
AnimeListEntry *anilist.AnimeListEntry `json:"animeListEntry"`
// Empty local file
LocalFile *anime.LocalFile `json:"localFile"`
// Empty local file wrapper entry
LocalFileWrapperEntry *anime.LocalFileWrapperEntry `json:"localFileWrapperEntry"`
}
// PlaybackStreamDetailsRequestedEvent is triggered when the stream details are requested.
// Prevent default to skip the default fetching and override the details.
// In the current implementation, the details are fetched by selecting the anime from the anime collection. If nothing is found, the stream is still tracked.
type PlaybackStreamDetailsRequestedEvent struct {
hook_resolver.Event
AnimeCollection *anilist.AnimeCollection `json:"animeCollection"`
MediaId int `json:"mediaId"`
// Empty anime list entry
AnimeListEntry *anilist.AnimeListEntry `json:"animeListEntry"`
}

View File

@@ -0,0 +1,143 @@
package playbackmanager
import (
"context"
"fmt"
"seanime/internal/api/anilist"
"seanime/internal/events"
"seanime/internal/util"
"time"
"github.com/samber/mo"
)
//////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
// Manual progress tracking
//////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
type ManualTrackingState struct {
EpisodeNumber int
MediaId int
CurrentProgress int
TotalEpisodes int
}
type StartManualProgressTrackingOptions struct {
ClientId string
MediaId int
EpisodeNumber int
}
func (pm *PlaybackManager) CancelManualProgressTracking() {
pm.mu.Lock()
defer pm.mu.Unlock()
if pm.manualTrackingCtxCancel != nil {
pm.manualTrackingCtxCancel()
pm.currentManualTrackingState = mo.None[*ManualTrackingState]()
}
}
func (pm *PlaybackManager) StartManualProgressTracking(opts *StartManualProgressTrackingOptions) (err error) {
defer util.HandlePanicInModuleWithError("library/playbackmanager/StartManualProgressTracking", &err)
ctx := context.Background()
pm.mu.Lock()
defer pm.mu.Unlock()
pm.Logger.Trace().Msg("playback manager: Starting manual progress tracking")
// Cancel manual tracking if active
if pm.manualTrackingCtxCancel != nil {
pm.Logger.Trace().Msg("playback manager: Cancelling previous manual tracking context")
pm.manualTrackingCtxCancel()
pm.manualTrackingWg.Wait()
}
// Get the media
// - Find the media in the collection
animeCollection, err := pm.platform.GetAnimeCollection(ctx, false)
if err != nil {
return err
}
var media *anilist.BaseAnime
var currentProgress int
var totalEpisodes int
listEntry, found := animeCollection.GetListEntryFromAnimeId(opts.MediaId)
if found {
media = listEntry.Media
} else {
// Fetch the media from AniList
media, err = pm.platform.GetAnime(ctx, opts.MediaId)
}
if media == nil {
pm.Logger.Error().Msg("playback manager: Media not found for manual tracking")
return fmt.Errorf("media not found")
}
currentProgress = 0
if listEntry != nil && listEntry.GetProgress() != nil {
currentProgress = *listEntry.GetProgress()
}
totalEpisodes = media.GetTotalEpisodeCount()
// Set the current playback type (for progress update later on)
pm.currentPlaybackType = ManualTrackingPlayback
// Set the manual tracking state (for progress update later on)
pm.currentManualTrackingState = mo.Some(&ManualTrackingState{
EpisodeNumber: opts.EpisodeNumber,
MediaId: opts.MediaId,
CurrentProgress: currentProgress,
TotalEpisodes: totalEpisodes,
})
pm.Logger.Trace().
Int("episode_number", opts.EpisodeNumber).
Int("mediaId", opts.MediaId).
Int("currentProgress", currentProgress).
Int("totalEpisodes", totalEpisodes).
Msg("playback manager: Starting manual progress tracking")
// Start sending the manual tracking events
pm.manualTrackingWg.Add(1)
go func() {
defer pm.manualTrackingWg.Done()
// Create a new context
pm.manualTrackingCtx, pm.manualTrackingCtxCancel = context.WithCancel(context.Background())
defer func() {
if pm.manualTrackingCtxCancel != nil {
pm.manualTrackingCtxCancel()
}
}()
for {
select {
case <-pm.manualTrackingCtx.Done():
pm.Logger.Debug().Msg("playback manager: Manual progress tracking canceled")
pm.wsEventManager.SendEvent(events.PlaybackManagerManualTrackingStopped, nil)
return
default:
ps := playbackStatePool.Get().(*PlaybackState)
ps.EpisodeNumber = opts.EpisodeNumber
ps.MediaTitle = *media.GetTitle().GetUserPreferred()
ps.MediaTotalEpisodes = totalEpisodes
ps.Filename = ""
ps.CompletionPercentage = 0
ps.CanPlayNext = false
ps.ProgressUpdated = false
ps.MediaId = opts.MediaId
pm.wsEventManager.SendEvent(events.PlaybackManagerManualTrackingPlaybackState, ps)
playbackStatePool.Put(ps)
// Continuously send the progress to the client
time.Sleep(3 * time.Second)
}
}
}()
return nil
}

View File

@@ -0,0 +1,98 @@
package playbackmanager
import (
"context"
"fmt"
"seanime/internal/database/db_bridge"
"seanime/internal/library/anime"
"github.com/samber/lo"
)
type StartRandomVideoOptions struct {
UserAgent string
ClientId string
}
// StartRandomVideo starts a random video from the collection.
// Note that this might now be suited if the user has multiple seasons of the same anime.
func (pm *PlaybackManager) StartRandomVideo(opts *StartRandomVideoOptions) error {
pm.playlistHub.reset()
if err := pm.checkOrLoadAnimeCollection(); err != nil {
return err
}
animeCollection, err := pm.platform.GetAnimeCollection(context.Background(), false)
if err != nil {
return err
}
//
// Retrieve random episode
//
// Get lfs
lfs, _, err := db_bridge.GetLocalFiles(pm.Database)
if err != nil {
return fmt.Errorf("error getting local files: %s", err.Error())
}
// Create a local file wrapper
lfw := anime.NewLocalFileWrapper(lfs)
// Get entries (grouped by media id)
lfEntries := lfw.GetLocalEntries()
lfEntries = lo.Filter(lfEntries, func(e *anime.LocalFileWrapperEntry, _ int) bool {
return e.HasMainLocalFiles()
})
if len(lfEntries) == 0 {
return fmt.Errorf("no playable media found")
}
continueLfs := make([]*anime.LocalFile, 0)
otherLfs := make([]*anime.LocalFile, 0)
for _, e := range lfEntries {
anilistEntry, ok := animeCollection.GetListEntryFromAnimeId(e.GetMediaId())
if !ok {
continue
}
progress := 0
if anilistEntry.Progress != nil {
progress = *anilistEntry.Progress
}
if anilistEntry.Status == nil || *anilistEntry.Status == "COMPLETED" {
continue
}
firstUnwatchedFile, found := e.GetFirstUnwatchedLocalFiles(progress)
if !found {
continue
}
if *anilistEntry.Status == "CURRENT" || *anilistEntry.Status == "REPEATING" {
continueLfs = append(continueLfs, firstUnwatchedFile)
} else {
otherLfs = append(otherLfs, firstUnwatchedFile)
}
}
if len(continueLfs) == 0 && len(otherLfs) == 0 {
return fmt.Errorf("no playable file found")
}
lfs = append(continueLfs, otherLfs...)
// only choose from continueLfs if there are more than 8 episodes
if len(continueLfs) > 8 {
lfs = continueLfs
}
lfs = lo.Shuffle(lfs)
err = pm.StartPlayingUsingMediaPlayer(&StartPlayingOptions{
Payload: lfs[0].GetPath(),
UserAgent: opts.UserAgent,
ClientId: opts.ClientId,
})
if err != nil {
return err
}
return nil
}

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

View File

@@ -0,0 +1,57 @@
package playbackmanager_test
import (
"seanime/internal/api/anilist"
"seanime/internal/api/metadata"
"seanime/internal/continuity"
"seanime/internal/database/db"
"seanime/internal/events"
"seanime/internal/library/playbackmanager"
"seanime/internal/platforms/anilist_platform"
"seanime/internal/test_utils"
"seanime/internal/util"
"seanime/internal/util/filecache"
"testing"
"github.com/stretchr/testify/require"
)
func getPlaybackManager(t *testing.T) (*playbackmanager.PlaybackManager, *anilist.AnimeCollection, error) {
logger := util.NewLogger()
wsEventManager := events.NewMockWSEventManager(logger)
database, err := db.NewDatabase(test_utils.ConfigData.Path.DataDir, test_utils.ConfigData.Database.Name, logger)
if err != nil {
t.Fatalf("error while creating database, %v", err)
}
filecacher, err := filecache.NewCacher(t.TempDir())
require.NoError(t, err)
anilistClient := anilist.TestGetMockAnilistClient()
anilistPlatform := anilist_platform.NewAnilistPlatform(anilistClient, logger)
animeCollection, err := anilistPlatform.GetAnimeCollection(t.Context(), true)
metadataProvider := metadata.GetMockProvider(t)
require.NoError(t, err)
continuityManager := continuity.NewManager(&continuity.NewManagerOptions{
FileCacher: filecacher,
Logger: logger,
Database: database,
})
return playbackmanager.New(&playbackmanager.NewPlaybackManagerOptions{
WSEventManager: wsEventManager,
Logger: logger,
Platform: anilistPlatform,
MetadataProvider: metadataProvider,
Database: database,
RefreshAnimeCollectionFunc: func() {
// Do nothing
},
DiscordPresence: nil,
IsOffline: &[]bool{false}[0],
ContinuityManager: continuityManager,
}), animeCollection, nil
}

View File

@@ -0,0 +1,235 @@
package playbackmanager
import (
"context"
"fmt"
"seanime/internal/api/anilist"
"seanime/internal/events"
"seanime/internal/library/anime"
"sync"
"sync/atomic"
"github.com/rs/zerolog"
)
type (
playlistHub struct {
requestNewFileCh chan string
endOfPlaylistCh chan struct{}
wsEventManager events.WSEventManagerInterface
logger *zerolog.Logger
currentPlaylist *anime.Playlist // The current playlist that is being played (can be nil)
nextLocalFile *anime.LocalFile // The next episode that will be played (can be nil)
cancel context.CancelFunc // The cancel function for the current playlist
mu sync.Mutex // The mutex
playingLf *anime.LocalFile // The currently playing local file
playingMediaListEntry *anilist.AnimeListEntry // The currently playing media entry
completedCurrent atomic.Bool // Whether the current episode has been completed
currentState *PlaylistState // This is sent to the client to show the current playlist state
playbackManager *PlaybackManager
}
PlaylistState struct {
Current *PlaylistStateItem `json:"current"`
Next *PlaylistStateItem `json:"next"`
Remaining int `json:"remaining"`
}
PlaylistStateItem struct {
Name string `json:"name"`
MediaImage string `json:"mediaImage"`
}
)
func newPlaylistHub(pm *PlaybackManager) *playlistHub {
ret := &playlistHub{
logger: pm.Logger,
wsEventManager: pm.wsEventManager,
playbackManager: pm,
requestNewFileCh: make(chan string, 1),
endOfPlaylistCh: make(chan struct{}, 1),
completedCurrent: atomic.Bool{},
}
ret.completedCurrent.Store(false)
return ret
}
func (h *playlistHub) loadPlaylist(playlist *anime.Playlist) {
if playlist == nil {
h.logger.Error().Msg("playlist hub: Playlist is nil")
return
}
h.reset()
h.currentPlaylist = playlist
h.logger.Debug().Str("name", playlist.Name).Msg("playlist hub: Playlist loaded")
return
}
func (h *playlistHub) reset() {
if h.cancel != nil {
h.cancel()
}
h.currentPlaylist = nil
h.playingLf = nil
h.playingMediaListEntry = nil
h.currentState = nil
h.wsEventManager.SendEvent(events.PlaybackManagerPlaylistState, h.currentState)
return
}
func (h *playlistHub) check(currListEntry *anilist.AnimeListEntry, currLf *anime.LocalFile, ps PlaybackState) bool {
if h.currentPlaylist == nil || currLf == nil || currListEntry == nil {
h.currentPlaylist = nil
h.playingLf = nil
h.playingMediaListEntry = nil
return false
}
return true
}
func (h *playlistHub) findNextFile() (*anime.LocalFile, bool) {
if h.currentPlaylist == nil || h.playingLf == nil {
return nil, false
}
for i, lf := range h.currentPlaylist.LocalFiles {
if lf.GetNormalizedPath() == h.playingLf.GetNormalizedPath() {
if i+1 < len(h.currentPlaylist.LocalFiles) {
return h.currentPlaylist.LocalFiles[i+1], true
}
break
}
}
return nil, false
}
func (h *playlistHub) playNextFile() (*anime.LocalFile, bool) {
if h.currentPlaylist == nil || h.playingLf == nil || h.nextLocalFile == nil {
return nil, false
}
h.logger.Debug().Str("path", h.nextLocalFile.Path).Str("cmd", "playNextFile").Msg("playlist hub: Requesting next file")
h.requestNewFileCh <- h.nextLocalFile.Path
h.completedCurrent.Store(false)
return nil, false
}
func (h *playlistHub) onVideoStart(currListEntry *anilist.AnimeListEntry, currLf *anime.LocalFile, ps PlaybackState) {
if !h.check(currListEntry, currLf, ps) {
return
}
h.playingLf = currLf
h.playingMediaListEntry = currListEntry
h.nextLocalFile, _ = h.findNextFile()
if h.playbackManager.animeCollection.IsAbsent() {
return
}
// Refresh current playlist state
playlistState := &PlaylistState{}
playlistState.Current = &PlaylistStateItem{
Name: fmt.Sprintf("%s - Episode %d", currListEntry.GetMedia().GetPreferredTitle(), currLf.GetEpisodeNumber()),
MediaImage: currListEntry.GetMedia().GetCoverImageSafe(),
}
if h.nextLocalFile != nil {
lfe, found := h.playbackManager.animeCollection.MustGet().GetListEntryFromAnimeId(h.nextLocalFile.MediaId)
if found {
playlistState.Next = &PlaylistStateItem{
Name: fmt.Sprintf("%s - Episode %d", lfe.GetMedia().GetPreferredTitle(), h.nextLocalFile.GetEpisodeNumber()),
MediaImage: lfe.GetMedia().GetCoverImageSafe(),
}
}
}
remaining := 0
for i, lf := range h.currentPlaylist.LocalFiles {
if lf.GetNormalizedPath() == currLf.GetNormalizedPath() {
remaining = len(h.currentPlaylist.LocalFiles) - 1 - i
break
}
}
playlistState.Remaining = remaining
h.currentState = playlistState
h.completedCurrent.Store(false)
h.logger.Debug().Str("path", currLf.Path).Msgf("playlist hub: Video started")
return
}
func (h *playlistHub) onVideoCompleted(currListEntry *anilist.AnimeListEntry, currLf *anime.LocalFile, ps PlaybackState) {
if !h.check(currListEntry, currLf, ps) {
return
}
h.logger.Debug().Str("path", currLf.Path).Msgf("playlist hub: Video completed")
h.completedCurrent.Store(true)
return
}
func (h *playlistHub) onPlaybackStatus(currListEntry *anilist.AnimeListEntry, currLf *anime.LocalFile, ps PlaybackState) {
if !h.check(currListEntry, currLf, ps) {
return
}
h.wsEventManager.SendEvent(events.PlaybackManagerPlaylistState, h.currentState)
return
}
func (h *playlistHub) onTrackingStopped() {
if h.currentPlaylist == nil || h.playingLf == nil { // Return if no playlist
return
}
// When tracking has stopped, request next file
//if h.nextLocalFile != nil {
// h.logger.Debug().Str("path", h.nextLocalFile.Path).Msg("playlist hub: Requesting next file")
// h.requestNewFileCh <- h.nextLocalFile.Path
//} else {
// h.logger.Debug().Msg("playlist hub: End of playlist")
// h.endOfPlaylistCh <- struct{}{}
//}
h.logger.Debug().Msgf("playlist hub: Tracking stopped, completed current: %v", h.completedCurrent.Load())
if !h.completedCurrent.Load() {
h.reset()
}
return
}
func (h *playlistHub) onTrackingError() {
if h.currentPlaylist == nil { // Return if no playlist
return
}
// When tracking has stopped, request next file
h.logger.Debug().Msgf("playlist hub: Tracking error, completed current: %v", h.completedCurrent.Load())
if h.completedCurrent.Load() {
h.logger.Debug().Msg("playlist hub: Assuming current episode is completed")
if h.nextLocalFile != nil {
h.logger.Debug().Str("path", h.nextLocalFile.Path).Msg("playlist hub: Requesting next file")
h.requestNewFileCh <- h.nextLocalFile.Path
//h.completedCurrent.Store(false) do not reset completedCurrent here
} else {
h.logger.Debug().Msg("playlist hub: End of playlist")
h.endOfPlaylistCh <- struct{}{}
h.completedCurrent.Store(false)
}
}
return
}

View File

@@ -0,0 +1,93 @@
package playbackmanager_test
import (
"seanime/internal/events"
"seanime/internal/library/anime"
"seanime/internal/mediaplayers/mediaplayer"
"seanime/internal/mediaplayers/mpchc"
"seanime/internal/mediaplayers/mpv"
"seanime/internal/mediaplayers/vlc"
"seanime/internal/test_utils"
"seanime/internal/util"
"strconv"
"testing"
)
var defaultPlayer = "vlc"
var localFilePaths = []string{
"E:/ANIME/Dungeon Meshi/[EMBER] Dungeon Meshi - 04.mkv",
"E:/ANIME/Dungeon Meshi/[EMBER] Dungeon Meshi - 05.mkv",
"E:/ANIME/Dungeon Meshi/[EMBER] Dungeon Meshi - 06.mkv",
}
var mediaId = 153518
func TestPlaylists(t *testing.T) {
test_utils.InitTestProvider(t, test_utils.Anilist(), test_utils.MediaPlayer())
playbackManager, animeCollection, err := getPlaybackManager(t)
if err != nil {
t.Fatal(err)
}
repo := getRepo()
playbackManager.SetMediaPlayerRepository(repo)
playbackManager.SetAnimeCollection(animeCollection)
// Test the playlist hub
lfs := make([]*anime.LocalFile, 0)
for _, path := range localFilePaths {
lf := anime.NewLocalFile(path, "E:/ANIME")
epNum, _ := strconv.Atoi(lf.ParsedData.Episode)
lf.MediaId = mediaId
lf.Metadata.Type = anime.LocalFileTypeMain
lf.Metadata.Episode = epNum
lf.Metadata.AniDBEpisode = lf.ParsedData.Episode
lfs = append(lfs, lf)
}
playlist := &anime.Playlist{
DbId: 1,
Name: "test",
LocalFiles: lfs,
}
err = playbackManager.StartPlaylist(playlist)
if err != nil {
t.Fatal(err)
}
select {}
}
//////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
func getRepo() *mediaplayer.Repository {
logger := util.NewLogger()
WSEventManager := events.NewMockWSEventManager(logger)
vlcI := &vlc.VLC{
Host: test_utils.ConfigData.Provider.VlcPath,
Port: test_utils.ConfigData.Provider.VlcPort,
Password: test_utils.ConfigData.Provider.VlcPassword,
Logger: logger,
}
mpc := &mpchc.MpcHc{
Host: test_utils.ConfigData.Provider.MpcHost,
Path: test_utils.ConfigData.Provider.MpcPath,
Port: test_utils.ConfigData.Provider.MpcPort,
Logger: logger,
}
repo := mediaplayer.NewRepository(&mediaplayer.NewRepositoryOptions{
Logger: logger,
Default: defaultPlayer,
VLC: vlcI,
MpcHc: mpc,
Mpv: mpv.New(logger, "", ""),
WSEventManager: WSEventManager,
})
return repo
}

View File

@@ -0,0 +1,685 @@
package playbackmanager
import (
"cmp"
"context"
"errors"
"seanime/internal/continuity"
discordrpc_presence "seanime/internal/discordrpc/presence"
"seanime/internal/events"
"seanime/internal/library/anime"
"seanime/internal/mediaplayers/mediaplayer"
"seanime/internal/util"
"github.com/samber/mo"
)
var (
ErrProgressUpdateAnilist = errors.New("playback manager: Failed to update progress on AniList")
ErrProgressUpdateMAL = errors.New("playback manager: Failed to update progress on MyAnimeList")
)
func (pm *PlaybackManager) listenToMediaPlayerEvents(ctx context.Context) {
// Listen for media player events
go func() {
for {
select {
// Stop listening when the context is cancelled -- meaning a new MediaPlayer instance is set
case <-ctx.Done():
return
case event := <-pm.mediaPlayerRepoSubscriber.EventCh:
switch e := event.(type) {
// Local file events
case mediaplayer.TrackingStartedEvent: // New video has started playing
pm.handleTrackingStarted(e.Status)
case mediaplayer.VideoCompletedEvent: // Video has been watched completely but still tracking
pm.handleVideoCompleted(e.Status)
case mediaplayer.TrackingStoppedEvent: // Tracking has stopped completely
pm.handleTrackingStopped(e.Reason)
case mediaplayer.PlaybackStatusEvent: // Playback status has changed
pm.handlePlaybackStatus(e.Status)
case mediaplayer.TrackingRetryEvent: // Error occurred while starting tracking
pm.handleTrackingRetry(e.Reason)
// Streaming events
case mediaplayer.StreamingTrackingStartedEvent:
pm.handleStreamingTrackingStarted(e.Status)
case mediaplayer.StreamingPlaybackStatusEvent:
pm.handleStreamingPlaybackStatus(e.Status)
case mediaplayer.StreamingVideoCompletedEvent:
pm.handleStreamingVideoCompleted(e.Status)
case mediaplayer.StreamingTrackingStoppedEvent:
pm.handleStreamingTrackingStopped(e.Reason)
case mediaplayer.StreamingTrackingRetryEvent:
// Do nothing
}
}
}
}()
}
func (pm *PlaybackManager) handleTrackingStarted(status *mediaplayer.PlaybackStatus) {
pm.eventMu.Lock()
defer pm.eventMu.Unlock()
// Set the playback type
pm.currentPlaybackType = LocalFilePlayback
// Reset the history map
pm.historyMap = make(map[string]PlaybackState)
// Set the current media playback status
pm.currentMediaPlaybackStatus = status
// Get the playback state
_ps := pm.getLocalFilePlaybackState(status)
// Log
pm.Logger.Debug().Msg("playback manager: Tracking started, extracting metadata...")
// Send event to the client
pm.wsEventManager.SendEvent(events.PlaybackManagerProgressTrackingStarted, _ps)
// Notify subscribers
go func() {
pm.playbackStatusSubscribers.Range(func(key string, value *PlaybackStatusSubscriber) bool {
if value.canceled.Load() {
return true
}
value.EventCh <- PlaybackStatusChangedEvent{Status: *status, State: _ps}
value.EventCh <- VideoStartedEvent{Filename: status.Filename, Filepath: status.Filepath}
return true
})
}()
// Retrieve data about the current video playback
// Set PlaybackManager.currentMediaListEntry to the list entry of the current video
currentMediaListEntry, currentLocalFile, currentLocalFileWrapperEntry, err := pm.getLocalFilePlaybackDetails(status.Filepath)
if err != nil {
pm.Logger.Error().Err(err).Msg("playback manager: Failed to get media data")
// Send error event to the client
pm.wsEventManager.SendEvent(events.ErrorToast, err.Error())
//
pm.MediaPlayerRepository.Cancel()
return
}
pm.currentMediaListEntry = mo.Some(currentMediaListEntry)
pm.currentLocalFile = mo.Some(currentLocalFile)
pm.currentLocalFileWrapperEntry = mo.Some(currentLocalFileWrapperEntry)
pm.Logger.Debug().
Str("media", pm.currentMediaListEntry.MustGet().GetMedia().GetPreferredTitle()).
Int("episode", pm.currentLocalFile.MustGet().GetEpisodeNumber()).
Msg("playback manager: Playback started")
pm.continuityManager.SetExternalPlayerEpisodeDetails(&continuity.ExternalPlayerEpisodeDetails{
EpisodeNumber: pm.currentLocalFile.MustGet().GetEpisodeNumber(),
MediaId: pm.currentMediaListEntry.MustGet().GetMedia().GetID(),
Filepath: pm.currentLocalFile.MustGet().GetPath(),
})
// ------- Playlist ------- //
go pm.playlistHub.onVideoStart(pm.currentMediaListEntry.MustGet(), pm.currentLocalFile.MustGet(), _ps)
// ------- Discord ------- //
if pm.discordPresence != nil && !*pm.isOffline {
go pm.discordPresence.SetAnimeActivity(&discordrpc_presence.AnimeActivity{
ID: pm.currentMediaListEntry.MustGet().GetMedia().GetID(),
Title: pm.currentMediaListEntry.MustGet().GetMedia().GetPreferredTitle(),
Image: pm.currentMediaListEntry.MustGet().GetMedia().GetCoverImageSafe(),
IsMovie: pm.currentMediaListEntry.MustGet().GetMedia().IsMovie(),
EpisodeNumber: pm.currentLocalFileWrapperEntry.MustGet().GetProgressNumber(pm.currentLocalFile.MustGet()),
Progress: int(pm.currentMediaPlaybackStatus.CurrentTimeInSeconds),
Duration: int(pm.currentMediaPlaybackStatus.DurationInSeconds),
TotalEpisodes: pm.currentMediaListEntry.MustGet().GetMedia().Episodes,
CurrentEpisodeCount: pm.currentMediaListEntry.MustGet().GetMedia().GetCurrentEpisodeCountOrNil(),
})
}
}
func (pm *PlaybackManager) handleVideoCompleted(status *mediaplayer.PlaybackStatus) {
pm.eventMu.Lock()
defer pm.eventMu.Unlock()
// Set the current media playback status
pm.currentMediaPlaybackStatus = status
// Get the playback state
_ps := pm.getLocalFilePlaybackState(status)
// Log
pm.Logger.Debug().Msg("playback manager: Received video completed event")
// Notify subscribers
go func() {
pm.playbackStatusSubscribers.Range(func(key string, value *PlaybackStatusSubscriber) bool {
if value.canceled.Load() {
return true
}
value.EventCh <- PlaybackStatusChangedEvent{Status: *status, State: _ps}
value.EventCh <- VideoCompletedEvent{Filename: status.Filename}
return true
})
}()
//
// Update the progress on AniList if auto update progress is enabled
//
pm.autoSyncCurrentProgress(&_ps)
// Send the playback state with the `ProgressUpdated` flag
// The client will use this to notify the user if the progress has been updated
pm.wsEventManager.SendEvent(events.PlaybackManagerProgressVideoCompleted, _ps)
// Push the video playback state to the history
pm.historyMap[status.Filename] = _ps
// ------- Playlist ------- //
if pm.currentMediaListEntry.IsPresent() && pm.currentLocalFile.IsPresent() {
go pm.playlistHub.onVideoCompleted(pm.currentMediaListEntry.MustGet(), pm.currentLocalFile.MustGet(), _ps)
}
}
func (pm *PlaybackManager) handleTrackingStopped(reason string) {
pm.eventMu.Lock()
defer pm.eventMu.Unlock()
pm.Logger.Debug().Msg("playback manager: Received tracking stopped event")
pm.wsEventManager.SendEvent(events.PlaybackManagerProgressTrackingStopped, reason)
// Find the next episode and set it to [PlaybackManager.nextEpisodeLocalFile]
if pm.currentMediaListEntry.IsPresent() && pm.currentLocalFile.IsPresent() && pm.currentLocalFileWrapperEntry.IsPresent() {
lf, ok := pm.currentLocalFileWrapperEntry.MustGet().FindNextEpisode(pm.currentLocalFile.MustGet())
if ok {
pm.nextEpisodeLocalFile = mo.Some(lf)
} else {
pm.nextEpisodeLocalFile = mo.None[*anime.LocalFile]()
}
}
// Notify subscribers
go func() {
pm.playbackStatusSubscribers.Range(func(key string, value *PlaybackStatusSubscriber) bool {
if value.canceled.Load() {
return true
}
value.EventCh <- VideoStoppedEvent{Reason: reason}
return true
})
}()
if pm.currentMediaPlaybackStatus != nil {
pm.continuityManager.UpdateExternalPlayerEpisodeWatchHistoryItem(pm.currentMediaPlaybackStatus.CurrentTimeInSeconds, pm.currentMediaPlaybackStatus.DurationInSeconds)
}
// ------- Playlist ------- //
go pm.playlistHub.onTrackingStopped()
// ------- Discord ------- //
if pm.discordPresence != nil && !*pm.isOffline {
go pm.discordPresence.Close()
}
}
func (pm *PlaybackManager) handlePlaybackStatus(status *mediaplayer.PlaybackStatus) {
pm.eventMu.Lock()
defer pm.eventMu.Unlock()
pm.currentPlaybackType = LocalFilePlayback
// Set the current media playback status
pm.currentMediaPlaybackStatus = status
// Get the playback state
_ps := pm.getLocalFilePlaybackState(status)
// If the same PlaybackState is in the history, update the ProgressUpdated flag
// PlaybackStatusCh has no way of knowing if the progress has been updated
if h, ok := pm.historyMap[status.Filename]; ok {
_ps.ProgressUpdated = h.ProgressUpdated
}
// Notify subscribers
go func() {
pm.playbackStatusSubscribers.Range(func(key string, value *PlaybackStatusSubscriber) bool {
if value.canceled.Load() {
return true
}
value.EventCh <- PlaybackStatusChangedEvent{Status: *status, State: _ps}
return true
})
}()
// Send the playback state to the client
pm.wsEventManager.SendEvent(events.PlaybackManagerProgressPlaybackState, _ps)
// ------- Playlist ------- //
if pm.currentMediaListEntry.IsPresent() && pm.currentLocalFile.IsPresent() {
go pm.playlistHub.onPlaybackStatus(pm.currentMediaListEntry.MustGet(), pm.currentLocalFile.MustGet(), _ps)
}
// ------- Discord ------- //
if pm.discordPresence != nil && !*pm.isOffline {
go pm.discordPresence.UpdateAnimeActivity(int(pm.currentMediaPlaybackStatus.CurrentTimeInSeconds), int(pm.currentMediaPlaybackStatus.DurationInSeconds), !pm.currentMediaPlaybackStatus.Playing)
}
}
func (pm *PlaybackManager) handleTrackingRetry(reason string) {
// DEVNOTE: This event is not sent to the client
// We notify the playlist hub, so it can play the next episode (it's assumed that the user closed the player)
// ------- Playlist ------- //
go pm.playlistHub.onTrackingError()
}
func (pm *PlaybackManager) handleStreamingTrackingStarted(status *mediaplayer.PlaybackStatus) {
pm.eventMu.Lock()
defer pm.eventMu.Unlock()
if pm.currentStreamEpisode.IsAbsent() || pm.currentStreamMedia.IsAbsent() {
return
}
//// Get the media list entry
//// Note that it might be absent if the user is watching a stream that is not in the library
pm.currentMediaListEntry = pm.getStreamPlaybackDetails(pm.currentStreamMedia.MustGet().GetID())
// Set the playback type
pm.currentPlaybackType = StreamPlayback
// Reset the history map
pm.historyMap = make(map[string]PlaybackState)
// Set the current media playback status
pm.currentMediaPlaybackStatus = status
// Get the playback state
_ps := pm.getStreamPlaybackState(status)
// Notify subscribers
go func() {
pm.playbackStatusSubscribers.Range(func(key string, value *PlaybackStatusSubscriber) bool {
if value.canceled.Load() {
return true
}
value.EventCh <- PlaybackStatusChangedEvent{Status: *status, State: _ps}
value.EventCh <- StreamStartedEvent{Filename: status.Filename, Filepath: status.Filepath}
return true
})
}()
// Log
pm.Logger.Debug().Msg("playback manager: Tracking started for stream")
// Send event to the client
pm.wsEventManager.SendEvent(events.PlaybackManagerProgressTrackingStarted, _ps)
pm.continuityManager.SetExternalPlayerEpisodeDetails(&continuity.ExternalPlayerEpisodeDetails{
EpisodeNumber: pm.currentStreamEpisode.MustGet().GetProgressNumber(),
MediaId: pm.currentStreamMedia.MustGet().GetID(),
Filepath: "",
})
// ------- Discord ------- //
if pm.discordPresence != nil && !*pm.isOffline {
go pm.discordPresence.SetAnimeActivity(&discordrpc_presence.AnimeActivity{
ID: pm.currentStreamMedia.MustGet().GetID(),
Title: pm.currentStreamMedia.MustGet().GetPreferredTitle(),
Image: pm.currentStreamMedia.MustGet().GetCoverImageSafe(),
IsMovie: pm.currentStreamMedia.MustGet().IsMovie(),
EpisodeNumber: pm.currentStreamEpisode.MustGet().GetProgressNumber(),
Progress: int(pm.currentMediaPlaybackStatus.CurrentTimeInSeconds),
Duration: int(pm.currentMediaPlaybackStatus.DurationInSeconds),
TotalEpisodes: pm.currentStreamMedia.MustGet().Episodes,
CurrentEpisodeCount: pm.currentStreamMedia.MustGet().GetCurrentEpisodeCountOrNil(),
})
}
}
func (pm *PlaybackManager) handleStreamingPlaybackStatus(status *mediaplayer.PlaybackStatus) {
pm.eventMu.Lock()
defer pm.eventMu.Unlock()
if pm.currentStreamEpisode.IsAbsent() {
return
}
pm.currentPlaybackType = StreamPlayback
// Set the current media playback status
pm.currentMediaPlaybackStatus = status
// Get the playback state
_ps := pm.getStreamPlaybackState(status)
// If the same PlaybackState is in the history, update the ProgressUpdated flag
// PlaybackStatusCh has no way of knowing if the progress has been updated
if h, ok := pm.historyMap[status.Filename]; ok {
_ps.ProgressUpdated = h.ProgressUpdated
}
// Notify subscribers
go func() {
pm.playbackStatusSubscribers.Range(func(key string, value *PlaybackStatusSubscriber) bool {
if value.canceled.Load() {
return true
}
value.EventCh <- PlaybackStatusChangedEvent{Status: *status, State: _ps}
return true
})
}()
// Send the playback state to the client
pm.wsEventManager.SendEvent(events.PlaybackManagerProgressPlaybackState, _ps)
// ------- Discord ------- //
if pm.discordPresence != nil && !*pm.isOffline {
go pm.discordPresence.UpdateAnimeActivity(int(pm.currentMediaPlaybackStatus.CurrentTimeInSeconds), int(pm.currentMediaPlaybackStatus.DurationInSeconds), !pm.currentMediaPlaybackStatus.Playing)
}
}
func (pm *PlaybackManager) handleStreamingVideoCompleted(status *mediaplayer.PlaybackStatus) {
pm.eventMu.Lock()
defer pm.eventMu.Unlock()
if pm.currentStreamEpisode.IsAbsent() {
return
}
// Set the current media playback status
pm.currentMediaPlaybackStatus = status
// Get the playback state
_ps := pm.getStreamPlaybackState(status)
// Log
pm.Logger.Debug().Msg("playback manager: Received video completed event")
// Notify subscribers
go func() {
pm.playbackStatusSubscribers.Range(func(key string, value *PlaybackStatusSubscriber) bool {
if value.canceled.Load() {
return true
}
value.EventCh <- PlaybackStatusChangedEvent{Status: *status, State: _ps}
value.EventCh <- StreamCompletedEvent{Filename: status.Filename}
return true
})
}()
//
// Update the progress on AniList if auto update progress is enabled
//
pm.autoSyncCurrentProgress(&_ps)
// Send the playback state with the `ProgressUpdated` flag
// The client will use this to notify the user if the progress has been updated
pm.wsEventManager.SendEvent(events.PlaybackManagerProgressVideoCompleted, _ps)
// Push the video playback state to the history
pm.historyMap[status.Filename] = _ps
}
func (pm *PlaybackManager) handleStreamingTrackingStopped(reason string) {
pm.eventMu.Lock()
defer pm.eventMu.Unlock()
if pm.currentStreamEpisode.IsAbsent() {
return
}
if pm.currentMediaPlaybackStatus != nil {
pm.continuityManager.UpdateExternalPlayerEpisodeWatchHistoryItem(pm.currentMediaPlaybackStatus.CurrentTimeInSeconds, pm.currentMediaPlaybackStatus.DurationInSeconds)
}
// Notify subscribers
go func() {
pm.playbackStatusSubscribers.Range(func(key string, value *PlaybackStatusSubscriber) bool {
if value.canceled.Load() {
return true
}
value.EventCh <- StreamStoppedEvent{Reason: reason}
return true
})
}()
pm.Logger.Debug().Msg("playback manager: Received tracking stopped event")
pm.wsEventManager.SendEvent(events.PlaybackManagerProgressTrackingStopped, reason)
// ------- Discord ------- //
if pm.discordPresence != nil && !*pm.isOffline {
go pm.discordPresence.Close()
}
}
//////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
// Local File
//////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
// getLocalFilePlaybackState returns a new PlaybackState
func (pm *PlaybackManager) getLocalFilePlaybackState(status *mediaplayer.PlaybackStatus) PlaybackState {
pm.mu.Lock()
defer pm.mu.Unlock()
currentLocalFileWrapperEntry, ok := pm.currentLocalFileWrapperEntry.Get()
if !ok {
return PlaybackState{}
}
currentLocalFile, ok := pm.currentLocalFile.Get()
if !ok {
return PlaybackState{}
}
currentMediaListEntry, ok := pm.currentMediaListEntry.Get()
if !ok {
return PlaybackState{}
}
// Find the following episode
_, canPlayNext := currentLocalFileWrapperEntry.FindNextEpisode(currentLocalFile)
return PlaybackState{
EpisodeNumber: currentLocalFileWrapperEntry.GetProgressNumber(currentLocalFile),
AniDbEpisode: currentLocalFile.GetAniDBEpisode(),
MediaTitle: currentMediaListEntry.GetMedia().GetPreferredTitle(),
MediaTotalEpisodes: currentMediaListEntry.GetMedia().GetCurrentEpisodeCount(),
MediaCoverImage: currentMediaListEntry.GetMedia().GetCoverImageSafe(),
MediaId: currentMediaListEntry.GetMedia().GetID(),
Filename: status.Filename,
CompletionPercentage: status.CompletionPercentage,
CanPlayNext: canPlayNext,
}
}
//////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
// Stream
//////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
// getStreamPlaybackState returns a new PlaybackState
func (pm *PlaybackManager) getStreamPlaybackState(status *mediaplayer.PlaybackStatus) PlaybackState {
pm.mu.Lock()
defer pm.mu.Unlock()
currentStreamEpisode, ok := pm.currentStreamEpisode.Get()
if !ok {
return PlaybackState{}
}
currentStreamMedia, ok := pm.currentStreamMedia.Get()
if !ok {
return PlaybackState{}
}
currentStreamAniDbEpisode, ok := pm.currentStreamAniDbEpisode.Get()
if !ok {
return PlaybackState{}
}
return PlaybackState{
EpisodeNumber: currentStreamEpisode.GetProgressNumber(),
AniDbEpisode: currentStreamAniDbEpisode,
MediaTitle: currentStreamMedia.GetPreferredTitle(),
MediaTotalEpisodes: currentStreamMedia.GetCurrentEpisodeCount(),
MediaCoverImage: currentStreamMedia.GetCoverImageSafe(),
MediaId: currentStreamMedia.GetID(),
Filename: cmp.Or(status.Filename, "Stream"),
CompletionPercentage: status.CompletionPercentage,
CanPlayNext: false, // DEVNOTE: This is not used for streams
}
}
//////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
// autoSyncCurrentProgress syncs the current video playback progress with providers.
// This is called once when a "video complete" event is heard.
func (pm *PlaybackManager) autoSyncCurrentProgress(_ps *PlaybackState) {
shouldUpdate, err := pm.Database.AutoUpdateProgressIsEnabled()
if err != nil {
pm.Logger.Error().Err(err).Msg("playback manager: Failed to check if auto update progress is enabled")
return
}
if !shouldUpdate {
return
}
switch pm.currentPlaybackType {
case LocalFilePlayback:
// Note :currentMediaListEntry MUST be defined since we assume that the media is in the user's library
if pm.currentMediaListEntry.IsAbsent() || pm.currentLocalFileWrapperEntry.IsAbsent() || pm.currentLocalFile.IsAbsent() {
return
}
// Check if we should update the progress
// If the current progress is lower than the episode progress number
epProgressNum := pm.currentLocalFileWrapperEntry.MustGet().GetProgressNumber(pm.currentLocalFile.MustGet())
if *pm.currentMediaListEntry.MustGet().Progress >= epProgressNum {
return
}
case StreamPlayback:
if pm.currentStreamEpisode.IsAbsent() || pm.currentStreamMedia.IsAbsent() {
return
}
// Do not auto update progress is the media is in the library AND the progress is higher than the current episode
epProgressNum := pm.currentStreamEpisode.MustGet().GetProgressNumber()
if pm.currentMediaListEntry.IsPresent() && *pm.currentMediaListEntry.MustGet().Progress >= epProgressNum {
return
}
}
// Update the progress on AniList
pm.Logger.Debug().Msg("playback manager: Updating progress on AniList")
err = pm.updateProgress()
if err != nil {
_ps.ProgressUpdated = false
pm.wsEventManager.SendEvent(events.ErrorToast, "Failed to update progress on AniList")
} else {
_ps.ProgressUpdated = true
pm.wsEventManager.SendEvent(events.PlaybackManagerProgressUpdated, _ps)
}
}
// SyncCurrentProgress syncs the current video playback progress with providers
// This method is called when the user manually requests to sync the progress
// - This method will return an error only if the progress update fails on AniList
// - This method will refresh the anilist collection
func (pm *PlaybackManager) SyncCurrentProgress() error {
pm.eventMu.RLock()
err := pm.updateProgress()
if err != nil {
pm.eventMu.RUnlock()
return err
}
// Push the current playback state to the history
if pm.currentMediaPlaybackStatus != nil {
var _ps PlaybackState
switch pm.currentPlaybackType {
case LocalFilePlayback:
pm.getLocalFilePlaybackState(pm.currentMediaPlaybackStatus)
case StreamPlayback:
pm.getStreamPlaybackState(pm.currentMediaPlaybackStatus)
}
_ps.ProgressUpdated = true
pm.historyMap[pm.currentMediaPlaybackStatus.Filename] = _ps
pm.wsEventManager.SendEvent(events.PlaybackManagerProgressUpdated, _ps)
}
pm.refreshAnimeCollectionFunc()
pm.eventMu.RUnlock()
return nil
}
// updateProgress updates the progress of the current video playback on AniList and MyAnimeList.
// This only returns an error if the progress update fails on AniList
// - /!\ When this is called, the PlaybackState should have been pushed to the history
func (pm *PlaybackManager) updateProgress() (err error) {
var mediaId int
var epNum int
var totalEpisodes int
switch pm.currentPlaybackType {
case LocalFilePlayback:
//
// Local File
//
if pm.currentLocalFileWrapperEntry.IsAbsent() || pm.currentLocalFile.IsAbsent() || pm.currentMediaListEntry.IsAbsent() {
return errors.New("no video is being watched")
}
defer util.HandlePanicInModuleWithError("playbackmanager/updateProgress", &err)
/// Online
mediaId = pm.currentMediaListEntry.MustGet().GetMedia().GetID()
epNum = pm.currentLocalFileWrapperEntry.MustGet().GetProgressNumber(pm.currentLocalFile.MustGet())
totalEpisodes = pm.currentMediaListEntry.MustGet().GetMedia().GetTotalEpisodeCount() // total episode count or -1
case StreamPlayback:
//
// Stream
//
// Last sanity check
if pm.currentStreamEpisode.IsAbsent() || pm.currentStreamMedia.IsAbsent() {
return errors.New("no video is being watched")
}
mediaId = pm.currentStreamMedia.MustGet().ID
epNum = pm.currentStreamEpisode.MustGet().GetProgressNumber()
totalEpisodes = pm.currentStreamMedia.MustGet().GetTotalEpisodeCount() // total episode count or -1
case ManualTrackingPlayback:
//
// Manual Tracking
//
if pm.currentManualTrackingState.IsAbsent() {
return errors.New("no media file is being manually tracked")
}
defer func() {
if pm.manualTrackingCtxCancel != nil {
pm.manualTrackingCtxCancel()
}
}()
/// Online
mediaId = pm.currentManualTrackingState.MustGet().MediaId
epNum = pm.currentManualTrackingState.MustGet().EpisodeNumber
totalEpisodes = pm.currentManualTrackingState.MustGet().TotalEpisodes
default:
return errors.New("unknown playback type")
}
if mediaId == 0 { // Sanity check
return errors.New("media ID not found")
}
// Update the progress on AniList
err = pm.platform.UpdateEntryProgress(
context.Background(),
mediaId,
epNum,
&totalEpisodes,
)
if err != nil {
pm.Logger.Error().Err(err).Msg("playback manager: Error occurred while updating progress on AniList")
return ErrProgressUpdateAnilist
}
pm.refreshAnimeCollectionFunc() // Refresh the AniList collection
pm.Logger.Info().Msg("playback manager: Updated progress on AniList")
return nil
}

View File

@@ -0,0 +1,22 @@
package playbackmanager
import "seanime/internal/library/anime"
type (
StreamMagnetRequestOptions struct {
MagnetLink string `json:"magnet_link"` // magnet link to stream
OptionalMediaId int `json:"optionalMediaId,omitempty"` // optional media ID to associate with the magnet link
Untracked bool `json:"untracked"`
}
// TrackedStreamMagnetRequestResponse is returned after analysis of the magnet link
TrackedStreamMagnetRequestResponse struct {
EpisodeNumber int `json:"episodeNumber"` // episode number of the magnet link
EpisodeCollection *anime.EpisodeCollection `json:"episodeCollection"`
}
TrackedStreamMagnetOptions struct {
EpisodeNumber int `json:"episodeNumber"`
AniDBEpisode string `json:"anidbEpisode"`
}
)

View File

@@ -0,0 +1,141 @@
package playbackmanager
import (
"errors"
"fmt"
"seanime/internal/api/anilist"
"seanime/internal/database/db_bridge"
"seanime/internal/hook"
"seanime/internal/library/anime"
"seanime/internal/util"
"strings"
"github.com/samber/mo"
)
// GetCurrentMediaID returns the media id of the currently playing media
func (pm *PlaybackManager) GetCurrentMediaID() (int, error) {
if pm.currentLocalFile.IsAbsent() {
return 0, errors.New("no media is currently playing")
}
return pm.currentLocalFile.MustGet().MediaId, nil
}
// GetLocalFilePlaybackDetails is called once everytime a new video is played. It returns the anilist entry, local file and local file wrapper entry.
func (pm *PlaybackManager) getLocalFilePlaybackDetails(path string) (*anilist.AnimeListEntry, *anime.LocalFile, *anime.LocalFileWrapperEntry, error) {
pm.mu.Lock()
defer pm.mu.Unlock()
// Normalize path
path = util.NormalizePath(path)
pm.Logger.Debug().Str("path", path).Msg("playback manager: Getting local file playback details")
// Find the local file from the path
lfs, _, err := db_bridge.GetLocalFiles(pm.Database)
if err != nil {
return nil, nil, nil, fmt.Errorf("error getting local files: %s", err.Error())
}
reqEvent := &PlaybackLocalFileDetailsRequestedEvent{
Path: path,
LocalFiles: lfs,
AnimeListEntry: &anilist.AnimeListEntry{},
LocalFile: &anime.LocalFile{},
LocalFileWrapperEntry: &anime.LocalFileWrapperEntry{},
}
err = hook.GlobalHookManager.OnPlaybackLocalFileDetailsRequested().Trigger(reqEvent)
if err != nil {
return nil, nil, nil, err
}
lfs = reqEvent.LocalFiles // Override the local files
// Default prevented, use the hook's details
if reqEvent.DefaultPrevented {
pm.Logger.Debug().Msg("playback manager: Local file details processing prevented by hook")
if reqEvent.AnimeListEntry == nil || reqEvent.LocalFile == nil || reqEvent.LocalFileWrapperEntry == nil {
return nil, nil, nil, errors.New("local file details not found")
}
return reqEvent.AnimeListEntry, reqEvent.LocalFile, reqEvent.LocalFileWrapperEntry, nil
}
var lf *anime.LocalFile
// Find the local file from the path
for _, l := range lfs {
if l.GetNormalizedPath() == path {
lf = l
pm.Logger.Debug().Msg("playback manager: Local file found by path")
break
}
}
// If the local file is not found, the path might be a filename (in the case of VLC)
if lf == nil {
for _, l := range lfs {
if strings.ToLower(l.Name) == path {
pm.Logger.Debug().Msg("playback manager: Local file found by name")
lf = l
break
}
}
}
if lf == nil {
return nil, nil, nil, errors.New("local file not found")
}
if lf.MediaId == 0 {
return nil, nil, nil, errors.New("local file has not been matched")
}
if pm.animeCollection.IsAbsent() {
return nil, nil, nil, fmt.Errorf("error getting anime collection: %w", err)
}
ret, ok := pm.animeCollection.MustGet().GetListEntryFromAnimeId(lf.MediaId)
if !ok {
return nil, nil, nil, errors.New("anilist list entry not found")
}
// Create local file wrapper
lfw := anime.NewLocalFileWrapper(lfs)
lfe, ok := lfw.GetLocalEntryById(lf.MediaId)
if !ok {
return nil, nil, nil, errors.New("local file wrapper entry not found")
}
return ret, lf, lfe, nil
}
// GetStreamPlaybackDetails is called once everytime a new video is played.
func (pm *PlaybackManager) getStreamPlaybackDetails(mId int) mo.Option[*anilist.AnimeListEntry] {
pm.mu.Lock()
defer pm.mu.Unlock()
if pm.animeCollection.IsAbsent() {
return mo.None[*anilist.AnimeListEntry]()
}
reqEvent := &PlaybackStreamDetailsRequestedEvent{
AnimeCollection: pm.animeCollection.MustGet(),
MediaId: mId,
AnimeListEntry: &anilist.AnimeListEntry{},
}
err := hook.GlobalHookManager.OnPlaybackStreamDetailsRequested().Trigger(reqEvent)
if err != nil {
return mo.None[*anilist.AnimeListEntry]()
}
if reqEvent.DefaultPrevented {
pm.Logger.Debug().Msg("playback manager: Stream details processing prevented by hook")
if reqEvent.AnimeListEntry == nil {
return mo.None[*anilist.AnimeListEntry]()
}
return mo.Some(reqEvent.AnimeListEntry)
}
ret, ok := pm.animeCollection.MustGet().GetListEntryFromAnimeId(mId)
if !ok {
return mo.None[*anilist.AnimeListEntry]()
}
return mo.Some(ret)
}