1036 lines
30 KiB
Go
1036 lines
30 KiB
Go
package mediaplayer
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"seanime/internal/continuity"
|
|
"seanime/internal/events"
|
|
"seanime/internal/hook"
|
|
"seanime/internal/mediaplayers/iina"
|
|
mpchc2 "seanime/internal/mediaplayers/mpchc"
|
|
"seanime/internal/mediaplayers/mpv"
|
|
vlc2 "seanime/internal/mediaplayers/vlc"
|
|
"seanime/internal/util/result"
|
|
"sync"
|
|
"time"
|
|
|
|
"github.com/rs/zerolog"
|
|
)
|
|
|
|
const (
|
|
PlayerClosedEvent = "Player closed"
|
|
)
|
|
|
|
type PlaybackType string
|
|
|
|
const (
|
|
PlaybackTypeFile PlaybackType = "file"
|
|
PlaybackTypeStream PlaybackType = "stream"
|
|
)
|
|
|
|
type (
|
|
// Repository provides a common interface to interact with media players
|
|
Repository struct {
|
|
Logger *zerolog.Logger
|
|
Default string
|
|
VLC *vlc2.VLC
|
|
MpcHc *mpchc2.MpcHc
|
|
Mpv *mpv.Mpv
|
|
Iina *iina.Iina
|
|
wsEventManager events.WSEventManagerInterface
|
|
continuityManager *continuity.Manager
|
|
playerInUse string
|
|
completionThreshold float64
|
|
mu sync.RWMutex
|
|
isRunning bool
|
|
currentPlaybackStatus *PlaybackStatus
|
|
subscribers *result.Map[string, *RepositorySubscriber]
|
|
cancel context.CancelFunc
|
|
exitedCh chan struct{} // Closed when the media player exits
|
|
}
|
|
|
|
NewRepositoryOptions struct {
|
|
Logger *zerolog.Logger
|
|
Default string
|
|
VLC *vlc2.VLC
|
|
MpcHc *mpchc2.MpcHc
|
|
Mpv *mpv.Mpv
|
|
Iina *iina.Iina
|
|
WSEventManager events.WSEventManagerInterface
|
|
ContinuityManager *continuity.Manager
|
|
}
|
|
|
|
// RepositorySubscriber provides a single event channel for all media player events
|
|
RepositorySubscriber struct {
|
|
EventCh chan MediaPlayerEvent
|
|
}
|
|
|
|
// MediaPlayerEvent is the base interface for all media player events
|
|
MediaPlayerEvent interface {
|
|
Type() string
|
|
}
|
|
|
|
// Local file playback events
|
|
TrackingStartedEvent struct {
|
|
Status *PlaybackStatus
|
|
}
|
|
|
|
TrackingRetryEvent struct {
|
|
Reason string
|
|
}
|
|
|
|
VideoCompletedEvent struct {
|
|
Status *PlaybackStatus
|
|
}
|
|
|
|
TrackingStoppedEvent struct {
|
|
Reason string
|
|
}
|
|
|
|
PlaybackStatusEvent struct {
|
|
Status *PlaybackStatus
|
|
}
|
|
|
|
// Streaming playback events
|
|
StreamingTrackingStartedEvent struct {
|
|
Status *PlaybackStatus
|
|
}
|
|
|
|
StreamingTrackingRetryEvent struct {
|
|
Reason string
|
|
}
|
|
|
|
StreamingVideoCompletedEvent struct {
|
|
Status *PlaybackStatus
|
|
}
|
|
|
|
StreamingTrackingStoppedEvent struct {
|
|
Reason string
|
|
}
|
|
|
|
StreamingPlaybackStatusEvent struct {
|
|
Status *PlaybackStatus
|
|
}
|
|
|
|
PlaybackStatus struct {
|
|
CompletionPercentage float64 `json:"completionPercentage"`
|
|
Playing bool `json:"playing"`
|
|
Filename string `json:"filename"`
|
|
Path string `json:"path"`
|
|
Duration int `json:"duration"` // in ms
|
|
Filepath string `json:"filepath"`
|
|
|
|
CurrentTimeInSeconds float64 `json:"currentTimeInSeconds"` // in seconds
|
|
DurationInSeconds float64 `json:"durationInSeconds"` // in seconds
|
|
|
|
PlaybackType PlaybackType `json:"playbackType"` // "file", "stream"
|
|
}
|
|
)
|
|
|
|
func (e TrackingStartedEvent) Type() string { return "tracking_started" }
|
|
func (e TrackingRetryEvent) Type() string { return "tracking_retry" }
|
|
func (e VideoCompletedEvent) Type() string { return "video_completed" }
|
|
func (e TrackingStoppedEvent) Type() string { return "tracking_stopped" }
|
|
func (e PlaybackStatusEvent) Type() string { return "playback_status" }
|
|
func (e StreamingTrackingStartedEvent) Type() string { return "streaming_tracking_started" }
|
|
func (e StreamingTrackingRetryEvent) Type() string { return "streaming_tracking_retry" }
|
|
func (e StreamingVideoCompletedEvent) Type() string { return "streaming_video_completed" }
|
|
func (e StreamingTrackingStoppedEvent) Type() string { return "streaming_tracking_stopped" }
|
|
func (e StreamingPlaybackStatusEvent) Type() string { return "streaming_playback_status" }
|
|
|
|
func NewRepository(opts *NewRepositoryOptions) *Repository {
|
|
|
|
return &Repository{
|
|
Logger: opts.Logger,
|
|
Default: opts.Default,
|
|
VLC: opts.VLC,
|
|
MpcHc: opts.MpcHc,
|
|
Mpv: opts.Mpv,
|
|
Iina: opts.Iina,
|
|
wsEventManager: opts.WSEventManager,
|
|
continuityManager: opts.ContinuityManager,
|
|
completionThreshold: 0.8,
|
|
subscribers: result.NewResultMap[string, *RepositorySubscriber](),
|
|
currentPlaybackStatus: &PlaybackStatus{},
|
|
exitedCh: make(chan struct{}),
|
|
}
|
|
}
|
|
|
|
func (m *Repository) Subscribe(id string) *RepositorySubscriber {
|
|
sub := &RepositorySubscriber{
|
|
EventCh: make(chan MediaPlayerEvent, 10), // Buffered channel to avoid blocking
|
|
}
|
|
m.subscribers.Set(id, sub)
|
|
return sub
|
|
}
|
|
|
|
func (m *Repository) Unsubscribe(id string) {
|
|
m.subscribers.Delete(id)
|
|
}
|
|
|
|
func (m *Repository) GetStatus() *PlaybackStatus {
|
|
m.mu.Lock()
|
|
defer m.mu.Unlock()
|
|
return m.currentPlaybackStatus
|
|
}
|
|
|
|
// PullStatus returns the current playback status directly from the media player.
|
|
func (m *Repository) PullStatus() (*PlaybackStatus, bool) {
|
|
m.mu.RLock()
|
|
defer m.mu.RUnlock()
|
|
|
|
status, err := m.getStatus()
|
|
if err != nil {
|
|
return nil, false
|
|
}
|
|
|
|
var ok bool
|
|
if m.currentPlaybackStatus == nil {
|
|
return nil, false
|
|
}
|
|
|
|
if m.currentPlaybackStatus.PlaybackType == PlaybackTypeFile {
|
|
ok = m.processStatus(m.Default, status)
|
|
} else {
|
|
ok = m.processStreamStatus(m.Default, status)
|
|
}
|
|
return m.currentPlaybackStatus, ok
|
|
}
|
|
|
|
func (m *Repository) IsRunning() bool {
|
|
return m.isRunning
|
|
}
|
|
|
|
func (m *Repository) GetExecutablePath() string {
|
|
switch m.Default {
|
|
case "vlc":
|
|
return m.VLC.GetExecutablePath()
|
|
case "mpc-hc":
|
|
return m.MpcHc.GetExecutablePath()
|
|
case "mpv":
|
|
return m.Mpv.GetExecutablePath()
|
|
case "iina":
|
|
return m.Iina.GetExecutablePath()
|
|
}
|
|
return ""
|
|
}
|
|
|
|
func (m *Repository) GetDefault() string {
|
|
return m.Default
|
|
}
|
|
|
|
// Play will start the media player and load the video at the given path.
|
|
// The implementation of the specific media player is handled by the respective media player package.
|
|
// Calling it multiple *should* not open multiple instances of the media player -- subsequent calls should just load a new video if the media player is already open.
|
|
func (m *Repository) Play(path string) error {
|
|
|
|
m.Logger.Debug().Str("path", path).Msg("media player: Media requested")
|
|
|
|
lastWatched := m.continuityManager.GetExternalPlayerEpisodeWatchHistoryItem(path, false, 0, 0)
|
|
|
|
switch m.Default {
|
|
case "vlc":
|
|
err := m.VLC.Start()
|
|
if err != nil {
|
|
m.Logger.Error().Err(err).Msg("media player: Could not start media player using VLC")
|
|
return fmt.Errorf("could not start VLC, %w", err)
|
|
}
|
|
|
|
err = m.VLC.AddAndPlay(path)
|
|
if err != nil {
|
|
m.Logger.Error().Err(err).Msg("media player: Could not open and play video using VLC")
|
|
if m.VLC.Path != "" {
|
|
return fmt.Errorf("could not open and play video, %w", err)
|
|
} else {
|
|
return fmt.Errorf("could not open and play video, %w", err)
|
|
}
|
|
}
|
|
|
|
if m.continuityManager.GetSettings().WatchContinuityEnabled {
|
|
if lastWatched.Found {
|
|
time.Sleep(400 * time.Millisecond)
|
|
_ = m.VLC.ForcePause()
|
|
time.Sleep(400 * time.Millisecond)
|
|
_ = m.VLC.Seek(fmt.Sprintf("%d", int(lastWatched.Item.CurrentTime)))
|
|
time.Sleep(400 * time.Millisecond)
|
|
_ = m.VLC.Resume()
|
|
}
|
|
}
|
|
|
|
return nil
|
|
case "mpc-hc":
|
|
err := m.MpcHc.Start()
|
|
if err != nil {
|
|
m.Logger.Error().Err(err).Msg("media player: Could not start media player using MPC-HC")
|
|
return fmt.Errorf("could not start MPC-HC, %w", err)
|
|
}
|
|
_, err = m.MpcHc.OpenAndPlay(path)
|
|
if err != nil {
|
|
m.Logger.Error().Err(err).Msg("media player: Could not open and play video using MPC-HC")
|
|
return fmt.Errorf("could not open and play video, %w", err)
|
|
}
|
|
|
|
if m.continuityManager.GetSettings().WatchContinuityEnabled {
|
|
if lastWatched.Found {
|
|
time.Sleep(400 * time.Millisecond)
|
|
_ = m.MpcHc.Pause()
|
|
time.Sleep(400 * time.Millisecond)
|
|
_ = m.MpcHc.Seek(int(lastWatched.Item.CurrentTime))
|
|
time.Sleep(400 * time.Millisecond)
|
|
_ = m.MpcHc.Play()
|
|
}
|
|
}
|
|
|
|
return nil
|
|
case "mpv":
|
|
if m.continuityManager.GetSettings().WatchContinuityEnabled {
|
|
var args []string
|
|
if lastWatched.Found {
|
|
//args = append(args, "--no-resume-playback", fmt.Sprintf("--start=+%d", int(lastWatched.Item.CurrentTime)))
|
|
args = append(args, "--no-resume-playback")
|
|
}
|
|
err := m.Mpv.OpenAndPlay(path, args...)
|
|
if err != nil {
|
|
m.Logger.Error().Err(err).Msg("media player: Could not open and play video using MPV")
|
|
return fmt.Errorf("could not open and play video, %w", err)
|
|
}
|
|
if lastWatched.Found {
|
|
_ = m.Mpv.SeekTo(lastWatched.Item.CurrentTime)
|
|
}
|
|
} else {
|
|
err := m.Mpv.OpenAndPlay(path)
|
|
if err != nil {
|
|
m.Logger.Error().Err(err).Msg("media player: Could not open and play video using MPV")
|
|
return fmt.Errorf("could not open and play video, %w", err)
|
|
}
|
|
}
|
|
|
|
return nil
|
|
case "iina":
|
|
if m.continuityManager.GetSettings().WatchContinuityEnabled {
|
|
var args []string
|
|
if lastWatched.Found {
|
|
//args = append(args, "--mpv-no-resume-playback", fmt.Sprintf("--mpv-start=+%d", int(lastWatched.Item.CurrentTime)))
|
|
args = append(args, "--mpv-no-resume-playback")
|
|
}
|
|
err := m.Iina.OpenAndPlay(path, args...)
|
|
if err != nil {
|
|
m.Logger.Error().Err(err).Msg("media player: Could not open and play video using IINA")
|
|
return fmt.Errorf("could not open and play video, %w", err)
|
|
}
|
|
if lastWatched.Found {
|
|
_ = m.Iina.SeekTo(lastWatched.Item.CurrentTime)
|
|
}
|
|
} else {
|
|
err := m.Iina.OpenAndPlay(path)
|
|
if err != nil {
|
|
m.Logger.Error().Err(err).Msg("media player: Could not open and play video using IINA")
|
|
return fmt.Errorf("could not open and play video, %w", err)
|
|
}
|
|
}
|
|
|
|
return nil
|
|
default:
|
|
return errors.New("no default media player set")
|
|
}
|
|
|
|
}
|
|
|
|
func (m *Repository) Pause() error {
|
|
switch m.Default {
|
|
case "vlc":
|
|
return m.VLC.Pause()
|
|
case "mpc-hc":
|
|
return m.MpcHc.Pause()
|
|
case "mpv":
|
|
return m.Mpv.Pause()
|
|
case "iina":
|
|
return m.Iina.Pause()
|
|
default:
|
|
return errors.New("no default media player set")
|
|
}
|
|
}
|
|
|
|
func (m *Repository) Resume() error {
|
|
switch m.Default {
|
|
case "vlc":
|
|
return m.VLC.Resume()
|
|
case "mpc-hc":
|
|
return m.MpcHc.Play()
|
|
case "mpv":
|
|
return m.Mpv.Resume()
|
|
case "iina":
|
|
return m.Iina.Resume()
|
|
default:
|
|
return errors.New("no default media player set")
|
|
}
|
|
}
|
|
|
|
func (m *Repository) Seek(seconds float64) error {
|
|
switch m.Default {
|
|
case "vlc":
|
|
return m.VLC.Seek(fmt.Sprintf("%d", int(seconds)))
|
|
case "mpc-hc":
|
|
return m.MpcHc.Seek(int(seconds))
|
|
case "mpv":
|
|
return m.Mpv.Seek(seconds)
|
|
case "iina":
|
|
return m.Iina.Seek(seconds)
|
|
default:
|
|
return errors.New("no default media player set")
|
|
}
|
|
}
|
|
|
|
func (m *Repository) Stream(streamUrl string, episode int, mediaId int, windowTitle string) error {
|
|
|
|
m.Logger.Debug().Str("streamUrl", streamUrl).Msg("media player: Stream requested")
|
|
var err error
|
|
|
|
switch m.Default {
|
|
case "vlc":
|
|
err = m.VLC.Start()
|
|
case "mpc-hc":
|
|
err = m.MpcHc.Start()
|
|
_, err = m.MpcHc.OpenAndPlay(streamUrl)
|
|
case "mpv":
|
|
// MPV does not need to be started
|
|
case "iina":
|
|
// IINA does not need to be started
|
|
default:
|
|
return errors.New("no default media player set")
|
|
}
|
|
|
|
if err != nil {
|
|
m.Logger.Error().Err(err).Msg("media player: Could not start media player for stream")
|
|
return fmt.Errorf("could not open media player, %w", err)
|
|
}
|
|
|
|
lastWatched := m.continuityManager.GetExternalPlayerEpisodeWatchHistoryItem("", true, episode, mediaId)
|
|
|
|
switch m.Default {
|
|
case "vlc":
|
|
err = m.VLC.AddAndPlay(streamUrl)
|
|
|
|
if m.continuityManager.GetSettings().WatchContinuityEnabled {
|
|
if lastWatched.Found {
|
|
time.Sleep(400 * time.Millisecond)
|
|
_ = m.VLC.ForcePause()
|
|
time.Sleep(400 * time.Millisecond)
|
|
_ = m.VLC.Seek(fmt.Sprintf("%d", int(lastWatched.Item.CurrentTime)))
|
|
time.Sleep(400 * time.Millisecond)
|
|
_ = m.VLC.Resume()
|
|
}
|
|
}
|
|
|
|
case "mpc-hc":
|
|
_, err = m.MpcHc.OpenAndPlay(streamUrl)
|
|
|
|
if m.continuityManager.GetSettings().WatchContinuityEnabled {
|
|
if lastWatched.Found {
|
|
time.Sleep(400 * time.Millisecond)
|
|
_ = m.MpcHc.Pause()
|
|
time.Sleep(400 * time.Millisecond)
|
|
_ = m.MpcHc.Seek(int(lastWatched.Item.CurrentTime))
|
|
time.Sleep(400 * time.Millisecond)
|
|
_ = m.MpcHc.Play()
|
|
}
|
|
}
|
|
|
|
case "mpv":
|
|
args := []string{}
|
|
if windowTitle != "" {
|
|
args = append(args, fmt.Sprintf("--title=%q", windowTitle))
|
|
}
|
|
if m.continuityManager.GetSettings().WatchContinuityEnabled {
|
|
err = m.Mpv.OpenAndPlay(streamUrl, args...)
|
|
if lastWatched.Found {
|
|
_ = m.Mpv.SeekTo(lastWatched.Item.CurrentTime)
|
|
}
|
|
} else {
|
|
err = m.Mpv.OpenAndPlay(streamUrl, args...)
|
|
}
|
|
|
|
case "iina":
|
|
args := []string{}
|
|
if windowTitle != "" {
|
|
args = append(args, fmt.Sprintf("--mpv-title=%q", windowTitle))
|
|
}
|
|
if m.continuityManager.GetSettings().WatchContinuityEnabled {
|
|
err = m.Iina.OpenAndPlay(streamUrl, args...)
|
|
if lastWatched.Found {
|
|
_ = m.Iina.SeekTo(lastWatched.Item.CurrentTime)
|
|
}
|
|
} else {
|
|
err = m.Iina.OpenAndPlay(streamUrl, args...)
|
|
}
|
|
|
|
}
|
|
|
|
if err != nil {
|
|
m.Logger.Error().Err(err).Msg("media player: Could not open and play stream")
|
|
return fmt.Errorf("could not open and play stream, %w", err)
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// Cancel will stop the tracking process and publish an "abnormal" event
|
|
func (m *Repository) Cancel() {
|
|
m.mu.Lock()
|
|
if m.cancel != nil {
|
|
m.Logger.Debug().Msg("media player: Cancel request received")
|
|
m.cancel()
|
|
m.trackingStopped("Something went wrong, tracking cancelled")
|
|
} else {
|
|
m.Logger.Debug().Msg("media player: Cancel request received, but no context found")
|
|
}
|
|
// Close MPV if it's the default player
|
|
if m.Default == "mpv" {
|
|
m.Mpv.CloseAll()
|
|
}
|
|
m.mu.Unlock()
|
|
}
|
|
|
|
// Stop will stop the tracking process and publish a "normal" event
|
|
func (m *Repository) Stop() {
|
|
m.mu.Lock()
|
|
if m.cancel != nil {
|
|
m.Logger.Debug().Msg("media player: Stop request received")
|
|
m.cancel()
|
|
m.cancel = nil
|
|
m.trackingStopped("Tracking stopped")
|
|
// Close MPV if it's the default player
|
|
if m.Default == "mpv" {
|
|
go m.Mpv.CloseAll()
|
|
}
|
|
}
|
|
m.mu.Unlock()
|
|
}
|
|
|
|
// StartTrackingTorrentStream will start tracking media player status for torrent streaming
|
|
func (m *Repository) StartTrackingTorrentStream() {
|
|
m.mu.Lock()
|
|
// If a previous context exists, cancel it
|
|
if m.cancel != nil {
|
|
m.Logger.Debug().Msg("media player: Cancelling previous context")
|
|
m.cancel()
|
|
}
|
|
|
|
// Create a new context
|
|
var trackingCtx context.Context
|
|
trackingCtx, m.cancel = context.WithCancel(context.Background())
|
|
|
|
done := make(chan struct{})
|
|
var filename string
|
|
var completed bool
|
|
var retries int
|
|
|
|
hookEvent := &MediaPlayerStreamTrackingRequestedEvent{
|
|
StartRefreshDelay: 3,
|
|
RefreshDelay: 1,
|
|
MaxRetries: 5,
|
|
MaxRetriesAfterStart: 5,
|
|
}
|
|
_ = hook.GlobalHookManager.OnMediaPlayerStreamTrackingRequested().Trigger(hookEvent)
|
|
startRefreshDelay := hookEvent.StartRefreshDelay
|
|
maxTries := hookEvent.MaxRetries
|
|
refreshDelay := hookEvent.RefreshDelay
|
|
maxRetriesAfterStart := hookEvent.MaxRetriesAfterStart
|
|
|
|
// Default prevented, do not track
|
|
if hookEvent.DefaultPrevented {
|
|
m.Logger.Debug().Msg("media player: Tracking cancelled by hook")
|
|
return
|
|
}
|
|
|
|
// Unlike normal tracking when the file is downloaded, we may need to wait a bit before we can get the status,
|
|
// so we won't count retries until it's confirmed that the file has started playing.
|
|
var trackingStarted bool
|
|
var waitInSeconds int
|
|
|
|
m.isRunning = true
|
|
gotFirstStatus := false
|
|
|
|
m.mu.Unlock()
|
|
|
|
go func() {
|
|
defer func() {
|
|
m.mu.Lock()
|
|
m.isRunning = false
|
|
if m.cancel != nil {
|
|
m.cancel()
|
|
}
|
|
m.mu.Unlock()
|
|
}()
|
|
for {
|
|
select {
|
|
case <-done:
|
|
m.mu.Lock()
|
|
m.Logger.Debug().Msg("media player: Connection lost")
|
|
m.isRunning = false
|
|
m.mu.Unlock()
|
|
return
|
|
case <-trackingCtx.Done():
|
|
m.mu.Lock()
|
|
m.Logger.Debug().Msg("media player: Context cancelled")
|
|
m.isRunning = false
|
|
m.mu.Unlock()
|
|
return
|
|
//case <-m.exitedCh:
|
|
// m.mu.Lock()
|
|
// m.Logger.Debug().Msg("media player: Player exited")
|
|
// m.isRunning = false
|
|
// m.streamingTrackingStopped(PlayerClosedEvent)
|
|
// m.mu.Unlock()
|
|
// return
|
|
default:
|
|
// Wait at least 3 seconds before we start checking the status
|
|
if !gotFirstStatus {
|
|
time.Sleep(time.Duration(startRefreshDelay) * time.Second)
|
|
} else {
|
|
time.Sleep(time.Duration(refreshDelay) * time.Second)
|
|
}
|
|
status, err := m.getStatus()
|
|
if err != nil {
|
|
if !trackingStarted {
|
|
if waitInSeconds > 60 {
|
|
m.Logger.Warn().Msg("media player: Ending goroutine, waited too long")
|
|
return
|
|
}
|
|
m.Logger.Trace().Msgf("media player: Waiting for stream, %d seconds", waitInSeconds)
|
|
waitInSeconds += refreshDelay
|
|
continue
|
|
} else {
|
|
m.streamingTrackingRetry("Failed to get player status")
|
|
m.Logger.Error().Msgf("media player: Failed to get player status, retrying (%d/%d)", retries+1, maxTries)
|
|
|
|
// Video is completed, and we are unable to get the status
|
|
// We can safely assume that the player has been closed
|
|
if retries == 1 && (completed || m.continuityManager.GetSettings().WatchContinuityEnabled) {
|
|
m.Logger.Debug().Msg("media player: Sending player closed event")
|
|
m.streamingTrackingStopped(PlayerClosedEvent)
|
|
close(done)
|
|
break
|
|
}
|
|
|
|
if retries >= maxTries-1 {
|
|
m.Logger.Debug().Msg("media player: Sending failed status query event")
|
|
m.streamingTrackingStopped("Failed to get player status")
|
|
close(done)
|
|
break
|
|
}
|
|
retries++
|
|
continue
|
|
}
|
|
}
|
|
|
|
trackingStarted = true
|
|
ok := m.processStreamStatus(m.Default, status)
|
|
|
|
if !ok {
|
|
m.streamingTrackingRetry("Failed to get player status")
|
|
m.Logger.Error().Interface("status", status).Msgf("media player: Failed to process status, retrying (%d/%d)", retries+1, maxRetriesAfterStart)
|
|
if retries >= maxRetriesAfterStart-1 {
|
|
m.Logger.Debug().Msg("media player: Sending failed status query event")
|
|
m.streamingTrackingStopped("Failed to process status")
|
|
close(done)
|
|
break
|
|
}
|
|
retries++
|
|
continue
|
|
}
|
|
|
|
// New video has started playing \/
|
|
if filename == "" || filename != m.currentPlaybackStatus.Filename {
|
|
m.Logger.Debug().Str("previousFilename", filename).Str("newFilename", m.currentPlaybackStatus.Filename).Msg("media player: Video loaded")
|
|
m.streamingTrackingStarted(m.currentPlaybackStatus)
|
|
filename = m.currentPlaybackStatus.Filename
|
|
completed = false
|
|
}
|
|
|
|
// Video completed \/
|
|
if m.currentPlaybackStatus.CompletionPercentage > m.completionThreshold && !completed {
|
|
m.Logger.Debug().Msg("media player: Video completed")
|
|
m.streamingVideoCompleted(m.currentPlaybackStatus)
|
|
completed = true
|
|
}
|
|
|
|
m.streamingPlaybackStatus(m.currentPlaybackStatus)
|
|
}
|
|
}
|
|
}()
|
|
}
|
|
|
|
// StartTracking will start tracking media player status.
|
|
// This method is safe to call multiple times -- it will cancel the previous context and start a new one.
|
|
func (m *Repository) StartTracking() {
|
|
m.mu.Lock()
|
|
// If a previous context exists, cancel it
|
|
if m.cancel != nil {
|
|
m.Logger.Debug().Msg("media player: Cancelling previous context")
|
|
m.cancel()
|
|
}
|
|
|
|
// Create a new context
|
|
var trackingCtx context.Context
|
|
trackingCtx, m.cancel = context.WithCancel(context.Background())
|
|
|
|
done := make(chan struct{})
|
|
var filename string
|
|
var completed bool
|
|
var retries int
|
|
|
|
hookEvent := &MediaPlayerLocalFileTrackingRequestedEvent{
|
|
StartRefreshDelay: 3,
|
|
RefreshDelay: 1,
|
|
MaxRetries: 5,
|
|
}
|
|
_ = hook.GlobalHookManager.OnMediaPlayerLocalFileTrackingRequested().Trigger(hookEvent)
|
|
startRefreshDelay := hookEvent.StartRefreshDelay
|
|
maxTries := hookEvent.MaxRetries
|
|
refreshDelay := hookEvent.RefreshDelay
|
|
|
|
// Default prevented, do not track
|
|
if hookEvent.DefaultPrevented {
|
|
m.Logger.Debug().Msg("media player: Tracking cancelled by hook")
|
|
return
|
|
}
|
|
|
|
m.isRunning = true
|
|
gotFirstStatus := false
|
|
|
|
m.mu.Unlock()
|
|
|
|
go func() {
|
|
for {
|
|
select {
|
|
case <-done:
|
|
m.mu.Lock()
|
|
m.Logger.Debug().Msg("media player: Connection lost")
|
|
m.isRunning = false
|
|
m.mu.Unlock()
|
|
if m.cancel != nil {
|
|
m.cancel()
|
|
m.cancel = nil
|
|
}
|
|
return
|
|
case <-trackingCtx.Done():
|
|
m.mu.Lock()
|
|
m.Logger.Debug().Msg("media player: Context cancelled")
|
|
m.isRunning = false
|
|
m.cancel = nil
|
|
m.mu.Unlock()
|
|
return
|
|
//case <-m.exitedCh:
|
|
// m.mu.Lock()
|
|
// m.Logger.Debug().Msg("media player: Player exited")
|
|
// m.isRunning = false
|
|
// m.trackingStopped(PlayerClosedEvent)
|
|
// m.mu.Unlock()
|
|
// return
|
|
default:
|
|
// Wait at least X seconds before we start checking the status
|
|
if !gotFirstStatus {
|
|
time.Sleep(time.Duration(startRefreshDelay) * time.Second)
|
|
} else {
|
|
time.Sleep(time.Duration(refreshDelay) * time.Second)
|
|
}
|
|
status, err := m.getStatus()
|
|
if err != nil {
|
|
m.trackingRetry("Failed to get player status")
|
|
m.Logger.Error().Msgf("media player: Failed to get player status, retrying (%d/%d)", retries+1, maxTries)
|
|
|
|
// Video is completed, and we are unable to get the status
|
|
// We can safely assume that the player has been closed
|
|
if retries == 1 && (completed || m.continuityManager.GetSettings().WatchContinuityEnabled) {
|
|
m.trackingStopped(PlayerClosedEvent)
|
|
close(done)
|
|
break
|
|
}
|
|
|
|
if retries >= maxTries-1 {
|
|
m.trackingStopped("Failed to get player status")
|
|
close(done)
|
|
break
|
|
}
|
|
retries++
|
|
continue
|
|
}
|
|
|
|
gotFirstStatus = true
|
|
|
|
ok := m.processStatus(m.Default, status)
|
|
|
|
if !ok {
|
|
m.trackingRetry("Failed to get player status")
|
|
m.Logger.Error().Interface("status", status).Msgf("media player: Failed to process status, retrying (%d/%d)", retries+1, maxTries)
|
|
if retries >= maxTries-1 {
|
|
m.trackingStopped("Failed to process status")
|
|
close(done)
|
|
break
|
|
}
|
|
retries++
|
|
continue
|
|
}
|
|
|
|
// New video has started playing \/
|
|
if filename == "" || filename != m.currentPlaybackStatus.Filename {
|
|
m.Logger.Debug().Str("previousFilename", filename).Str("newFilename", m.currentPlaybackStatus.Filename).Msg("media player: Video started playing")
|
|
m.Logger.Debug().Interface("currentPlaybackStatus", m.currentPlaybackStatus).Msg("media player: Playback status")
|
|
m.trackingStarted(m.currentPlaybackStatus)
|
|
filename = m.currentPlaybackStatus.Filename
|
|
completed = false
|
|
}
|
|
|
|
// Video completed \/
|
|
if m.currentPlaybackStatus.CompletionPercentage > m.completionThreshold && !completed {
|
|
m.Logger.Debug().Msg("media player: Video completed")
|
|
m.Logger.Debug().Interface("currentPlaybackStatus", m.currentPlaybackStatus).Msg("media player: Playback status")
|
|
m.videoCompleted(m.currentPlaybackStatus)
|
|
completed = true
|
|
}
|
|
|
|
m.playbackStatus(m.currentPlaybackStatus)
|
|
}
|
|
}
|
|
}()
|
|
}
|
|
|
|
//////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
|
|
|
|
func (m *Repository) trackingStopped(reason string) {
|
|
m.subscribers.Range(func(key string, value *RepositorySubscriber) bool {
|
|
value.EventCh <- TrackingStoppedEvent{Reason: reason}
|
|
return true
|
|
})
|
|
}
|
|
|
|
func (m *Repository) trackingStarted(status *PlaybackStatus) {
|
|
m.subscribers.Range(func(key string, value *RepositorySubscriber) bool {
|
|
value.EventCh <- TrackingStartedEvent{Status: status}
|
|
return true
|
|
})
|
|
}
|
|
|
|
func (m *Repository) trackingRetry(reason string) {
|
|
m.subscribers.Range(func(key string, value *RepositorySubscriber) bool {
|
|
value.EventCh <- TrackingRetryEvent{Reason: reason}
|
|
return true
|
|
})
|
|
}
|
|
|
|
func (m *Repository) videoCompleted(status *PlaybackStatus) {
|
|
m.subscribers.Range(func(key string, value *RepositorySubscriber) bool {
|
|
value.EventCh <- VideoCompletedEvent{Status: status}
|
|
return true
|
|
})
|
|
}
|
|
|
|
func (m *Repository) playbackStatus(status *PlaybackStatus) {
|
|
m.subscribers.Range(func(key string, value *RepositorySubscriber) bool {
|
|
value.EventCh <- PlaybackStatusEvent{Status: status}
|
|
return true
|
|
})
|
|
}
|
|
|
|
func (m *Repository) streamingTrackingStopped(reason string) {
|
|
m.subscribers.Range(func(key string, value *RepositorySubscriber) bool {
|
|
value.EventCh <- StreamingTrackingStoppedEvent{Reason: reason}
|
|
return true
|
|
})
|
|
}
|
|
|
|
func (m *Repository) streamingTrackingStarted(status *PlaybackStatus) {
|
|
m.subscribers.Range(func(key string, value *RepositorySubscriber) bool {
|
|
value.EventCh <- StreamingTrackingStartedEvent{Status: status}
|
|
return true
|
|
})
|
|
}
|
|
|
|
func (m *Repository) streamingTrackingRetry(reason string) {
|
|
m.subscribers.Range(func(key string, value *RepositorySubscriber) bool {
|
|
value.EventCh <- StreamingTrackingRetryEvent{Reason: reason}
|
|
return true
|
|
})
|
|
}
|
|
|
|
func (m *Repository) streamingVideoCompleted(status *PlaybackStatus) {
|
|
m.subscribers.Range(func(key string, value *RepositorySubscriber) bool {
|
|
value.EventCh <- StreamingVideoCompletedEvent{Status: status}
|
|
return true
|
|
})
|
|
}
|
|
|
|
func (m *Repository) streamingPlaybackStatus(status *PlaybackStatus) {
|
|
m.subscribers.Range(func(key string, value *RepositorySubscriber) bool {
|
|
value.EventCh <- StreamingPlaybackStatusEvent{Status: status}
|
|
return true
|
|
})
|
|
}
|
|
|
|
func (m *Repository) getStatus() (interface{}, error) {
|
|
switch m.Default {
|
|
case "vlc":
|
|
return m.VLC.GetStatus()
|
|
case "mpc-hc":
|
|
return m.MpcHc.GetVariables()
|
|
case "mpv":
|
|
return m.Mpv.GetPlaybackStatus()
|
|
case "iina":
|
|
return m.Iina.GetPlaybackStatus()
|
|
}
|
|
return nil, errors.New("unsupported media player")
|
|
}
|
|
|
|
func (m *Repository) processStatus(player string, status interface{}) bool {
|
|
m.currentPlaybackStatus.PlaybackType = PlaybackTypeFile
|
|
switch player {
|
|
case "vlc":
|
|
// Process VLC status
|
|
st, ok := status.(*vlc2.Status)
|
|
if !ok || st == nil {
|
|
return false
|
|
}
|
|
|
|
m.currentPlaybackStatus.CompletionPercentage = st.Position
|
|
m.currentPlaybackStatus.Playing = st.State == "playing"
|
|
m.currentPlaybackStatus.Filename = st.Information.Category["meta"].Filename
|
|
m.currentPlaybackStatus.Duration = int(st.Length * 1000)
|
|
m.currentPlaybackStatus.Filepath = st.Information.Category["meta"].Filename
|
|
|
|
m.currentPlaybackStatus.CurrentTimeInSeconds = float64(st.Time)
|
|
m.currentPlaybackStatus.DurationInSeconds = float64(st.Length)
|
|
return true
|
|
case "mpc-hc":
|
|
// Process MPC-HC status
|
|
st, ok := status.(*mpchc2.Variables)
|
|
if !ok || st == nil || st.Duration == 0 {
|
|
return false
|
|
}
|
|
|
|
m.currentPlaybackStatus.CompletionPercentage = st.Position / st.Duration
|
|
m.currentPlaybackStatus.Playing = st.State == 2
|
|
m.currentPlaybackStatus.Filename = st.File
|
|
m.currentPlaybackStatus.Duration = int(st.Duration)
|
|
m.currentPlaybackStatus.Filepath = st.FilePath
|
|
|
|
m.currentPlaybackStatus.CurrentTimeInSeconds = st.Position / 1000
|
|
m.currentPlaybackStatus.DurationInSeconds = st.Duration / 1000
|
|
|
|
return true
|
|
case "mpv":
|
|
// Process MPV status
|
|
st, ok := status.(*mpv.Playback)
|
|
if !ok || st == nil || st.Duration == 0 || st.IsRunning == false {
|
|
return false
|
|
}
|
|
|
|
m.currentPlaybackStatus.CompletionPercentage = st.Position / st.Duration
|
|
m.currentPlaybackStatus.Playing = !st.Paused
|
|
m.currentPlaybackStatus.Filename = st.Filename
|
|
m.currentPlaybackStatus.Duration = int(st.Duration)
|
|
m.currentPlaybackStatus.Filepath = st.Filepath
|
|
|
|
m.currentPlaybackStatus.CurrentTimeInSeconds = st.Position
|
|
m.currentPlaybackStatus.DurationInSeconds = st.Duration
|
|
|
|
return true
|
|
case "iina":
|
|
// Process IINA status
|
|
st, ok := status.(*iina.Playback)
|
|
if !ok || st == nil || st.Duration == 0 || st.IsRunning == false {
|
|
return false
|
|
}
|
|
|
|
m.currentPlaybackStatus.CompletionPercentage = st.Position / st.Duration
|
|
m.currentPlaybackStatus.Playing = !st.Paused
|
|
m.currentPlaybackStatus.Filename = st.Filename
|
|
m.currentPlaybackStatus.Duration = int(st.Duration)
|
|
m.currentPlaybackStatus.Filepath = st.Filepath
|
|
|
|
m.currentPlaybackStatus.CurrentTimeInSeconds = st.Position
|
|
m.currentPlaybackStatus.DurationInSeconds = st.Duration
|
|
|
|
return true
|
|
default:
|
|
return false
|
|
}
|
|
}
|
|
|
|
func (m *Repository) processStreamStatus(player string, status interface{}) bool {
|
|
m.currentPlaybackStatus.PlaybackType = PlaybackTypeStream
|
|
switch player {
|
|
case "vlc":
|
|
// Process VLC status
|
|
st, ok := status.(*vlc2.Status)
|
|
if !ok || st == nil {
|
|
return false
|
|
}
|
|
|
|
m.currentPlaybackStatus.CompletionPercentage = st.Position
|
|
m.currentPlaybackStatus.Playing = st.State == "playing"
|
|
m.currentPlaybackStatus.Filename = st.Information.Category["meta"].Filename
|
|
m.currentPlaybackStatus.Duration = int(st.Length * 1000)
|
|
m.currentPlaybackStatus.Filepath = st.Information.Category["meta"].Filename // VLC does not provide the filepath, use filename
|
|
|
|
m.currentPlaybackStatus.CurrentTimeInSeconds = float64(st.Time)
|
|
m.currentPlaybackStatus.DurationInSeconds = float64(st.Length)
|
|
|
|
return true
|
|
case "mpc-hc":
|
|
// Process MPC-HC status
|
|
st, ok := status.(*mpchc2.Variables)
|
|
if !ok || st == nil {
|
|
return false
|
|
}
|
|
|
|
m.currentPlaybackStatus.CompletionPercentage = st.Position / st.Duration
|
|
m.currentPlaybackStatus.Playing = st.State == 2
|
|
m.currentPlaybackStatus.Filename = st.File
|
|
m.currentPlaybackStatus.Duration = int(st.Duration)
|
|
m.currentPlaybackStatus.Filepath = st.FilePath
|
|
|
|
m.currentPlaybackStatus.CurrentTimeInSeconds = st.Position / 1000
|
|
m.currentPlaybackStatus.DurationInSeconds = st.Duration / 1000
|
|
|
|
return true
|
|
case "mpv":
|
|
// Process MPV status
|
|
st, ok := status.(*mpv.Playback)
|
|
if !ok || st == nil || st.Duration == 0 || st.IsRunning == false {
|
|
return false
|
|
}
|
|
|
|
m.currentPlaybackStatus.CompletionPercentage = st.Position / st.Duration
|
|
m.currentPlaybackStatus.Playing = !st.Paused
|
|
m.currentPlaybackStatus.Filename = st.Filename
|
|
m.currentPlaybackStatus.Duration = int(st.Duration)
|
|
m.currentPlaybackStatus.Filepath = st.Filepath
|
|
|
|
m.currentPlaybackStatus.CurrentTimeInSeconds = st.Position
|
|
m.currentPlaybackStatus.DurationInSeconds = st.Duration
|
|
|
|
return true
|
|
case "iina":
|
|
// Process IINA status
|
|
st, ok := status.(*iina.Playback)
|
|
if !ok || st == nil || st.Duration == 0 || st.IsRunning == false {
|
|
return false
|
|
}
|
|
|
|
m.currentPlaybackStatus.CompletionPercentage = st.Position / st.Duration
|
|
m.currentPlaybackStatus.Playing = !st.Paused
|
|
m.currentPlaybackStatus.Filename = st.Filename
|
|
m.currentPlaybackStatus.Duration = int(st.Duration)
|
|
m.currentPlaybackStatus.Filepath = st.Filepath
|
|
|
|
m.currentPlaybackStatus.CurrentTimeInSeconds = st.Position
|
|
m.currentPlaybackStatus.DurationInSeconds = st.Duration
|
|
|
|
return true
|
|
default:
|
|
return false
|
|
}
|
|
}
|