node build fixed
This commit is contained in:
@@ -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"`
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
235
seanime-2.9.10/internal/library/playbackmanager/playlist.go
Normal file
235
seanime-2.9.10/internal/library/playbackmanager/playlist.go
Normal 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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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"`
|
||||
}
|
||||
)
|
||||
141
seanime-2.9.10/internal/library/playbackmanager/utils.go
Normal file
141
seanime-2.9.10/internal/library/playbackmanager/utils.go
Normal 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)
|
||||
}
|
||||
Reference in New Issue
Block a user