node build fixed
This commit is contained in:
588
seanime-2.9.10/internal/mediaplayers/iina/iina.go
Normal file
588
seanime-2.9.10/internal/mediaplayers/iina/iina.go
Normal file
@@ -0,0 +1,588 @@
|
||||
package iina
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"os/exec"
|
||||
"seanime/internal/mediaplayers/mpvipc"
|
||||
"seanime/internal/util"
|
||||
"seanime/internal/util/result"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/rs/zerolog"
|
||||
)
|
||||
|
||||
type (
|
||||
Playback struct {
|
||||
Filename string
|
||||
Paused bool
|
||||
Position float64
|
||||
Duration float64
|
||||
IsRunning bool
|
||||
Filepath string
|
||||
}
|
||||
|
||||
Iina struct {
|
||||
Logger *zerolog.Logger
|
||||
Playback *Playback
|
||||
SocketName string
|
||||
AppPath string
|
||||
Args string
|
||||
mu sync.Mutex
|
||||
playbackMu sync.RWMutex
|
||||
cancel context.CancelFunc // Cancel function for the context
|
||||
subscribers *result.Map[string, *Subscriber] // Subscribers to the iina events
|
||||
conn *mpvipc.Connection // Reference to the mpv connection (iina uses mpv IPC)
|
||||
cmd *exec.Cmd
|
||||
prevSocketName string
|
||||
exitedCh chan struct{}
|
||||
}
|
||||
|
||||
// Subscriber is a subscriber to the iina events.
|
||||
// Make sure the subscriber listens to both channels, otherwise it will deadlock.
|
||||
Subscriber struct {
|
||||
eventCh chan *mpvipc.Event
|
||||
closedCh chan struct{}
|
||||
}
|
||||
)
|
||||
|
||||
var cmdCtx, cmdCancel = context.WithCancel(context.Background())
|
||||
|
||||
func New(logger *zerolog.Logger, socketName string, appPath string, optionalArgs ...string) *Iina {
|
||||
if cmdCancel != nil {
|
||||
cmdCancel()
|
||||
}
|
||||
|
||||
sn := socketName
|
||||
if socketName == "" {
|
||||
sn = getDefaultSocketName()
|
||||
}
|
||||
|
||||
additionalArgs := ""
|
||||
if len(optionalArgs) > 0 {
|
||||
additionalArgs = optionalArgs[0]
|
||||
}
|
||||
|
||||
return &Iina{
|
||||
Logger: logger,
|
||||
Playback: &Playback{},
|
||||
mu: sync.Mutex{},
|
||||
playbackMu: sync.RWMutex{},
|
||||
SocketName: sn,
|
||||
AppPath: appPath,
|
||||
Args: additionalArgs,
|
||||
subscribers: result.NewResultMap[string, *Subscriber](),
|
||||
exitedCh: make(chan struct{}),
|
||||
}
|
||||
}
|
||||
|
||||
func (i *Iina) GetExecutablePath() string {
|
||||
if i.AppPath != "" {
|
||||
return i.AppPath
|
||||
}
|
||||
return "iina-cli"
|
||||
}
|
||||
|
||||
// launchPlayer starts the iina player and plays the file.
|
||||
// If the player is already running, it just loads the new file.
|
||||
func (i *Iina) launchPlayer(idle bool, filePath string, args ...string) error {
|
||||
var err error
|
||||
|
||||
i.Logger.Trace().Msgf("iina: Launching player with args: %+v", args)
|
||||
|
||||
// Cancel previous goroutine context
|
||||
if i.cancel != nil {
|
||||
i.Logger.Trace().Msg("iina: Cancelling previous context")
|
||||
i.cancel()
|
||||
}
|
||||
// Cancel previous command context
|
||||
if cmdCancel != nil {
|
||||
i.Logger.Trace().Msg("iina: Cancelling previous command context")
|
||||
cmdCancel()
|
||||
}
|
||||
cmdCtx, cmdCancel = context.WithCancel(context.Background())
|
||||
|
||||
i.Logger.Debug().Msg("iina: Starting player")
|
||||
|
||||
iinaArgs := []string{
|
||||
"--mpv-input-ipc-server=" + i.SocketName,
|
||||
"--no-stdin",
|
||||
}
|
||||
|
||||
if idle {
|
||||
iinaArgs = append(iinaArgs, "--mpv-idle")
|
||||
iinaArgs = append(iinaArgs, args...)
|
||||
i.cmd, err = i.createCmd("", iinaArgs...)
|
||||
} else {
|
||||
iinaArgs = append(iinaArgs, args...)
|
||||
i.cmd, err = i.createCmd(filePath, iinaArgs...)
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
i.prevSocketName = i.SocketName
|
||||
|
||||
err = i.cmd.Start()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
go func() {
|
||||
err := i.cmd.Wait()
|
||||
if err != nil {
|
||||
i.Logger.Warn().Err(err).Msg("iina: Player has exited")
|
||||
}
|
||||
}()
|
||||
|
||||
time.Sleep(2 * time.Second)
|
||||
|
||||
i.Logger.Debug().Msg("iina: Player started")
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (i *Iina) replaceFile(filePath string) error {
|
||||
i.Logger.Debug().Msg("iina: Replacing file")
|
||||
|
||||
if i.conn != nil && !i.conn.IsClosed() {
|
||||
_, err := i.conn.Call("loadfile", filePath, "replace")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (i *Iina) Exited() chan struct{} {
|
||||
return i.exitedCh
|
||||
}
|
||||
|
||||
func (i *Iina) OpenAndPlay(filePath string, args ...string) error {
|
||||
i.mu.Lock()
|
||||
defer i.mu.Unlock()
|
||||
|
||||
i.Playback = &Playback{}
|
||||
|
||||
// If the player is already running, just load the new file
|
||||
var err error
|
||||
if i.conn != nil && !i.conn.IsClosed() {
|
||||
// Launch player or replace file
|
||||
err = i.replaceFile(filePath)
|
||||
} else {
|
||||
// Launch player
|
||||
err = i.launchPlayer(false, filePath, args...)
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
var ctx context.Context
|
||||
ctx, i.cancel = context.WithCancel(context.Background())
|
||||
|
||||
// Establish new connection, only if it doesn't exist
|
||||
if i.conn != nil && !i.conn.IsClosed() {
|
||||
return nil
|
||||
}
|
||||
|
||||
err = i.establishConnection()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
i.Playback.IsRunning = false
|
||||
|
||||
// Listen for events in a goroutine
|
||||
go i.listenForEvents(ctx)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (i *Iina) Pause() error {
|
||||
i.mu.Lock()
|
||||
defer i.mu.Unlock()
|
||||
|
||||
if i.conn == nil || i.conn.IsClosed() {
|
||||
return errors.New("iina is not running")
|
||||
}
|
||||
|
||||
_, err := i.conn.Call("set_property", "pause", true)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (i *Iina) Resume() error {
|
||||
i.mu.Lock()
|
||||
defer i.mu.Unlock()
|
||||
|
||||
if i.conn == nil || i.conn.IsClosed() {
|
||||
return errors.New("iina is not running")
|
||||
}
|
||||
|
||||
_, err := i.conn.Call("set_property", "pause", false)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// SeekTo seeks to the given position in the file.
|
||||
func (i *Iina) SeekTo(position float64) error {
|
||||
i.mu.Lock()
|
||||
defer i.mu.Unlock()
|
||||
|
||||
if i.conn == nil || i.conn.IsClosed() {
|
||||
return errors.New("iina is not running")
|
||||
}
|
||||
|
||||
_, err := i.conn.Call("set_property", "time-pos", position)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// Seek seeks to the given position in the file.
|
||||
func (i *Iina) Seek(position float64) error {
|
||||
i.mu.Lock()
|
||||
defer i.mu.Unlock()
|
||||
|
||||
if i.conn == nil || i.conn.IsClosed() {
|
||||
return errors.New("iina is not running")
|
||||
}
|
||||
|
||||
_, err := i.conn.Call("set_property", "time-pos", position)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (i *Iina) GetOpenConnection() (*mpvipc.Connection, error) {
|
||||
if i.conn == nil || i.conn.IsClosed() {
|
||||
return nil, errors.New("iina is not running")
|
||||
}
|
||||
return i.conn, nil
|
||||
}
|
||||
|
||||
func (i *Iina) establishConnection() error {
|
||||
tries := 1
|
||||
for {
|
||||
i.conn = mpvipc.NewConnection(i.SocketName)
|
||||
err := i.conn.Open()
|
||||
if err != nil {
|
||||
if tries >= 3 {
|
||||
i.Logger.Error().Err(err).Msg("iina: Failed to establish connection")
|
||||
return err
|
||||
}
|
||||
i.Logger.Error().Err(err).Msgf("iina: Failed to establish connection (%d/8), retrying...", tries)
|
||||
tries++
|
||||
time.Sleep(1500 * time.Millisecond)
|
||||
continue
|
||||
}
|
||||
i.Logger.Debug().Msg("iina: Connection established")
|
||||
break
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (i *Iina) listenForEvents(ctx context.Context) {
|
||||
// Close the connection when the goroutine ends
|
||||
defer func() {
|
||||
i.Logger.Debug().Msg("iina: Closing socket connection")
|
||||
i.conn.Close()
|
||||
i.terminate()
|
||||
i.Logger.Debug().Msg("iina: Instance closed")
|
||||
}()
|
||||
|
||||
events, stopListening := i.conn.NewEventListener()
|
||||
i.Logger.Debug().Msg("iina: Listening for events")
|
||||
|
||||
_, err := i.conn.Get("path")
|
||||
if err != nil {
|
||||
i.Logger.Error().Err(err).Msg("iina: Failed to get path")
|
||||
return
|
||||
}
|
||||
|
||||
_, err = i.conn.Call("observe_property", 42, "time-pos")
|
||||
if err != nil {
|
||||
i.Logger.Error().Err(err).Msg("iina: Failed to observe time-pos")
|
||||
return
|
||||
}
|
||||
_, err = i.conn.Call("observe_property", 43, "pause")
|
||||
if err != nil {
|
||||
i.Logger.Error().Err(err).Msg("iina: Failed to observe pause")
|
||||
return
|
||||
}
|
||||
_, err = i.conn.Call("observe_property", 44, "duration")
|
||||
if err != nil {
|
||||
i.Logger.Error().Err(err).Msg("iina: Failed to observe duration")
|
||||
return
|
||||
}
|
||||
_, err = i.conn.Call("observe_property", 45, "filename")
|
||||
if err != nil {
|
||||
i.Logger.Error().Err(err).Msg("iina: Failed to observe filename")
|
||||
return
|
||||
}
|
||||
_, err = i.conn.Call("observe_property", 46, "path")
|
||||
if err != nil {
|
||||
i.Logger.Error().Err(err).Msg("iina: Failed to observe path")
|
||||
return
|
||||
}
|
||||
|
||||
// Listen for close event
|
||||
go func() {
|
||||
i.conn.WaitUntilClosed()
|
||||
i.Logger.Debug().Msg("iina: Connection has been closed")
|
||||
stopListening <- struct{}{}
|
||||
}()
|
||||
|
||||
go func() {
|
||||
// When the context is cancelled, close the connection
|
||||
<-ctx.Done()
|
||||
i.Logger.Debug().Msg("iina: Context cancelled")
|
||||
i.Playback.IsRunning = false
|
||||
err := i.conn.Close()
|
||||
if err != nil {
|
||||
i.Logger.Error().Err(err).Msg("iina: Failed to close connection")
|
||||
}
|
||||
stopListening <- struct{}{}
|
||||
return
|
||||
}()
|
||||
|
||||
// Listen for events
|
||||
for event := range events {
|
||||
if event.Data != nil {
|
||||
i.Playback.IsRunning = true
|
||||
switch event.ID {
|
||||
case 43:
|
||||
i.Playback.Paused = event.Data.(bool)
|
||||
case 42:
|
||||
i.Playback.Position = event.Data.(float64)
|
||||
case 44:
|
||||
i.Playback.Duration = event.Data.(float64)
|
||||
case 45:
|
||||
i.Playback.Filename = event.Data.(string)
|
||||
case 46:
|
||||
i.Playback.Filepath = event.Data.(string)
|
||||
}
|
||||
i.subscribers.Range(func(key string, sub *Subscriber) bool {
|
||||
go func() {
|
||||
sub.eventCh <- event
|
||||
}()
|
||||
return true
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (i *Iina) GetPlaybackStatus() (*Playback, error) {
|
||||
i.playbackMu.RLock()
|
||||
defer i.playbackMu.RUnlock()
|
||||
if !i.Playback.IsRunning {
|
||||
return nil, errors.New("iina is not running")
|
||||
}
|
||||
if i.Playback == nil {
|
||||
return nil, errors.New("no playback status")
|
||||
}
|
||||
if i.Playback.Filename == "" {
|
||||
return nil, errors.New("no media found")
|
||||
}
|
||||
if i.Playback.Duration == 0 {
|
||||
return nil, errors.New("no duration found")
|
||||
}
|
||||
return i.Playback, nil
|
||||
}
|
||||
|
||||
func (i *Iina) CloseAll() {
|
||||
i.Logger.Debug().Msg("iina: Received close request")
|
||||
if i.conn != nil && !i.conn.IsClosed() {
|
||||
// Send quit command to IINA before closing connection
|
||||
i.Logger.Debug().Msg("iina: Sending quit command")
|
||||
_, err := i.conn.Call("quit")
|
||||
if err != nil {
|
||||
i.Logger.Warn().Err(err).Msg("iina: Failed to send quit command")
|
||||
}
|
||||
time.Sleep(500 * time.Millisecond)
|
||||
|
||||
err = i.conn.Close()
|
||||
if err != nil {
|
||||
i.Logger.Error().Err(err).Msg("iina: Failed to close connection")
|
||||
}
|
||||
}
|
||||
i.terminate()
|
||||
}
|
||||
|
||||
func (i *Iina) terminate() {
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
i.Logger.Warn().Msgf("iina: Termination panic")
|
||||
}
|
||||
}()
|
||||
i.Logger.Trace().Msg("iina: Terminating")
|
||||
i.resetPlaybackStatus()
|
||||
i.publishDone()
|
||||
if i.cancel != nil {
|
||||
i.cancel()
|
||||
}
|
||||
if cmdCancel != nil {
|
||||
cmdCancel()
|
||||
}
|
||||
i.Logger.Trace().Msg("iina: Terminated")
|
||||
}
|
||||
|
||||
func (i *Iina) Subscribe(id string) *Subscriber {
|
||||
sub := &Subscriber{
|
||||
eventCh: make(chan *mpvipc.Event, 100),
|
||||
closedCh: make(chan struct{}),
|
||||
}
|
||||
i.subscribers.Set(id, sub)
|
||||
return sub
|
||||
}
|
||||
|
||||
func (i *Iina) Unsubscribe(id string) {
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
}
|
||||
}()
|
||||
sub, ok := i.subscribers.Get(id)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
close(sub.eventCh)
|
||||
close(sub.closedCh)
|
||||
i.subscribers.Delete(id)
|
||||
}
|
||||
|
||||
func (s *Subscriber) Events() <-chan *mpvipc.Event {
|
||||
return s.eventCh
|
||||
}
|
||||
|
||||
func (s *Subscriber) Closed() <-chan struct{} {
|
||||
return s.closedCh
|
||||
}
|
||||
|
||||
//////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
|
||||
|
||||
// parseArgs parses a command line string into individual arguments, respecting quotes
|
||||
func parseArgs(s string) ([]string, error) {
|
||||
args := make([]string, 0)
|
||||
var current strings.Builder
|
||||
var inQuotes bool
|
||||
var quoteChar rune
|
||||
|
||||
runes := []rune(s)
|
||||
for i := 0; i < len(runes); i++ {
|
||||
char := runes[i]
|
||||
switch {
|
||||
case char == '"' || char == '\'':
|
||||
if !inQuotes {
|
||||
inQuotes = true
|
||||
quoteChar = char
|
||||
} else if char == quoteChar {
|
||||
inQuotes = false
|
||||
quoteChar = 0
|
||||
// Add the current string even if it's empty (for empty quoted strings)
|
||||
args = append(args, current.String())
|
||||
current.Reset()
|
||||
} else {
|
||||
current.WriteRune(char)
|
||||
}
|
||||
case char == ' ' || char == '\t':
|
||||
if inQuotes {
|
||||
current.WriteRune(char)
|
||||
} else if current.Len() > 0 {
|
||||
args = append(args, current.String())
|
||||
current.Reset()
|
||||
}
|
||||
case char == '\\' && i+1 < len(runes):
|
||||
// Handle escaped characters
|
||||
if inQuotes && (runes[i+1] == '"' || runes[i+1] == '\'') {
|
||||
i++
|
||||
current.WriteRune(runes[i])
|
||||
} else {
|
||||
current.WriteRune(char)
|
||||
}
|
||||
default:
|
||||
current.WriteRune(char)
|
||||
}
|
||||
}
|
||||
|
||||
if inQuotes {
|
||||
return nil, errors.New("unclosed quote in arguments")
|
||||
}
|
||||
|
||||
if current.Len() > 0 {
|
||||
args = append(args, current.String())
|
||||
}
|
||||
|
||||
return args, nil
|
||||
}
|
||||
|
||||
func getDefaultSocketName() string {
|
||||
return "/tmp/iina_socket"
|
||||
}
|
||||
|
||||
// createCmd returns a new exec.Cmd instance for iina-cli.
|
||||
func (i *Iina) createCmd(filePath string, args ...string) (*exec.Cmd, error) {
|
||||
var cmd *exec.Cmd
|
||||
|
||||
// Add user-defined arguments
|
||||
if i.Args != "" {
|
||||
userArgs, err := parseArgs(i.Args)
|
||||
if err != nil {
|
||||
i.Logger.Warn().Err(err).Msg("iina: Failed to parse user arguments, using simple split")
|
||||
userArgs = strings.Fields(i.Args)
|
||||
}
|
||||
args = append(args, userArgs...)
|
||||
}
|
||||
|
||||
if filePath != "" {
|
||||
args = append(args, filePath)
|
||||
}
|
||||
|
||||
binaryPath := i.GetExecutablePath()
|
||||
|
||||
cmd = util.NewCmdCtx(cmdCtx, binaryPath, args...)
|
||||
|
||||
i.Logger.Trace().Msgf("iina: Command: %s", strings.Join(cmd.Args, " "))
|
||||
|
||||
return cmd, nil
|
||||
}
|
||||
|
||||
func (i *Iina) resetPlaybackStatus() {
|
||||
i.playbackMu.Lock()
|
||||
i.Logger.Trace().Msg("iina: Resetting playback status")
|
||||
i.Playback.Filename = ""
|
||||
i.Playback.Filepath = ""
|
||||
i.Playback.Paused = false
|
||||
i.Playback.Position = 0
|
||||
i.Playback.Duration = 0
|
||||
i.Playback.IsRunning = false
|
||||
i.playbackMu.Unlock()
|
||||
return
|
||||
}
|
||||
|
||||
func (i *Iina) publishDone() {
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
i.Logger.Warn().Msgf("iina: Connection already closed")
|
||||
}
|
||||
}()
|
||||
i.subscribers.Range(func(key string, sub *Subscriber) bool {
|
||||
go func() {
|
||||
sub.closedCh <- struct{}{}
|
||||
}()
|
||||
return true
|
||||
})
|
||||
}
|
||||
141
seanime-2.9.10/internal/mediaplayers/iina/iina_test.go
Normal file
141
seanime-2.9.10/internal/mediaplayers/iina/iina_test.go
Normal file
@@ -0,0 +1,141 @@
|
||||
package iina
|
||||
|
||||
import (
|
||||
"seanime/internal/test_utils"
|
||||
"seanime/internal/util"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
var testFilePath = "/Users/rahim/Documents/collection/Bocchi the Rock/[ASW] Bocchi the Rock! - 01 [1080p HEVC][EDC91675].mkv"
|
||||
var testFilePath2 = "/Users/rahim/Documents/collection/One Piece/[Erai-raws] One Piece - 1072 [1080p][Multiple Subtitle][51CB925F].mkv"
|
||||
|
||||
func TestIina_OpenPlayPauseSeekClose(t *testing.T) {
|
||||
test_utils.InitTestProvider(t, test_utils.MediaPlayer())
|
||||
|
||||
i := New(util.NewLogger(), "", "")
|
||||
|
||||
// Test Open and Play
|
||||
t.Log("Open and Play...")
|
||||
err := i.OpenAndPlay(testFilePath)
|
||||
if err != nil {
|
||||
t.Skipf("Skipping test: %v", err)
|
||||
}
|
||||
|
||||
// Subscribe to events
|
||||
sub := i.Subscribe("test")
|
||||
|
||||
time.Sleep(3 * time.Second)
|
||||
|
||||
t.Log("Get Playback Status...")
|
||||
status, err := i.GetPlaybackStatus()
|
||||
if err != nil {
|
||||
t.Logf("Warning: Could not get playback status: %v", err)
|
||||
} else {
|
||||
t.Logf("Playback Status: Duration=%.2f, Position=%.2f, Playing=%t, Filename=%s",
|
||||
status.Duration, status.Position, !status.Paused, status.Filename)
|
||||
assert.True(t, status.IsRunning, "Player should be running")
|
||||
assert.Greater(t, status.Duration, 0.0, "Duration should be greater than 0")
|
||||
}
|
||||
|
||||
t.Log("Pause...")
|
||||
err = i.Pause()
|
||||
if err != nil {
|
||||
t.Logf("Warning: Could not pause: %v", err)
|
||||
} else {
|
||||
time.Sleep(2 * time.Second)
|
||||
status, err := i.GetPlaybackStatus()
|
||||
if err == nil {
|
||||
t.Logf("After pause - Paused: %t", status.Paused)
|
||||
assert.True(t, status.Paused, "Player should be paused")
|
||||
}
|
||||
}
|
||||
|
||||
t.Log("Resume...")
|
||||
err = i.Resume()
|
||||
if err != nil {
|
||||
t.Logf("Warning: Could not resume: %v", err)
|
||||
} else {
|
||||
time.Sleep(2 * time.Second)
|
||||
status, err := i.GetPlaybackStatus()
|
||||
if err == nil {
|
||||
t.Logf("After resume - Paused: %t", status.Paused)
|
||||
assert.False(t, status.Paused, "Player should not be paused")
|
||||
}
|
||||
}
|
||||
|
||||
t.Log("Seek...")
|
||||
seekPosition := 30.0 // Seek to 30 seconds
|
||||
err = i.Seek(seekPosition)
|
||||
if err != nil {
|
||||
t.Logf("Warning: Could not seek: %v", err)
|
||||
} else {
|
||||
time.Sleep(2 * time.Second)
|
||||
status, err := i.GetPlaybackStatus()
|
||||
if err == nil {
|
||||
t.Logf("After seek - Position: %.2f", status.Position)
|
||||
assert.InDelta(t, seekPosition, status.Position, 5.0, "Position should be close to seek position")
|
||||
}
|
||||
}
|
||||
|
||||
t.Log("SeekTo...")
|
||||
seekToPosition := 60.0 // Seek to 60 seconds
|
||||
err = i.SeekTo(seekToPosition)
|
||||
if err != nil {
|
||||
t.Logf("Warning: Could not seek to position: %v", err)
|
||||
} else {
|
||||
time.Sleep(2 * time.Second)
|
||||
status, err := i.GetPlaybackStatus()
|
||||
if err == nil {
|
||||
t.Logf("After seekTo - Position: %.2f", status.Position)
|
||||
assert.InDelta(t, seekToPosition, status.Position, 5.0, "Position should be close to seekTo position")
|
||||
}
|
||||
}
|
||||
|
||||
// Test loading another file
|
||||
t.Log("Open another file...")
|
||||
err = i.OpenAndPlay(testFilePath2)
|
||||
if err != nil {
|
||||
t.Logf("Warning: Could not open another file: %v", err)
|
||||
} else {
|
||||
time.Sleep(2 * time.Second) // Wait for the new file to load
|
||||
status, err := i.GetPlaybackStatus()
|
||||
if err != nil {
|
||||
t.Logf("Warning: Could not get playback status after opening another file: %v", err)
|
||||
} else {
|
||||
t.Logf("New Playback Status: Duration=%.2f, Position=%.2f, Playing=%t, Filename=%s",
|
||||
status.Duration, status.Position, !status.Paused, status.Filename)
|
||||
assert.True(t, status.IsRunning, "Player should be running after opening another file")
|
||||
assert.Greater(t, status.Duration, 0.0, "Duration should be greater than 0 after opening another file")
|
||||
}
|
||||
}
|
||||
|
||||
// Test Close
|
||||
t.Log("Close...")
|
||||
go func() {
|
||||
time.Sleep(2 * time.Second)
|
||||
i.CloseAll()
|
||||
}()
|
||||
|
||||
// Wait for close event
|
||||
select {
|
||||
case <-sub.Closed():
|
||||
t.Log("IINA exited successfully")
|
||||
case <-time.After(10 * time.Second):
|
||||
t.Log("Timeout waiting for IINA to close")
|
||||
i.CloseAll() // Force close
|
||||
}
|
||||
|
||||
// Verify player is not running
|
||||
time.Sleep(1 * time.Second)
|
||||
status, err = i.GetPlaybackStatus()
|
||||
if err != nil {
|
||||
t.Log("Confirmed: Player is no longer running")
|
||||
} else if status != nil && !status.IsRunning {
|
||||
t.Log("Confirmed: Player status shows not running")
|
||||
}
|
||||
|
||||
t.Log("Test completed successfully")
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
package mediaplayer
|
||||
|
||||
import (
|
||||
"seanime/internal/hook_resolver"
|
||||
)
|
||||
|
||||
// MediaPlayerLocalFileTrackingRequestedEvent is triggered when the playback manager wants to track the progress of a local file.
|
||||
// Prevent default to stop tracking.
|
||||
type MediaPlayerLocalFileTrackingRequestedEvent struct {
|
||||
hook_resolver.Event
|
||||
// StartRefreshDelay is the number of seconds to wait before attempting to get the status
|
||||
StartRefreshDelay int `json:"startRefreshDelay"`
|
||||
// RefreshDelay is the number of seconds to wait before we refresh the status of the player after getting it for the first time
|
||||
RefreshDelay int `json:"refreshDelay"`
|
||||
// MaxRetries is the maximum number of retries
|
||||
MaxRetries int `json:"maxRetries"`
|
||||
}
|
||||
|
||||
// MediaPlayerStreamTrackingRequestedEvent is triggered when the playback manager wants to track the progress of a stream.
|
||||
// Prevent default to stop tracking.
|
||||
type MediaPlayerStreamTrackingRequestedEvent struct {
|
||||
hook_resolver.Event
|
||||
// StartRefreshDelay is the number of seconds to wait before attempting to get the status
|
||||
StartRefreshDelay int `json:"startRefreshDelay"`
|
||||
// RefreshDelay is the number of seconds to wait before we refresh the status of the player after getting it for the first time
|
||||
RefreshDelay int `json:"refreshDelay"`
|
||||
// MaxRetries is the maximum number of retries
|
||||
MaxRetries int `json:"maxRetries"`
|
||||
// MaxRetriesAfterStart is the maximum number of retries after the player has started
|
||||
MaxRetriesAfterStart int `json:"maxRetriesAfterStart"`
|
||||
}
|
||||
1035
seanime-2.9.10/internal/mediaplayers/mediaplayer/repository.go
Normal file
1035
seanime-2.9.10/internal/mediaplayers/mediaplayer/repository.go
Normal file
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,24 @@
|
||||
package mediaplayer
|
||||
|
||||
import (
|
||||
"github.com/stretchr/testify/assert"
|
||||
"seanime/internal/test_utils"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestRepository_StartTracking(t *testing.T) {
|
||||
test_utils.InitTestProvider(t, test_utils.MediaPlayer())
|
||||
|
||||
repo := NewTestRepository(t, "mpv")
|
||||
|
||||
err := repo.Play("E:\\ANIME\\Sousou no Frieren\\[SubsPlease] Sousou no Frieren - 01 (1080p) [F02B9CEE].mkv")
|
||||
assert.NoError(t, err)
|
||||
|
||||
repo.StartTracking()
|
||||
|
||||
go func() {
|
||||
time.Sleep(5 * time.Second)
|
||||
repo.Stop()
|
||||
}()
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
package mediaplayer
|
||||
|
||||
import (
|
||||
"seanime/internal/events"
|
||||
"seanime/internal/mediaplayers/mpchc"
|
||||
"seanime/internal/mediaplayers/mpv"
|
||||
"seanime/internal/mediaplayers/vlc"
|
||||
"seanime/internal/test_utils"
|
||||
"seanime/internal/util"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func NewTestRepository(t *testing.T, defaultPlayer string) *Repository {
|
||||
if defaultPlayer == "" {
|
||||
defaultPlayer = "mpv"
|
||||
}
|
||||
test_utils.InitTestProvider(t, test_utils.MediaPlayer())
|
||||
|
||||
logger := util.NewLogger()
|
||||
WSEventManager := events.NewMockWSEventManager(logger)
|
||||
|
||||
_vlc := &vlc.VLC{
|
||||
Host: test_utils.ConfigData.Provider.VlcHost,
|
||||
Port: test_utils.ConfigData.Provider.VlcPort,
|
||||
Password: test_utils.ConfigData.Provider.VlcPassword,
|
||||
Logger: logger,
|
||||
}
|
||||
|
||||
_mpc := &mpchc.MpcHc{
|
||||
Host: test_utils.ConfigData.Provider.MpcHost,
|
||||
Port: test_utils.ConfigData.Provider.MpcPort,
|
||||
Logger: logger,
|
||||
}
|
||||
|
||||
_mpv := mpv.New(logger, "", "")
|
||||
|
||||
repo := NewRepository(&NewRepositoryOptions{
|
||||
Logger: logger,
|
||||
Default: defaultPlayer,
|
||||
WSEventManager: WSEventManager,
|
||||
Mpv: _mpv,
|
||||
VLC: _vlc,
|
||||
MpcHc: _mpc,
|
||||
})
|
||||
|
||||
return repo
|
||||
}
|
||||
188
seanime-2.9.10/internal/mediaplayers/mpchc/commands.go
Normal file
188
seanime-2.9.10/internal/mediaplayers/mpchc/commands.go
Normal file
@@ -0,0 +1,188 @@
|
||||
package mpchc
|
||||
|
||||
const (
|
||||
setVolumeCmd = -2
|
||||
seekCmd = -1
|
||||
quickOpenFileCmd = 969
|
||||
openFileCmd = 800
|
||||
openDVDBDCmd = 801
|
||||
openDeviceCmd = 802
|
||||
reopenFileCmd = 976
|
||||
moveToRecycleBinCmd = 24044
|
||||
saveACopyCmd = 805
|
||||
saveImageCmd = 806
|
||||
saveImageAutoCmd = 807
|
||||
saveThumbnailsCmd = 808
|
||||
loadSubtitleCmd = 809
|
||||
saveSubtitleCmd = 810
|
||||
closeCmd = 804
|
||||
propertiesCmd = 814
|
||||
exitCmd = 816
|
||||
playPauseCmd = 889
|
||||
playCmd = 887
|
||||
pauseCmd = 888
|
||||
stopCmd = 890
|
||||
framestepCmd = 891
|
||||
framestepBackCmd = 892
|
||||
goToCmd = 893
|
||||
increaseRateCmd = 895
|
||||
decreaseRateCmd = 894
|
||||
resetRateCmd = 896
|
||||
audioDelayPlus10MsCmd = 905
|
||||
audioDelayMinus10MsCmd = 906
|
||||
jumpForwardSmallCmd = 900
|
||||
jumpBackwardSmallCmd = 899
|
||||
jumpForwardMediumCmd = 902
|
||||
jumpBackwardMediumCmd = 901
|
||||
jumpForwardLargeCmd = 904
|
||||
jumpBackwardLargeCmd = 903
|
||||
jumpForwardKeyframeCmd = 898
|
||||
jumpBackwardKeyframeCmd = 897
|
||||
jumpToBeginningCmd = 996
|
||||
nextCmd = 922
|
||||
previousCmd = 921
|
||||
nextFileCmd = 920
|
||||
previousFileCmd = 919
|
||||
tunerScanCmd = 974
|
||||
quickAddFavoriteCmd = 975
|
||||
toggleCaptionAndMenuCmd = 817
|
||||
toggleSeekerCmd = 818
|
||||
toggleControlsCmd = 819
|
||||
toggleInformationCmd = 820
|
||||
toggleStatisticsCmd = 821
|
||||
toggleStatusCmd = 822
|
||||
toggleSubresyncBarCmd = 823
|
||||
togglePlaylistBarCmd = 824
|
||||
toggleCaptureBarCmd = 825
|
||||
toggleNavigationBarCmd = 33415
|
||||
toggleDebugShadersCmd = 826
|
||||
viewMinimalCmd = 827
|
||||
viewCompactCmd = 828
|
||||
viewNormalCmd = 829
|
||||
fullscreenCmd = 830
|
||||
fullscreenWithoutResChangeCmd = 831
|
||||
zoom50Cmd = 832
|
||||
zoom100Cmd = 833
|
||||
zoom200Cmd = 834
|
||||
zoomAutoFitCmd = 968
|
||||
zoomAutoFitLargerOnlyCmd = 4900
|
||||
nextARPreseCmd = 859
|
||||
vidFrmHalfCmd = 835
|
||||
vidFrmNormalCmd = 836
|
||||
vidFrmDoubleCmd = 837
|
||||
vidFrmStretchCmd = 838
|
||||
vidFrmInsideCmd = 839
|
||||
vidFrmZoom1Cmd = 841
|
||||
vidFrmZoom2Cmd = 842
|
||||
vidFrmOutsideCmd = 840
|
||||
vidFrmSwitchZoomCmd = 843
|
||||
alwaysOnTopCmd = 884
|
||||
pnsResetCmd = 861
|
||||
pnsIncSizeCmd = 862
|
||||
pnsIncWidthCmd = 864
|
||||
pnsIncHeightCmd = 866
|
||||
pnsDecSizeCmd = 863
|
||||
pnsDecWidthCmd = 865
|
||||
pnsDecHeightCmd = 867
|
||||
pnsCenterCmd = 876
|
||||
pnsLeftCmd = 868
|
||||
pnsRightCmd = 869
|
||||
pnsUpCmd = 870
|
||||
pnsDownCmd = 871
|
||||
pnsUpLeftCmd = 872
|
||||
pnsUpRightCmd = 873
|
||||
pnsDownLeftCmd = 874
|
||||
pnsDownRightCmd = 875
|
||||
pnsRotateXPlusCmd = 877
|
||||
pnsRotateXMinusCmd = 878
|
||||
pnsRotateYPlusCmd = 879
|
||||
pnsRotateYMinusCmd = 880
|
||||
pnsRotateZPlusCmd = 881
|
||||
pnsRotateZMinusCmd = 882
|
||||
volumeUpCmd = 907
|
||||
volumeDownCmd = 908
|
||||
volumeMuteCmd = 909
|
||||
volumeBoostIncreaseCmd = 970
|
||||
volumeBoostDecreaseCmd = 971
|
||||
volumeBoostMinCmd = 972
|
||||
volumeBoostMaxCmd = 973
|
||||
toggleCustomChannelMappingCmd = 993
|
||||
toggleNormalizationCmd = 994
|
||||
toggleRegainVolumeCmd = 995
|
||||
brightnessIncreaseCmd = 984
|
||||
brightnessDecreaseCmd = 985
|
||||
contrastIncreaseCmd = 986
|
||||
contrastDecreaseCmd = 987
|
||||
hueIncreaseCmd = 988
|
||||
hueDecreaseCmd = 989
|
||||
saturationIncreaseCmd = 990
|
||||
saturationDecreaseCmd = 991
|
||||
resetColorSettingsCmd = 992
|
||||
dvdTitleMenuCmd = 923
|
||||
dvdRootMenuCmd = 924
|
||||
dvdSubtitleMenuCmd = 925
|
||||
dvdAudioMenuCmd = 926
|
||||
dvdAngleMenuCmd = 927
|
||||
dvdChapterMenuCmd = 928
|
||||
dvdMenuLeftCmd = 929
|
||||
dvdMenuRightCmd = 930
|
||||
dvdMenuUpCmd = 931
|
||||
dvdMenuDownCmd = 932
|
||||
dvdMenuActivateCmd = 933
|
||||
dvdMenuBackCmd = 934
|
||||
dvdMenuLeaveCmd = 935
|
||||
bossKeyCmd = 944
|
||||
playerMenuShortCmd = 949
|
||||
playerMenuLongCmd = 950
|
||||
filtersMenuCmd = 951
|
||||
optionsCmd = 815
|
||||
nextAudioCmd = 952
|
||||
prevAudioCmd = 953
|
||||
nextSubtitleCmd = 954
|
||||
prevSubtitleCmd = 955
|
||||
onOffSubtitleCmd = 956
|
||||
reloadSubtitlesCmd = 2302
|
||||
downloadSubtitlesCmd = 812
|
||||
nextAudioOGMCmd = 957
|
||||
prevAudioOGMCmd = 958
|
||||
nextSubtitleOGMCmd = 959
|
||||
prevSubtitleOGMCmd = 960
|
||||
nextAngleDVDCmd = 961
|
||||
prevAngleDVDCmd = 962
|
||||
nextAudioDVDCmd = 963
|
||||
prevAudioDVDCmd = 964
|
||||
nextSubtitleDVDCmd = 965
|
||||
prevSubtitleDVDCmd = 966
|
||||
onOffSubtitleDVDCmd = 967
|
||||
tearingTestCmd = 32769
|
||||
remainingTimeCmd = 32778
|
||||
nextShaderPresetCmd = 57382
|
||||
prevShaderPresetCmd = 57384
|
||||
toggleDirect3DFullscreenCmd = 32779
|
||||
gotoPrevSubtitleCmd = 32780
|
||||
gotoNextSubtitleCmd = 32781
|
||||
shiftSubtitleLeftCmd = 32782
|
||||
shiftSubtitleRightCmd = 32783
|
||||
displayStatsCmd = 32784
|
||||
resetDisplayStatsCmd = 33405
|
||||
vsyncCmd = 33243
|
||||
enableFrameTimeCorrectionCmd = 33265
|
||||
accurateVsyncCmd = 33260
|
||||
decreaseVsyncOffsetCmd = 33246
|
||||
increaseVsyncOffsetCmd = 33247
|
||||
subtitleDelayMinusCmd = 24000
|
||||
subtitleDelayPlusCmd = 24001
|
||||
afterPlaybackExitCmd = 912
|
||||
afterPlaybackStandByCmd = 913
|
||||
afterPlaybackHibernateCmd = 914
|
||||
afterPlaybackShutdownCmd = 915
|
||||
afterPlaybackLogOffCmd = 916
|
||||
afterPlaybackLockCmd = 917
|
||||
afterPlaybackTurnOffTheMonitorCmd = 918
|
||||
afterPlaybackPlayNextFileInTheFolderCmd = 947
|
||||
toggleEDLWindowCmd = 846
|
||||
edlSetInCmd = 847
|
||||
edlSetOutCmd = 848
|
||||
edlNewClipCmd = 849
|
||||
edlSaveCmd = 860
|
||||
)
|
||||
139
seanime-2.9.10/internal/mediaplayers/mpchc/mpc_hc.go
Normal file
139
seanime-2.9.10/internal/mediaplayers/mpchc/mpc_hc.go
Normal file
@@ -0,0 +1,139 @@
|
||||
package mpchc
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
neturl "net/url"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"github.com/rs/zerolog"
|
||||
)
|
||||
|
||||
type MpcHc struct {
|
||||
Host string
|
||||
Port int
|
||||
Path string
|
||||
Logger *zerolog.Logger
|
||||
}
|
||||
|
||||
func (api *MpcHc) url() string {
|
||||
return fmt.Sprintf("http://%s:%d", api.Host, api.Port)
|
||||
}
|
||||
|
||||
// Execute sends a command to MPC and returns the response.
|
||||
func (api *MpcHc) Execute(command int, data map[string]interface{}) (string, error) {
|
||||
url := fmt.Sprintf("%s/command.html?wm_command=%d", api.url(), command)
|
||||
|
||||
if data != nil {
|
||||
queryParams := neturl.Values{}
|
||||
for key, value := range data {
|
||||
queryParams.Add(key, fmt.Sprintf("%v", value))
|
||||
}
|
||||
url += "&" + queryParams.Encode()
|
||||
}
|
||||
|
||||
response, err := http.Get(url)
|
||||
if err != nil {
|
||||
api.Logger.Error().Err(err).Msg("mpc hc: Failed to execute command")
|
||||
return "", err
|
||||
}
|
||||
defer response.Body.Close()
|
||||
|
||||
// Check HTTP status code and errors
|
||||
statusCode := response.StatusCode
|
||||
if !((statusCode >= 200) && (statusCode <= 299)) {
|
||||
err = fmt.Errorf("http error code: %d\n", statusCode)
|
||||
return "", err
|
||||
}
|
||||
|
||||
// Get byte response and http status code
|
||||
byteArr, readErr := io.ReadAll(response.Body)
|
||||
if readErr != nil {
|
||||
err = fmt.Errorf("error reading response: %s\n", readErr)
|
||||
return "", err
|
||||
}
|
||||
|
||||
// Write response
|
||||
res := string(byteArr)
|
||||
|
||||
return res, nil
|
||||
}
|
||||
|
||||
func escapeInput(input string) string {
|
||||
if strings.HasPrefix(input, "http") {
|
||||
return neturl.QueryEscape(input)
|
||||
} else {
|
||||
input = filepath.FromSlash(input)
|
||||
return strings.ReplaceAll(neturl.QueryEscape(input), "+", "%20")
|
||||
}
|
||||
}
|
||||
|
||||
// OpenAndPlay opens a video file in MPC.
|
||||
func (api *MpcHc) OpenAndPlay(filePath string) (string, error) {
|
||||
url := fmt.Sprintf("%s/browser.html?path=%s", api.url(), escapeInput(filePath))
|
||||
api.Logger.Trace().Str("url", url).Msg("mpc hc: Opening and playing")
|
||||
|
||||
response, err := http.Get(url)
|
||||
if err != nil {
|
||||
api.Logger.Error().Err(err).Msg("mpc hc: Failed to connect to MPC")
|
||||
return "", err
|
||||
}
|
||||
defer response.Body.Close()
|
||||
|
||||
// Check HTTP status code and errors
|
||||
statusCode := response.StatusCode
|
||||
if !((statusCode >= 200) && (statusCode <= 299)) {
|
||||
err = fmt.Errorf("http error code: %d\n", statusCode)
|
||||
api.Logger.Error().Err(err).Msg("mpc hc: Failed to open and play")
|
||||
return "", err
|
||||
}
|
||||
|
||||
// Get byte response and http status code
|
||||
byteArr, readErr := io.ReadAll(response.Body)
|
||||
if readErr != nil {
|
||||
err = fmt.Errorf("error reading response: %s\n", readErr)
|
||||
api.Logger.Error().Err(err).Msg("mpc hc: Failed to open and play")
|
||||
return "", err
|
||||
}
|
||||
|
||||
// Write response
|
||||
res := string(byteArr)
|
||||
|
||||
return res, nil
|
||||
}
|
||||
|
||||
// GetVariables retrieves player variables from MPC.
|
||||
func (api *MpcHc) GetVariables() (*Variables, error) {
|
||||
url := fmt.Sprintf("%s/variables.html", api.url())
|
||||
|
||||
response, err := http.Get(url)
|
||||
if err != nil {
|
||||
api.Logger.Error().Err(err).Msg("mpc hc: Failed to get variables")
|
||||
return &Variables{}, err
|
||||
}
|
||||
defer response.Body.Close()
|
||||
|
||||
// Check HTTP status code and errors
|
||||
statusCode := response.StatusCode
|
||||
if !((statusCode >= 200) && (statusCode <= 299)) {
|
||||
err = fmt.Errorf("http error code: %d\n", statusCode)
|
||||
api.Logger.Error().Err(err).Msg("mpc hc: Failed to get variables")
|
||||
return &Variables{}, err
|
||||
}
|
||||
|
||||
// Get byte response and http status code
|
||||
byteArr, readErr := io.ReadAll(response.Body)
|
||||
if readErr != nil {
|
||||
err = fmt.Errorf("error reading response: %s\n", readErr)
|
||||
api.Logger.Error().Err(err).Msg("mpc hc: Failed to get variables")
|
||||
return &Variables{}, err
|
||||
}
|
||||
|
||||
// Write response
|
||||
res := string(byteArr)
|
||||
vars := parseVariables(res)
|
||||
|
||||
return vars, nil
|
||||
}
|
||||
101
seanime-2.9.10/internal/mediaplayers/mpchc/mpc_hc_test.go
Normal file
101
seanime-2.9.10/internal/mediaplayers/mpchc/mpc_hc_test.go
Normal file
@@ -0,0 +1,101 @@
|
||||
package mpchc
|
||||
|
||||
import (
|
||||
"github.com/davecgh/go-spew/spew"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"seanime/internal/test_utils"
|
||||
"seanime/internal/util"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestMpcHc_Start(t *testing.T) {
|
||||
test_utils.InitTestProvider(t, test_utils.MediaPlayer())
|
||||
|
||||
mpc := &MpcHc{
|
||||
Host: test_utils.ConfigData.Provider.MpcHost,
|
||||
Path: test_utils.ConfigData.Provider.MpcPath,
|
||||
Port: test_utils.ConfigData.Provider.MpcPort,
|
||||
Logger: util.NewLogger(),
|
||||
}
|
||||
|
||||
err := mpc.Start()
|
||||
assert.NoError(t, err)
|
||||
|
||||
}
|
||||
|
||||
func TestMpcHc_Play(t *testing.T) {
|
||||
test_utils.InitTestProvider(t, test_utils.MediaPlayer())
|
||||
|
||||
mpc := &MpcHc{
|
||||
Host: test_utils.ConfigData.Provider.MpcHost,
|
||||
Path: test_utils.ConfigData.Provider.MpcPath,
|
||||
Port: test_utils.ConfigData.Provider.MpcPort,
|
||||
Logger: util.NewLogger(),
|
||||
}
|
||||
|
||||
err := mpc.Start()
|
||||
assert.NoError(t, err)
|
||||
|
||||
res, err := mpc.OpenAndPlay("E:\\ANIME\\Violet.Evergarden.The.Movie.1080p.Dual.Audio.BDRip.10.bits.DD.x265-EMBER.mkv")
|
||||
assert.NoError(t, err)
|
||||
|
||||
t.Log(res)
|
||||
|
||||
}
|
||||
|
||||
func TestMpcHc_GetVariables(t *testing.T) {
|
||||
test_utils.InitTestProvider(t, test_utils.MediaPlayer())
|
||||
|
||||
mpc := &MpcHc{
|
||||
Host: test_utils.ConfigData.Provider.MpcHost,
|
||||
Path: test_utils.ConfigData.Provider.MpcPath,
|
||||
Port: test_utils.ConfigData.Provider.MpcPort,
|
||||
Logger: util.NewLogger(),
|
||||
}
|
||||
|
||||
err := mpc.Start()
|
||||
assert.NoError(t, err)
|
||||
|
||||
res, err := mpc.GetVariables()
|
||||
if err != nil {
|
||||
t.Fatal(err.Error())
|
||||
}
|
||||
|
||||
spew.Dump(res)
|
||||
|
||||
}
|
||||
|
||||
func TestMpcHc_Seek(t *testing.T) {
|
||||
test_utils.InitTestProvider(t, test_utils.MediaPlayer())
|
||||
|
||||
mpc := &MpcHc{
|
||||
Host: test_utils.ConfigData.Provider.MpcHost,
|
||||
Path: test_utils.ConfigData.Provider.MpcPath,
|
||||
Port: test_utils.ConfigData.Provider.MpcPort,
|
||||
Logger: util.NewLogger(),
|
||||
}
|
||||
|
||||
err := mpc.Start()
|
||||
assert.NoError(t, err)
|
||||
|
||||
_, err = mpc.OpenAndPlay("E:\\ANIME\\[SubsPlease] Bocchi the Rock! (01-12) (1080p) [Batch]\\[SubsPlease] Bocchi the Rock! - 01v2 (1080p) [ABDDAE16].mkv")
|
||||
assert.NoError(t, err)
|
||||
|
||||
err = mpc.Pause()
|
||||
|
||||
time.Sleep(400 * time.Millisecond)
|
||||
|
||||
err = mpc.Seek(100000)
|
||||
assert.NoError(t, err)
|
||||
|
||||
time.Sleep(400 * time.Millisecond)
|
||||
|
||||
err = mpc.Pause()
|
||||
|
||||
vars, err := mpc.GetVariables()
|
||||
assert.NoError(t, err)
|
||||
|
||||
spew.Dump(vars)
|
||||
|
||||
}
|
||||
57
seanime-2.9.10/internal/mediaplayers/mpchc/start.go
Normal file
57
seanime-2.9.10/internal/mediaplayers/mpchc/start.go
Normal file
@@ -0,0 +1,57 @@
|
||||
package mpchc
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"seanime/internal/util"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
func (api *MpcHc) getExecutableName() string {
|
||||
if len(api.Path) > 0 {
|
||||
if strings.Contains(api.Path, "64") {
|
||||
return "mpc-hc64.exe"
|
||||
} else {
|
||||
return strings.Replace(api.Path, "C:\\Program Files\\MPC-HC\\", "", 1)
|
||||
}
|
||||
}
|
||||
return "mpc-hc64.exe"
|
||||
}
|
||||
|
||||
func (api *MpcHc) GetExecutablePath() string {
|
||||
|
||||
if len(api.Path) > 0 {
|
||||
return api.Path
|
||||
}
|
||||
|
||||
return "C:\\Program Files\\MPC-HC\\mpc-hc64.exe"
|
||||
}
|
||||
|
||||
func (api *MpcHc) isRunning(executable string) bool {
|
||||
cmd := util.NewCmd("tasklist")
|
||||
output, err := cmd.Output()
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
|
||||
return strings.Contains(string(output), executable)
|
||||
}
|
||||
|
||||
func (api *MpcHc) Start() error {
|
||||
name := api.getExecutableName()
|
||||
exe := api.GetExecutablePath()
|
||||
if api.isRunning(name) {
|
||||
return nil
|
||||
}
|
||||
|
||||
cmd := util.NewCmd(exe)
|
||||
err := cmd.Start()
|
||||
if err != nil {
|
||||
api.Logger.Error().Err(err).Msg("mpc-hc: Error starting MPC-HC")
|
||||
return fmt.Errorf("error starting MPC-HC: %w", err)
|
||||
}
|
||||
|
||||
time.Sleep(1 * time.Second)
|
||||
|
||||
return nil
|
||||
}
|
||||
58
seanime-2.9.10/internal/mediaplayers/mpchc/status.go
Normal file
58
seanime-2.9.10/internal/mediaplayers/mpchc/status.go
Normal file
@@ -0,0 +1,58 @@
|
||||
package mpchc
|
||||
|
||||
import "strconv"
|
||||
|
||||
func (api *MpcHc) Play() (err error) {
|
||||
_, err = api.Execute(playCmd, nil)
|
||||
return
|
||||
}
|
||||
|
||||
func (api *MpcHc) Pause() (err error) {
|
||||
_, err = api.Execute(pauseCmd, nil)
|
||||
return
|
||||
}
|
||||
|
||||
func (api *MpcHc) TogglePlay() (err error) {
|
||||
_, err = api.Execute(playPauseCmd, nil)
|
||||
return
|
||||
}
|
||||
|
||||
func (api *MpcHc) Stop() (err error) {
|
||||
_, err = api.Execute(stopCmd, nil)
|
||||
return
|
||||
}
|
||||
|
||||
func (api *MpcHc) ToggleFullScreen() (err error) {
|
||||
_, err = api.Execute(fullscreenCmd, nil)
|
||||
return
|
||||
}
|
||||
|
||||
// Seek position in ms
|
||||
func (api *MpcHc) Seek(pos int) (err error) {
|
||||
_, err = api.Execute(seekCmd, map[string]interface{}{"position": millisecondsToDuration(pos)})
|
||||
return
|
||||
}
|
||||
|
||||
//----------------------------------------------------------------------------------------------------------------------
|
||||
|
||||
func millisecondsToDuration(ms int) string {
|
||||
if ms <= 0 {
|
||||
return "00:00:00"
|
||||
}
|
||||
|
||||
duration := ms / 1000
|
||||
hours := duration / 3600
|
||||
duration %= 3600
|
||||
|
||||
minutes := duration / 60
|
||||
duration %= 60
|
||||
|
||||
return padStart(strconv.Itoa(hours), 2, "0") + ":" + padStart(strconv.Itoa(minutes), 2, "0") + ":" + padStart(strconv.Itoa(duration), 2, "0")
|
||||
}
|
||||
|
||||
func padStart(s string, length int, pad string) string {
|
||||
for len(s) < length {
|
||||
s = pad + s
|
||||
}
|
||||
return s
|
||||
}
|
||||
68
seanime-2.9.10/internal/mediaplayers/mpchc/variables.go
Normal file
68
seanime-2.9.10/internal/mediaplayers/mpchc/variables.go
Normal file
@@ -0,0 +1,68 @@
|
||||
package mpchc
|
||||
|
||||
import (
|
||||
"github.com/PuerkitoBio/goquery"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
type Variables struct {
|
||||
Version string `json:"version"`
|
||||
File string `json:"file"`
|
||||
FilePath string `json:"filepath"`
|
||||
FileDir string `json:"filedir"`
|
||||
Size string `json:"size"`
|
||||
State int `json:"state"`
|
||||
StateString string `json:"statestring"`
|
||||
Position float64 `json:"position"`
|
||||
PositionString string `json:"positionstring"`
|
||||
Duration float64 `json:"duration"`
|
||||
DurationString string `json:"durationstring"`
|
||||
VolumeLevel float64 `json:"volumelevel"`
|
||||
Muted bool `json:"muted"`
|
||||
}
|
||||
|
||||
func parseVariables(variablePageHtml string) *Variables {
|
||||
doc, err := goquery.NewDocumentFromReader(strings.NewReader(variablePageHtml))
|
||||
if err != nil {
|
||||
// Handle error
|
||||
return &Variables{}
|
||||
}
|
||||
|
||||
fields := make(map[string]string)
|
||||
|
||||
doc.Find("p").Each(func(_ int, s *goquery.Selection) {
|
||||
id, exists := s.Attr("id")
|
||||
if !exists {
|
||||
return
|
||||
}
|
||||
text := s.Text()
|
||||
fields[id] = text
|
||||
})
|
||||
|
||||
return &Variables{
|
||||
Version: fields["version"],
|
||||
File: fields["file"],
|
||||
FilePath: fields["filepath"],
|
||||
FileDir: fields["filedir"],
|
||||
Size: fields["size"],
|
||||
State: parseInt(fields["state"]),
|
||||
StateString: fields["statestring"],
|
||||
Position: parseFloat(fields["position"]),
|
||||
PositionString: fields["positionstring"],
|
||||
Duration: parseFloat(fields["duration"]),
|
||||
DurationString: fields["durationstring"],
|
||||
VolumeLevel: parseFloat(fields["volumelevel"]),
|
||||
Muted: fields["muted"] != "0",
|
||||
}
|
||||
}
|
||||
|
||||
func parseInt(value string) int {
|
||||
intValue, _ := strconv.Atoi(value)
|
||||
return intValue
|
||||
}
|
||||
|
||||
func parseFloat(value string) float64 {
|
||||
floatValue, _ := strconv.ParseFloat(value, 64)
|
||||
return floatValue
|
||||
}
|
||||
657
seanime-2.9.10/internal/mediaplayers/mpv/mpv.go
Normal file
657
seanime-2.9.10/internal/mediaplayers/mpv/mpv.go
Normal file
@@ -0,0 +1,657 @@
|
||||
package mpv
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"bytes"
|
||||
"context"
|
||||
"errors"
|
||||
"os/exec"
|
||||
"runtime"
|
||||
"seanime/internal/mediaplayers/mpvipc"
|
||||
"seanime/internal/util"
|
||||
"seanime/internal/util/result"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/rs/zerolog"
|
||||
)
|
||||
|
||||
type (
|
||||
Playback struct {
|
||||
Filename string
|
||||
Paused bool
|
||||
Position float64
|
||||
Duration float64
|
||||
IsRunning bool
|
||||
Filepath string
|
||||
}
|
||||
|
||||
Mpv struct {
|
||||
Logger *zerolog.Logger
|
||||
Playback *Playback
|
||||
SocketName string
|
||||
AppPath string
|
||||
Args string
|
||||
mu sync.Mutex
|
||||
playbackMu sync.RWMutex
|
||||
cancel context.CancelFunc // Cancel function for the context
|
||||
subscribers *result.Map[string, *Subscriber] // Subscribers to the mpv events
|
||||
conn *mpvipc.Connection // Reference to the mpv connection
|
||||
cmd *exec.Cmd
|
||||
prevSocketName string
|
||||
exitedCh chan struct{}
|
||||
}
|
||||
|
||||
// Subscriber is a subscriber to the mpv events.
|
||||
// Make sure the subscriber listens to both channels, otherwise it will deadlock.
|
||||
Subscriber struct {
|
||||
eventCh chan *mpvipc.Event
|
||||
closedCh chan struct{}
|
||||
}
|
||||
)
|
||||
|
||||
var cmdCtx, cmdCancel = context.WithCancel(context.Background())
|
||||
|
||||
func New(logger *zerolog.Logger, socketName string, appPath string, optionalArgs ...string) *Mpv {
|
||||
if cmdCancel != nil {
|
||||
cmdCancel()
|
||||
}
|
||||
|
||||
sn := socketName
|
||||
if socketName == "" {
|
||||
sn = getDefaultSocketName()
|
||||
}
|
||||
|
||||
additionalArgs := ""
|
||||
if len(optionalArgs) > 0 {
|
||||
additionalArgs = optionalArgs[0]
|
||||
}
|
||||
|
||||
return &Mpv{
|
||||
Logger: logger,
|
||||
Playback: &Playback{},
|
||||
mu: sync.Mutex{},
|
||||
playbackMu: sync.RWMutex{},
|
||||
SocketName: sn,
|
||||
AppPath: appPath,
|
||||
Args: additionalArgs,
|
||||
subscribers: result.NewResultMap[string, *Subscriber](),
|
||||
exitedCh: make(chan struct{}),
|
||||
}
|
||||
}
|
||||
|
||||
func (m *Mpv) GetExecutablePath() string {
|
||||
if m.AppPath != "" {
|
||||
return m.AppPath
|
||||
}
|
||||
return "mpv"
|
||||
}
|
||||
|
||||
// launchPlayer starts the mpv player and plays the file.
|
||||
// If the player is already running, it just loads the new file.
|
||||
func (m *Mpv) launchPlayer(idle bool, filePath string, args ...string) error {
|
||||
var err error
|
||||
|
||||
m.Logger.Trace().Msgf("mpv: Launching player with args: %+v", args)
|
||||
|
||||
// Cancel previous goroutine context
|
||||
if m.cancel != nil {
|
||||
m.Logger.Trace().Msg("mpv: Cancelling previous context")
|
||||
m.cancel()
|
||||
}
|
||||
// Cancel previous command context
|
||||
if cmdCancel != nil {
|
||||
m.Logger.Trace().Msg("mpv: Cancelling previous command context")
|
||||
cmdCancel()
|
||||
}
|
||||
cmdCtx, cmdCancel = context.WithCancel(context.Background())
|
||||
|
||||
m.Logger.Debug().Msg("mpv: Starting player")
|
||||
if idle {
|
||||
args = append(args, "--input-ipc-server="+m.SocketName, "--idle")
|
||||
m.cmd, err = m.createCmd("", args...)
|
||||
} else {
|
||||
args = append(args, "--input-ipc-server="+m.SocketName)
|
||||
m.cmd, err = m.createCmd(filePath, args...)
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
m.prevSocketName = m.SocketName
|
||||
|
||||
// Create a pipe for stdout
|
||||
stdoutPipe, err := m.cmd.StdoutPipe()
|
||||
if err != nil {
|
||||
m.Logger.Error().Err(err).Msg("mpv: Failed to create stdout pipe")
|
||||
return err
|
||||
}
|
||||
|
||||
err = m.cmd.Start()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
wg := sync.WaitGroup{}
|
||||
wg.Add(1)
|
||||
|
||||
receivedLog := false
|
||||
|
||||
go func() {
|
||||
scanner := bufio.NewScanner(stdoutPipe)
|
||||
for scanner.Scan() {
|
||||
// Skip AV messages
|
||||
if bytes.Contains(scanner.Bytes(), []byte("AV:")) {
|
||||
continue
|
||||
}
|
||||
line := strings.TrimSpace(scanner.Text())
|
||||
if line != "" {
|
||||
if !receivedLog {
|
||||
receivedLog = true
|
||||
wg.Done()
|
||||
}
|
||||
m.Logger.Trace().Msg("mpv cmd: " + line) // Print to logger
|
||||
}
|
||||
}
|
||||
if err := scanner.Err(); err != nil {
|
||||
if strings.Contains(err.Error(), "file already closed") {
|
||||
m.Logger.Debug().Msg("mpv: File closed")
|
||||
//close(m.exitedCh)
|
||||
//m.exitedCh = make(chan struct{})
|
||||
} else {
|
||||
m.Logger.Error().Err(err).Msg("mpv: Error reading from stdout")
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
go func() {
|
||||
err := m.cmd.Wait()
|
||||
if err != nil {
|
||||
m.Logger.Warn().Err(err).Msg("mpv: Player has exited")
|
||||
}
|
||||
}()
|
||||
|
||||
wg.Wait()
|
||||
time.Sleep(1 * time.Second)
|
||||
|
||||
m.Logger.Debug().Msg("mpv: Player started")
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *Mpv) replaceFile(filePath string) error {
|
||||
m.Logger.Debug().Msg("mpv: Replacing file")
|
||||
|
||||
if m.conn != nil && !m.conn.IsClosed() {
|
||||
_, err := m.conn.Call("loadfile", filePath, "replace")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *Mpv) Exited() chan struct{} {
|
||||
return m.exitedCh
|
||||
}
|
||||
|
||||
func (m *Mpv) OpenAndPlay(filePath string, args ...string) error {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
|
||||
m.Playback = &Playback{}
|
||||
|
||||
// If the player is already running, just load the new file
|
||||
var err error
|
||||
if m.conn != nil && !m.conn.IsClosed() {
|
||||
// Launch player or replace file
|
||||
err = m.replaceFile(filePath)
|
||||
} else {
|
||||
// Launch player
|
||||
err = m.launchPlayer(false, filePath, args...)
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Create context for the connection
|
||||
// When the cancel method is called (by launchPlayer), the previous connection will be closed
|
||||
var ctx context.Context
|
||||
ctx, m.cancel = context.WithCancel(context.Background())
|
||||
|
||||
// Establish new connection, only if it doesn't exist
|
||||
// We don't continue past this point if the connection is already open, because it means the goroutine is already running
|
||||
if m.conn != nil && !m.conn.IsClosed() {
|
||||
return nil
|
||||
}
|
||||
|
||||
err = m.establishConnection()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// // Reset subscriber's done channel in case it was closed
|
||||
// m.subscribers.Range(func(key string, sub *Subscriber) bool {
|
||||
// sub.eventCh = make(chan *mpvipc.Event)
|
||||
// return true
|
||||
// })
|
||||
|
||||
m.Playback.IsRunning = false
|
||||
|
||||
// Listen for events in a goroutine
|
||||
go m.listenForEvents(ctx)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *Mpv) Pause() error {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
|
||||
if m.conn == nil || m.conn.IsClosed() {
|
||||
return errors.New("mpv is not running")
|
||||
}
|
||||
|
||||
_, err := m.conn.Call("set_property", "pause", true)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *Mpv) Resume() error {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
|
||||
if m.conn == nil || m.conn.IsClosed() {
|
||||
return errors.New("mpv is not running")
|
||||
}
|
||||
|
||||
_, err := m.conn.Call("set_property", "pause", false)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// SeekTo seeks to the given position in the file by first pausing the player and unpausing it after seeking.
|
||||
func (m *Mpv) SeekTo(position float64) error {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
|
||||
if m.conn == nil || m.conn.IsClosed() {
|
||||
return errors.New("mpv is not running")
|
||||
}
|
||||
|
||||
// pause the player
|
||||
_, err := m.conn.Call("set_property", "pause", true)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
time.Sleep(100 * time.Millisecond)
|
||||
|
||||
_, err = m.conn.Call("set_property", "time-pos", position)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
time.Sleep(100 * time.Millisecond)
|
||||
|
||||
// unpause the player
|
||||
_, err = m.conn.Call("set_property", "pause", false)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// Seek seeks to the given position in the file.
|
||||
func (m *Mpv) Seek(position float64) error {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
|
||||
if m.conn == nil || m.conn.IsClosed() {
|
||||
return errors.New("mpv is not running")
|
||||
}
|
||||
|
||||
_, err := m.conn.Call("set_property", "time-pos", position)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *Mpv) GetOpenConnection() (*mpvipc.Connection, error) {
|
||||
if m.conn == nil || m.conn.IsClosed() {
|
||||
return nil, errors.New("mpv is not running")
|
||||
}
|
||||
return m.conn, nil
|
||||
}
|
||||
|
||||
func (m *Mpv) establishConnection() error {
|
||||
tries := 1
|
||||
for {
|
||||
m.conn = mpvipc.NewConnection(m.SocketName)
|
||||
err := m.conn.Open()
|
||||
if err != nil {
|
||||
if tries >= 5 {
|
||||
m.Logger.Error().Err(err).Msg("mpv: Failed to establish connection")
|
||||
return err
|
||||
}
|
||||
m.Logger.Error().Err(err).Msgf("mpv: Failed to establish connection (%d/4), retrying...", tries)
|
||||
tries++
|
||||
time.Sleep(1 * time.Second)
|
||||
continue
|
||||
}
|
||||
m.Logger.Debug().Msg("mpv: Connection established")
|
||||
break
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *Mpv) listenForEvents(ctx context.Context) {
|
||||
// Close the connection when the goroutine ends
|
||||
defer func() {
|
||||
m.Logger.Debug().Msg("mpv: Closing socket connection")
|
||||
m.conn.Close()
|
||||
m.terminate()
|
||||
m.Logger.Debug().Msg("mpv: Instance closed")
|
||||
}()
|
||||
|
||||
events, stopListening := m.conn.NewEventListener()
|
||||
m.Logger.Debug().Msg("mpv: Listening for events")
|
||||
|
||||
_, err := m.conn.Get("path")
|
||||
if err != nil {
|
||||
m.Logger.Error().Err(err).Msg("mpv: Failed to get path")
|
||||
return
|
||||
}
|
||||
|
||||
_, err = m.conn.Call("observe_property", 42, "time-pos")
|
||||
if err != nil {
|
||||
m.Logger.Error().Err(err).Msg("mpv: Failed to observe time-pos")
|
||||
return
|
||||
}
|
||||
_, err = m.conn.Call("observe_property", 43, "pause")
|
||||
if err != nil {
|
||||
m.Logger.Error().Err(err).Msg("mpv: Failed to observe pause")
|
||||
return
|
||||
}
|
||||
_, err = m.conn.Call("observe_property", 44, "duration")
|
||||
if err != nil {
|
||||
m.Logger.Error().Err(err).Msg("mpv: Failed to observe duration")
|
||||
return
|
||||
}
|
||||
_, err = m.conn.Call("observe_property", 45, "filename")
|
||||
if err != nil {
|
||||
m.Logger.Error().Err(err).Msg("mpv: Failed to observe filename")
|
||||
return
|
||||
}
|
||||
_, err = m.conn.Call("observe_property", 46, "path")
|
||||
if err != nil {
|
||||
m.Logger.Error().Err(err).Msg("mpv: Failed to observe path")
|
||||
return
|
||||
}
|
||||
|
||||
// Listen for close event
|
||||
go func() {
|
||||
m.conn.WaitUntilClosed()
|
||||
m.Logger.Debug().Msg("mpv: Connection has been closed")
|
||||
stopListening <- struct{}{}
|
||||
}()
|
||||
|
||||
go func() {
|
||||
// When the context is cancelled, close the connection
|
||||
<-ctx.Done()
|
||||
m.Logger.Debug().Msg("mpv: Context cancelled")
|
||||
m.Playback.IsRunning = false
|
||||
err := m.conn.Close()
|
||||
if err != nil {
|
||||
m.Logger.Error().Err(err).Msg("mpv: Failed to close connection")
|
||||
}
|
||||
stopListening <- struct{}{}
|
||||
return
|
||||
}()
|
||||
|
||||
// Listen for events
|
||||
for event := range events {
|
||||
if event.Data != nil {
|
||||
m.Playback.IsRunning = true
|
||||
//m.Logger.Trace().Msgf("received event: %s, %v, %+v", event.Name, event.ID, event.Data)
|
||||
switch event.ID {
|
||||
case 43:
|
||||
m.Playback.Paused = event.Data.(bool)
|
||||
case 42:
|
||||
m.Playback.Position = event.Data.(float64)
|
||||
case 44:
|
||||
m.Playback.Duration = event.Data.(float64)
|
||||
case 45:
|
||||
m.Playback.Filename = event.Data.(string)
|
||||
case 46:
|
||||
m.Playback.Filepath = event.Data.(string)
|
||||
}
|
||||
m.subscribers.Range(func(key string, sub *Subscriber) bool {
|
||||
go func() {
|
||||
sub.eventCh <- event
|
||||
}()
|
||||
return true
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (m *Mpv) GetPlaybackStatus() (*Playback, error) {
|
||||
m.playbackMu.RLock()
|
||||
defer m.playbackMu.RUnlock()
|
||||
if !m.Playback.IsRunning {
|
||||
return nil, errors.New("mpv is not running")
|
||||
}
|
||||
if m.Playback == nil {
|
||||
return nil, errors.New("no playback status")
|
||||
}
|
||||
if m.Playback.Filename == "" {
|
||||
return nil, errors.New("no media found")
|
||||
}
|
||||
if m.Playback.Duration == 0 {
|
||||
return nil, errors.New("no duration found")
|
||||
}
|
||||
return m.Playback, nil
|
||||
}
|
||||
|
||||
func (m *Mpv) CloseAll() {
|
||||
m.Logger.Debug().Msg("mpv: Received close request")
|
||||
if m.conn != nil {
|
||||
err := m.conn.Close()
|
||||
if err != nil {
|
||||
m.Logger.Error().Err(err).Msg("mpv: Failed to close connection")
|
||||
}
|
||||
}
|
||||
m.terminate()
|
||||
}
|
||||
|
||||
func (m *Mpv) terminate() {
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
m.Logger.Warn().Msgf("mpv: Termination panic")
|
||||
}
|
||||
}()
|
||||
m.Logger.Trace().Msg("mpv: Terminating")
|
||||
m.resetPlaybackStatus()
|
||||
m.publishDone()
|
||||
if m.cancel != nil {
|
||||
m.cancel()
|
||||
}
|
||||
if cmdCancel != nil {
|
||||
cmdCancel()
|
||||
}
|
||||
m.Logger.Trace().Msg("mpv: Terminated")
|
||||
}
|
||||
|
||||
func (m *Mpv) Subscribe(id string) *Subscriber {
|
||||
sub := &Subscriber{
|
||||
eventCh: make(chan *mpvipc.Event, 100),
|
||||
closedCh: make(chan struct{}),
|
||||
}
|
||||
m.subscribers.Set(id, sub)
|
||||
return sub
|
||||
}
|
||||
|
||||
func (m *Mpv) Unsubscribe(id string) {
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
}
|
||||
}()
|
||||
sub, ok := m.subscribers.Get(id)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
close(sub.eventCh)
|
||||
close(sub.closedCh)
|
||||
m.subscribers.Delete(id)
|
||||
}
|
||||
|
||||
func (s *Subscriber) Events() <-chan *mpvipc.Event {
|
||||
return s.eventCh
|
||||
}
|
||||
|
||||
func (s *Subscriber) Closed() <-chan struct{} {
|
||||
return s.closedCh
|
||||
}
|
||||
|
||||
//////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
|
||||
|
||||
// parseArgs parses a command line string into individual arguments, respecting quotes
|
||||
func parseArgs(s string) ([]string, error) {
|
||||
args := make([]string, 0)
|
||||
var current strings.Builder
|
||||
var inQuotes bool
|
||||
var quoteChar rune
|
||||
|
||||
runes := []rune(s)
|
||||
for i := 0; i < len(runes); i++ {
|
||||
char := runes[i]
|
||||
switch {
|
||||
case char == '"' || char == '\'':
|
||||
if !inQuotes {
|
||||
inQuotes = true
|
||||
quoteChar = char
|
||||
} else if char == quoteChar {
|
||||
inQuotes = false
|
||||
quoteChar = 0
|
||||
// Add the current string even if it's empty (for empty quoted strings)
|
||||
args = append(args, current.String())
|
||||
current.Reset()
|
||||
} else {
|
||||
current.WriteRune(char)
|
||||
}
|
||||
case char == ' ' || char == '\t':
|
||||
if inQuotes {
|
||||
current.WriteRune(char)
|
||||
} else if current.Len() > 0 {
|
||||
args = append(args, current.String())
|
||||
current.Reset()
|
||||
}
|
||||
case char == '\\' && i+1 < len(runes):
|
||||
// Handle escaped characters
|
||||
if inQuotes && (runes[i+1] == '"' || runes[i+1] == '\'') {
|
||||
i++
|
||||
current.WriteRune(runes[i])
|
||||
} else {
|
||||
current.WriteRune(char)
|
||||
}
|
||||
default:
|
||||
current.WriteRune(char)
|
||||
}
|
||||
}
|
||||
|
||||
if inQuotes {
|
||||
return nil, errors.New("unclosed quote in arguments")
|
||||
}
|
||||
|
||||
if current.Len() > 0 {
|
||||
args = append(args, current.String())
|
||||
}
|
||||
|
||||
return args, nil
|
||||
}
|
||||
|
||||
// getDefaultSocketName returns the default name of the socket/pipe.
|
||||
func getDefaultSocketName() string {
|
||||
switch runtime.GOOS {
|
||||
case "windows":
|
||||
return "\\\\.\\pipe\\mpv_ipc"
|
||||
case "linux":
|
||||
return "/tmp/mpv_socket"
|
||||
case "darwin":
|
||||
return "/tmp/mpv_socket"
|
||||
default:
|
||||
return "/tmp/mpv_socket"
|
||||
}
|
||||
}
|
||||
|
||||
// createCmd returns a new exec.Cmd instance.
|
||||
func (m *Mpv) createCmd(filePath string, args ...string) (*exec.Cmd, error) {
|
||||
var cmd *exec.Cmd
|
||||
|
||||
// Add user-defined arguments
|
||||
if m.Args != "" {
|
||||
userArgs, err := parseArgs(m.Args)
|
||||
if err != nil {
|
||||
m.Logger.Warn().Err(err).Msg("mpv: Failed to parse user arguments, using simple split")
|
||||
userArgs = strings.Fields(m.Args)
|
||||
}
|
||||
args = append(args, userArgs...)
|
||||
}
|
||||
|
||||
if filePath != "" {
|
||||
// escapedFilePath := url.PathEscape(filePath)
|
||||
args = append(args, filePath)
|
||||
}
|
||||
|
||||
binaryPath := "mpv"
|
||||
switch m.AppPath {
|
||||
case "":
|
||||
default:
|
||||
binaryPath = m.AppPath
|
||||
}
|
||||
|
||||
cmd = util.NewCmdCtx(cmdCtx, binaryPath, args...)
|
||||
|
||||
m.Logger.Trace().Msgf("mpv: Command: %s", strings.Join(cmd.Args, " "))
|
||||
|
||||
return cmd, nil
|
||||
}
|
||||
|
||||
func (m *Mpv) resetPlaybackStatus() {
|
||||
m.playbackMu.Lock()
|
||||
m.Logger.Trace().Msg("mpv: Resetting playback status")
|
||||
m.Playback.Filename = ""
|
||||
m.Playback.Filepath = ""
|
||||
m.Playback.Paused = false
|
||||
m.Playback.Position = 0
|
||||
m.Playback.Duration = 0
|
||||
m.Playback.IsRunning = false
|
||||
m.playbackMu.Unlock()
|
||||
return
|
||||
}
|
||||
|
||||
func (m *Mpv) publishDone() {
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
m.Logger.Warn().Msgf("mpv: Connection already closed")
|
||||
}
|
||||
}()
|
||||
m.subscribers.Range(func(key string, sub *Subscriber) bool {
|
||||
go func() {
|
||||
sub.closedCh <- struct{}{}
|
||||
}()
|
||||
return true
|
||||
})
|
||||
}
|
||||
251
seanime-2.9.10/internal/mediaplayers/mpv/mpv_test.go
Normal file
251
seanime-2.9.10/internal/mediaplayers/mpv/mpv_test.go
Normal file
@@ -0,0 +1,251 @@
|
||||
package mpv
|
||||
|
||||
import (
|
||||
"seanime/internal/test_utils"
|
||||
"seanime/internal/util"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/davecgh/go-spew/spew"
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
var testFilePath = "E:\\ANIME\\[SubsPlease] Bocchi the Rock! (01-12) (1080p) [Batch]\\[SubsPlease] Bocchi the Rock! - 01v2 (1080p) [ABDDAE16].mkv"
|
||||
|
||||
func TestMpv_OpenAndPlay(t *testing.T) {
|
||||
test_utils.InitTestProvider(t, test_utils.MediaPlayer())
|
||||
|
||||
m := New(util.NewLogger(), "", "")
|
||||
|
||||
err := m.OpenAndPlay(testFilePath)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
sub := m.Subscribe("test")
|
||||
|
||||
go func() {
|
||||
time.Sleep(2 * time.Second)
|
||||
m.CloseAll()
|
||||
}()
|
||||
|
||||
select {
|
||||
case v, _ := <-sub.Closed():
|
||||
t.Logf("mpv exited, %+v", v)
|
||||
break
|
||||
}
|
||||
|
||||
t.Log("Done")
|
||||
|
||||
}
|
||||
|
||||
func TestMpv_OpenAndPlayPath(t *testing.T) {
|
||||
test_utils.InitTestProvider(t, test_utils.MediaPlayer())
|
||||
|
||||
m := New(util.NewLogger(), "", test_utils.ConfigData.Provider.MpvPath)
|
||||
|
||||
err := m.OpenAndPlay(testFilePath)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
sub := m.Subscribe("test")
|
||||
|
||||
select {
|
||||
case v, _ := <-sub.Closed():
|
||||
t.Logf("mpv exited, %+v", v)
|
||||
break
|
||||
}
|
||||
|
||||
t.Log("Done")
|
||||
|
||||
}
|
||||
|
||||
func TestMpv_Playback(t *testing.T) {
|
||||
test_utils.InitTestProvider(t, test_utils.MediaPlayer())
|
||||
|
||||
m := New(util.NewLogger(), "", "")
|
||||
|
||||
err := m.OpenAndPlay(testFilePath)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
sub := m.Subscribe("test")
|
||||
|
||||
loop:
|
||||
for {
|
||||
select {
|
||||
case v, _ := <-sub.Closed():
|
||||
t.Logf("mpv exited, %+v", v)
|
||||
break loop
|
||||
default:
|
||||
spew.Dump(m.GetPlaybackStatus())
|
||||
time.Sleep(2 * time.Second)
|
||||
}
|
||||
}
|
||||
|
||||
t.Log("Done")
|
||||
|
||||
}
|
||||
|
||||
func TestMpv_Multiple(t *testing.T) {
|
||||
test_utils.InitTestProvider(t, test_utils.MediaPlayer())
|
||||
|
||||
m := New(util.NewLogger(), "", "")
|
||||
|
||||
err := m.OpenAndPlay(testFilePath)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
time.Sleep(2 * time.Second)
|
||||
|
||||
err = m.OpenAndPlay(testFilePath)
|
||||
if !assert.NoError(t, err) {
|
||||
t.Log("error opening mpv instance twice")
|
||||
}
|
||||
|
||||
sub := m.Subscribe("test")
|
||||
|
||||
go func() {
|
||||
time.Sleep(2 * time.Second)
|
||||
m.CloseAll()
|
||||
}()
|
||||
|
||||
select {
|
||||
case v, _ := <-sub.Closed():
|
||||
t.Logf("mpv exited, %+v", v)
|
||||
break
|
||||
}
|
||||
|
||||
t.Log("Done")
|
||||
|
||||
}
|
||||
|
||||
// Test parseArgs function
|
||||
func TestParseArgs(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
input string
|
||||
expected []string
|
||||
hasError bool
|
||||
}{
|
||||
{
|
||||
name: "simple arguments",
|
||||
input: "--fullscreen --volume=50",
|
||||
expected: []string{"--fullscreen", "--volume=50"},
|
||||
hasError: false,
|
||||
},
|
||||
{
|
||||
name: "double quoted argument",
|
||||
input: "--title=\"My Movie Name\"",
|
||||
expected: []string{"--title=My Movie Name"},
|
||||
hasError: false,
|
||||
},
|
||||
{
|
||||
name: "single quoted argument",
|
||||
input: "--title='My Movie Name'",
|
||||
expected: []string{"--title=My Movie Name"},
|
||||
hasError: false,
|
||||
},
|
||||
{
|
||||
name: "space separated quoted argument",
|
||||
input: "--title \"My Movie Name\"",
|
||||
expected: []string{"--title", "My Movie Name"},
|
||||
hasError: false,
|
||||
},
|
||||
{
|
||||
name: "single space separated quoted argument",
|
||||
input: "--title 'My Movie Name'",
|
||||
expected: []string{"--title", "My Movie Name"},
|
||||
hasError: false,
|
||||
},
|
||||
{
|
||||
name: "mixed arguments",
|
||||
input: "--fullscreen --title \"My Movie\" --volume=50",
|
||||
expected: []string{"--fullscreen", "--title", "My Movie", "--volume=50"},
|
||||
hasError: false,
|
||||
},
|
||||
{
|
||||
name: "path with spaces",
|
||||
input: "--subtitle-file \"C:\\Program Files\\subtitles\\movie.srt\"",
|
||||
expected: []string{"--subtitle-file", "C:\\Program Files\\subtitles\\movie.srt"},
|
||||
hasError: false,
|
||||
},
|
||||
{
|
||||
name: "escaped quotes",
|
||||
input: "--title \"Movie with \\\"quotes\\\" in title\"",
|
||||
expected: []string{"--title", "Movie with \"quotes\" in title"},
|
||||
hasError: false,
|
||||
},
|
||||
{
|
||||
name: "empty string",
|
||||
input: "",
|
||||
expected: []string{},
|
||||
hasError: false,
|
||||
},
|
||||
{
|
||||
name: "only spaces",
|
||||
input: " ",
|
||||
expected: []string{},
|
||||
hasError: false,
|
||||
},
|
||||
{
|
||||
name: "tabs and spaces",
|
||||
input: "--fullscreen\t\t--volume=50 --loop",
|
||||
expected: []string{"--fullscreen", "--volume=50", "--loop"},
|
||||
hasError: false,
|
||||
},
|
||||
{
|
||||
name: "unclosed double quote",
|
||||
input: "--title \"My Movie",
|
||||
expected: nil,
|
||||
hasError: true,
|
||||
},
|
||||
{
|
||||
name: "unclosed single quote",
|
||||
input: "--title 'My Movie",
|
||||
expected: nil,
|
||||
hasError: true,
|
||||
},
|
||||
{
|
||||
name: "nested quotes",
|
||||
input: "--title \"Movie 'with' nested quotes\"",
|
||||
expected: []string{"--title", "Movie 'with' nested quotes"},
|
||||
hasError: false,
|
||||
},
|
||||
{
|
||||
name: "complex mixed case",
|
||||
input: "--fullscreen --title=\"Complex Movie\" --volume 75 --subtitle-file 'path/with spaces/sub.srt' --loop",
|
||||
expected: []string{"--fullscreen", "--title=Complex Movie", "--volume", "75", "--subtitle-file", "path/with spaces/sub.srt", "--loop"},
|
||||
hasError: false,
|
||||
},
|
||||
{
|
||||
name: "empty quoted string",
|
||||
input: "--title \"\"",
|
||||
expected: []string{"--title", ""},
|
||||
hasError: false,
|
||||
},
|
||||
{
|
||||
name: "multiple spaces between args",
|
||||
input: "--fullscreen --volume=50",
|
||||
expected: []string{"--fullscreen", "--volume=50"},
|
||||
hasError: false,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
result, err := parseArgs(tt.input)
|
||||
|
||||
if tt.hasError {
|
||||
assert.Error(t, err, "Expected error for input: %q", tt.input)
|
||||
assert.Nil(t, result, "Expected nil result when error occurs")
|
||||
} else {
|
||||
assert.NoError(t, err, "Unexpected error for input: %q", tt.input)
|
||||
assert.Equal(t, tt.expected, result, "Mismatch for input: %q", tt.input)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
324
seanime-2.9.10/internal/mediaplayers/mpvipc/mpvipc.go
Normal file
324
seanime-2.9.10/internal/mediaplayers/mpvipc/mpvipc.go
Normal file
@@ -0,0 +1,324 @@
|
||||
// Package mpvipc provides an interface for communicating with the mpv media
|
||||
// player via it's JSON IPC interface
|
||||
package mpvipc
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
var (
|
||||
ErrClientClosed = errors.New("client connection closed")
|
||||
)
|
||||
|
||||
// Connection represents a connection to a mpv IPC socket
|
||||
type Connection struct {
|
||||
client net.Conn
|
||||
socketName string
|
||||
|
||||
lastRequest uint
|
||||
waitingRequests map[uint]chan *commandResult
|
||||
|
||||
lastListener uint
|
||||
eventListeners map[uint]chan<- *Event
|
||||
|
||||
lastCloseWaiter uint
|
||||
closeWaiters map[uint]chan struct{}
|
||||
|
||||
lock *sync.Mutex
|
||||
}
|
||||
|
||||
// Event represents an event received from mpv. For a list of all possible
|
||||
// events, see https://mpv.io/manual/master/#list-of-events
|
||||
type Event struct {
|
||||
// Name is the only obligatory field: the name of the event
|
||||
Name string `json:"event"`
|
||||
|
||||
// Reason is the reason for the event: currently used for the "end-file"
|
||||
// event. When Name is "end-file", possible values of Reason are:
|
||||
// "eof", "stop", "quit", "error", "redirect", "unknown"
|
||||
Reason string `json:"reason"`
|
||||
|
||||
// Prefix is the log-message prefix (only if Name is "log-message")
|
||||
Prefix string `json:"prefix"`
|
||||
|
||||
// Level is the loglevel for a log-message (only if Name is "log-message")
|
||||
Level string `json:"level"`
|
||||
|
||||
// Text is the text of a log-message (only if Name is "log-message")
|
||||
Text string `json:"text"`
|
||||
|
||||
// ID is the user-set property ID (on events triggered by observed properties)
|
||||
ID uint `json:"id"`
|
||||
|
||||
// Data is the property value (on events triggered by observed properties)
|
||||
Data interface{} `json:"data"`
|
||||
}
|
||||
|
||||
// NewConnection returns a Connection associated with the given unix socket
|
||||
func NewConnection(socketName string) *Connection {
|
||||
return &Connection{
|
||||
socketName: socketName,
|
||||
lock: &sync.Mutex{},
|
||||
waitingRequests: make(map[uint]chan *commandResult),
|
||||
eventListeners: make(map[uint]chan<- *Event),
|
||||
closeWaiters: make(map[uint]chan struct{}),
|
||||
}
|
||||
}
|
||||
|
||||
// Open connects to the socket. Returns an error if already connected.
|
||||
// It also starts listening to events, so ListenForEvents() can be called
|
||||
// afterwards.
|
||||
func (c *Connection) Open() error {
|
||||
c.lock.Lock()
|
||||
defer c.lock.Unlock()
|
||||
|
||||
if c.client != nil {
|
||||
return fmt.Errorf("already open")
|
||||
}
|
||||
|
||||
client, err := dial(c.socketName)
|
||||
if err != nil {
|
||||
return fmt.Errorf("can't connect to mpv's socket: %s", err)
|
||||
}
|
||||
c.client = client
|
||||
go c.listen()
|
||||
return nil
|
||||
}
|
||||
|
||||
// ListenForEvents blocks until something is received on the stop channel (or
|
||||
// it's closed).
|
||||
// In the meantime, events received on the socket will be sent on the events
|
||||
// channel. They may not appear in the same order they happened in.
|
||||
//
|
||||
// The events channel is closed automatically just before this method returns.
|
||||
func (c *Connection) ListenForEvents(events chan<- *Event, stop <-chan struct{}) {
|
||||
c.lock.Lock()
|
||||
c.lastListener++
|
||||
id := c.lastListener
|
||||
c.eventListeners[id] = events
|
||||
c.lock.Unlock()
|
||||
|
||||
<-stop
|
||||
|
||||
c.lock.Lock()
|
||||
delete(c.eventListeners, id)
|
||||
close(events)
|
||||
c.lock.Unlock()
|
||||
}
|
||||
|
||||
// NewEventListener is a convenience wrapper around ListenForEvents(). It
|
||||
// creates and returns the event channel and the stop channel. After calling
|
||||
// NewEventListener, read events from the events channel and send an empty
|
||||
// struct to the stop channel to close it.
|
||||
func (c *Connection) NewEventListener() (chan *Event, chan struct{}) {
|
||||
events := make(chan *Event, 16)
|
||||
stop := make(chan struct{})
|
||||
go c.ListenForEvents(events, stop)
|
||||
return events, stop
|
||||
}
|
||||
|
||||
// Call calls an arbitrary command and returns its result. For a list of
|
||||
// possible functions, see https://mpv.io/manual/master/#commands and
|
||||
// https://mpv.io/manual/master/#list-of-input-commands
|
||||
func (c *Connection) Call(arguments ...interface{}) (interface{}, error) {
|
||||
c.lock.Lock()
|
||||
c.lastRequest++
|
||||
id := c.lastRequest
|
||||
resultChannel := make(chan *commandResult, 1)
|
||||
c.waitingRequests[id] = resultChannel
|
||||
c.lock.Unlock()
|
||||
|
||||
defer func() {
|
||||
c.lock.Lock()
|
||||
delete(c.waitingRequests, id)
|
||||
c.lock.Unlock()
|
||||
}()
|
||||
|
||||
err := c.sendCommand(id, arguments...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var deadline <-chan time.Time
|
||||
timer := time.NewTimer(time.Second * 5)
|
||||
defer timer.Stop()
|
||||
deadline = timer.C
|
||||
|
||||
select {
|
||||
case result := <-resultChannel:
|
||||
if result.Status == "success" {
|
||||
return result.Data, nil
|
||||
}
|
||||
return nil, fmt.Errorf("mpv error: %s", result.Status)
|
||||
case <-deadline:
|
||||
return nil, errors.New("timeout")
|
||||
}
|
||||
}
|
||||
|
||||
// Set is a shortcut to Call("set_property", property, value)
|
||||
func (c *Connection) Set(property string, value interface{}) error {
|
||||
_, err := c.Call("set_property", property, value)
|
||||
return err
|
||||
}
|
||||
|
||||
// Get is a shortcut to Call("get_property", property)
|
||||
func (c *Connection) Get(property string) (interface{}, error) {
|
||||
value, err := c.Call("get_property", property)
|
||||
return value, err
|
||||
}
|
||||
|
||||
// Close closes the socket, disconnecting from mpv. It is safe to call Close()
|
||||
// on an already closed connection.
|
||||
func (c *Connection) Close() error {
|
||||
c.lock.Lock()
|
||||
defer c.lock.Unlock()
|
||||
|
||||
if c.client != nil {
|
||||
err := c.client.Close()
|
||||
for waiterID := range c.closeWaiters {
|
||||
close(c.closeWaiters[waiterID])
|
||||
}
|
||||
c.client = nil
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// IsClosed returns true if the connection is closed. There are several cases
|
||||
// in which a connection is closed:
|
||||
//
|
||||
// 1. Close() has been called
|
||||
//
|
||||
// 2. The connection has been initialised but Open() hasn't been called yet
|
||||
//
|
||||
// 3. The connection terminated because of an error, mpv exiting or crashing
|
||||
//
|
||||
// It's ok to use IsClosed() to check if you need to reopen the connection
|
||||
// before calling a command.
|
||||
func (c *Connection) IsClosed() bool {
|
||||
c.lock.Lock()
|
||||
defer c.lock.Unlock()
|
||||
return c.client == nil
|
||||
}
|
||||
|
||||
// WaitUntilClosed blocks until the connection becomes closed. See IsClosed()
|
||||
// for an explanation of the closed state.
|
||||
func (c *Connection) WaitUntilClosed() {
|
||||
c.lock.Lock()
|
||||
if c.client == nil {
|
||||
c.lock.Unlock()
|
||||
return
|
||||
}
|
||||
|
||||
closed := make(chan struct{})
|
||||
c.lastCloseWaiter++
|
||||
waiterID := c.lastCloseWaiter
|
||||
c.closeWaiters[waiterID] = closed
|
||||
|
||||
c.lock.Unlock()
|
||||
|
||||
<-closed
|
||||
|
||||
c.lock.Lock()
|
||||
delete(c.closeWaiters, waiterID)
|
||||
c.lock.Unlock()
|
||||
}
|
||||
|
||||
func (c *Connection) sendCommand(id uint, arguments ...interface{}) error {
|
||||
var client net.Conn
|
||||
c.lock.Lock()
|
||||
client = c.client
|
||||
c.lock.Unlock()
|
||||
if client == nil {
|
||||
return ErrClientClosed
|
||||
}
|
||||
|
||||
message := &commandRequest{
|
||||
Arguments: arguments,
|
||||
ID: id,
|
||||
}
|
||||
data, err := json.Marshal(&message)
|
||||
if err != nil {
|
||||
return fmt.Errorf("can't encode command: %s", err)
|
||||
}
|
||||
_, err = c.client.Write(data)
|
||||
if err != nil {
|
||||
return fmt.Errorf("can't write command: %s", err)
|
||||
}
|
||||
_, err = c.client.Write([]byte("\n"))
|
||||
if err != nil {
|
||||
return fmt.Errorf("can't terminate command: %s", err)
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
type commandRequest struct {
|
||||
Arguments []interface{} `json:"command"`
|
||||
ID uint `json:"request_id"`
|
||||
}
|
||||
|
||||
type commandResult struct {
|
||||
Status string `json:"error"`
|
||||
Data interface{} `json:"data"`
|
||||
ID uint `json:"request_id"`
|
||||
}
|
||||
|
||||
func (c *Connection) checkResult(data []byte) {
|
||||
result := &commandResult{}
|
||||
err := json.Unmarshal(data, &result)
|
||||
if err != nil {
|
||||
return // skip malformed data
|
||||
}
|
||||
if result.Status == "" {
|
||||
return // not a result
|
||||
}
|
||||
c.lock.Lock()
|
||||
// not ok means the request is deleted
|
||||
request, ok := c.waitingRequests[result.ID]
|
||||
c.lock.Unlock()
|
||||
if ok {
|
||||
request <- result
|
||||
}
|
||||
}
|
||||
|
||||
func (c *Connection) checkEvent(data []byte) {
|
||||
event := &Event{}
|
||||
err := json.Unmarshal(data, &event)
|
||||
if err != nil {
|
||||
return // skip malformed data
|
||||
}
|
||||
if event.Name == "" {
|
||||
return // not an event
|
||||
}
|
||||
eventsCh := make([]chan<- *Event, 0, 8)
|
||||
c.lock.Lock()
|
||||
for listenerID := range c.eventListeners {
|
||||
listener := c.eventListeners[listenerID]
|
||||
eventsCh = append(eventsCh, listener)
|
||||
}
|
||||
c.lock.Unlock()
|
||||
|
||||
for _, eventCh := range eventsCh {
|
||||
select {
|
||||
case eventCh <- event:
|
||||
default:
|
||||
// ignore the recent
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (c *Connection) listen() {
|
||||
scanner := bufio.NewScanner(c.client)
|
||||
for scanner.Scan() {
|
||||
data := scanner.Bytes()
|
||||
c.checkEvent(data)
|
||||
c.checkResult(data)
|
||||
}
|
||||
_ = c.Close()
|
||||
}
|
||||
189
seanime-2.9.10/internal/mediaplayers/mpvipc/mpvipc_test.go
Normal file
189
seanime-2.9.10/internal/mediaplayers/mpvipc/mpvipc_test.go
Normal file
@@ -0,0 +1,189 @@
|
||||
package mpvipc
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"time"
|
||||
)
|
||||
|
||||
func ExampleConnection_Call() {
|
||||
conn := NewConnection("/tmp/mpv_socket")
|
||||
err := conn.Open()
|
||||
if err != nil {
|
||||
fmt.Print(err)
|
||||
return
|
||||
}
|
||||
defer func() {
|
||||
_ = conn.Close()
|
||||
}()
|
||||
|
||||
// toggle play/pause
|
||||
_, err = conn.Call("cycle", "pause")
|
||||
if err != nil {
|
||||
fmt.Print(err)
|
||||
}
|
||||
|
||||
// increase volume by 5
|
||||
_, err = conn.Call("add", "volume", 5)
|
||||
if err != nil {
|
||||
fmt.Print(err)
|
||||
}
|
||||
|
||||
// decrease volume by 3, showing an osd message and progress bar
|
||||
_, err = conn.Call("osd-msg-bar", "add", "volume", -3)
|
||||
if err != nil {
|
||||
fmt.Print(err)
|
||||
}
|
||||
|
||||
// get mpv's version
|
||||
version, err := conn.Call("get_version")
|
||||
if err != nil {
|
||||
fmt.Print(err)
|
||||
}
|
||||
fmt.Printf("version: %f\n", version.(float64))
|
||||
}
|
||||
|
||||
func ExampleConnection_Set() {
|
||||
conn := NewConnection("/tmp/mpv_socket")
|
||||
err := conn.Open()
|
||||
if err != nil {
|
||||
fmt.Print(err)
|
||||
return
|
||||
}
|
||||
defer func() {
|
||||
_ = conn.Close()
|
||||
}()
|
||||
|
||||
// pause playback
|
||||
err = conn.Set("pause", true)
|
||||
if err != nil {
|
||||
fmt.Print(err)
|
||||
}
|
||||
|
||||
// seek to the middle of file
|
||||
err = conn.Set("percent-pos", 50)
|
||||
if err != nil {
|
||||
fmt.Print(err)
|
||||
}
|
||||
}
|
||||
|
||||
func ExampleConnection_Get() {
|
||||
conn := NewConnection("/tmp/mpv_socket")
|
||||
err := conn.Open()
|
||||
if err != nil {
|
||||
fmt.Print(err)
|
||||
return
|
||||
}
|
||||
defer func() {
|
||||
_ = conn.Close()
|
||||
}()
|
||||
|
||||
// see if we're paused
|
||||
paused, err := conn.Get("pause")
|
||||
if err != nil {
|
||||
fmt.Print(err)
|
||||
} else if paused.(bool) {
|
||||
fmt.Printf("we're paused!\n")
|
||||
} else {
|
||||
fmt.Printf("we're not paused.\n")
|
||||
}
|
||||
|
||||
// see the current position in the file
|
||||
elapsed, err := conn.Get("time-pos")
|
||||
if err != nil {
|
||||
fmt.Print(err)
|
||||
} else {
|
||||
fmt.Printf("seconds from start of video: %f\n", elapsed.(float64))
|
||||
}
|
||||
}
|
||||
|
||||
func ExampleConnection_ListenForEvents() {
|
||||
conn := NewConnection("/tmp/mpv_socket")
|
||||
err := conn.Open()
|
||||
if err != nil {
|
||||
fmt.Print(err)
|
||||
return
|
||||
}
|
||||
defer func() {
|
||||
_ = conn.Close()
|
||||
}()
|
||||
|
||||
_, err = conn.Call("observe_property", 42, "volume")
|
||||
if err != nil {
|
||||
fmt.Print(err)
|
||||
}
|
||||
|
||||
events := make(chan *Event)
|
||||
stop := make(chan struct{})
|
||||
go conn.ListenForEvents(events, stop)
|
||||
|
||||
// print all incoming events for 5 seconds, then exit
|
||||
go func() {
|
||||
time.Sleep(time.Second * 5)
|
||||
stop <- struct{}{}
|
||||
}()
|
||||
|
||||
for event := range events {
|
||||
if event.ID == 42 {
|
||||
fmt.Printf("volume now is %f\n", event.Data.(float64))
|
||||
} else {
|
||||
fmt.Printf("received event: %s\n", event.Name)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func ExampleConnection_NewEventListener() {
|
||||
conn := NewConnection("/tmp/mpv_socket")
|
||||
err := conn.Open()
|
||||
if err != nil {
|
||||
fmt.Print(err)
|
||||
return
|
||||
}
|
||||
defer func() {
|
||||
_ = conn.Close()
|
||||
}()
|
||||
|
||||
_, err = conn.Call("observe_property", 42, "volume")
|
||||
if err != nil {
|
||||
fmt.Print(err)
|
||||
}
|
||||
|
||||
events, stop := conn.NewEventListener()
|
||||
|
||||
// print all incoming events for 5 seconds, then exit
|
||||
go func() {
|
||||
time.Sleep(time.Second * 5)
|
||||
stop <- struct{}{}
|
||||
}()
|
||||
|
||||
for event := range events {
|
||||
if event.ID == 42 {
|
||||
fmt.Printf("volume now is %f\n", event.Data.(float64))
|
||||
} else {
|
||||
fmt.Printf("received event: %s\n", event.Name)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func ExampleConnection_WaitUntilClosed() {
|
||||
conn := NewConnection("/tmp/mpv_socket")
|
||||
err := conn.Open()
|
||||
if err != nil {
|
||||
fmt.Print(err)
|
||||
return
|
||||
}
|
||||
defer func() {
|
||||
_ = conn.Close()
|
||||
}()
|
||||
|
||||
events, stop := conn.NewEventListener()
|
||||
|
||||
// print events until mpv exits, then exit
|
||||
go func() {
|
||||
conn.WaitUntilClosed()
|
||||
stop <- struct{}{}
|
||||
}()
|
||||
|
||||
for event := range events {
|
||||
fmt.Printf("received event: %s\n", event.Name)
|
||||
}
|
||||
}
|
||||
10
seanime-2.9.10/internal/mediaplayers/mpvipc/pipe.go
Normal file
10
seanime-2.9.10/internal/mediaplayers/mpvipc/pipe.go
Normal file
@@ -0,0 +1,10 @@
|
||||
//go:build !windows
|
||||
// +build !windows
|
||||
|
||||
package mpvipc
|
||||
|
||||
import "net"
|
||||
|
||||
func dial(path string) (net.Conn, error) {
|
||||
return net.Dial("unix", path)
|
||||
}
|
||||
16
seanime-2.9.10/internal/mediaplayers/mpvipc/pipe_windows.go
Normal file
16
seanime-2.9.10/internal/mediaplayers/mpvipc/pipe_windows.go
Normal file
@@ -0,0 +1,16 @@
|
||||
//go:build windows
|
||||
// +build windows
|
||||
|
||||
package mpvipc
|
||||
|
||||
import (
|
||||
"net"
|
||||
"time"
|
||||
|
||||
winio "github.com/Microsoft/go-winio"
|
||||
)
|
||||
|
||||
func dial(path string) (net.Conn, error) {
|
||||
timeout := time.Second * 10
|
||||
return winio.DialPipe(path, &timeout)
|
||||
}
|
||||
1
seanime-2.9.10/internal/mediaplayers/vlc/README.md
Normal file
1
seanime-2.9.10/internal/mediaplayers/vlc/README.md
Normal file
@@ -0,0 +1 @@
|
||||
Source code from [CedArtic/go-vlc-ctrl](https://github.com/CedArctic/go-vlc-ctrl), updated and modified for the purpose of this project.
|
||||
41
seanime-2.9.10/internal/mediaplayers/vlc/art.go
Normal file
41
seanime-2.9.10/internal/mediaplayers/vlc/art.go
Normal file
@@ -0,0 +1,41 @@
|
||||
package vlc
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"strconv"
|
||||
)
|
||||
|
||||
// Art fetches cover art based on a playlist item's ID. If no ID is provided, Art returns the current item's cover art.
|
||||
// Cover art is returned in the form of a byte array.
|
||||
func (vlc *VLC) Art(itemID ...int) (byteArr []byte, err error) {
|
||||
|
||||
// Check variadic arguments
|
||||
if len(itemID) > 1 {
|
||||
err = errors.New("please provide only up to one ID")
|
||||
return
|
||||
}
|
||||
|
||||
// Build request URL
|
||||
urlSegment := "/art"
|
||||
if len(itemID) == 1 {
|
||||
urlSegment = urlSegment + "?item=" + strconv.Itoa(itemID[0])
|
||||
}
|
||||
|
||||
// Make request
|
||||
var response string
|
||||
response, err = vlc.RequestMaker(urlSegment)
|
||||
|
||||
// Error Handling
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
if response == "Error" {
|
||||
err = errors.New("no cover art available for item")
|
||||
return
|
||||
}
|
||||
|
||||
// Convert response to byte array
|
||||
byteArr = []byte(response)
|
||||
|
||||
return
|
||||
}
|
||||
39
seanime-2.9.10/internal/mediaplayers/vlc/browse.go
Normal file
39
seanime-2.9.10/internal/mediaplayers/vlc/browse.go
Normal file
@@ -0,0 +1,39 @@
|
||||
package vlc
|
||||
|
||||
import "github.com/goccy/go-json"
|
||||
|
||||
// File struct represents a single item in the browsed directory. Can be a file or a dir
|
||||
type File struct {
|
||||
Type string `json:"type"` // file or dir
|
||||
Path string `json:"path"`
|
||||
Name string `json:"name"`
|
||||
AccessTime uint `json:"access_time"`
|
||||
UID uint `json:"uid"`
|
||||
CreationTime uint `json:"creation_time"`
|
||||
GID uint `json:"gid"`
|
||||
ModificationTime uint `json:"modification_time"`
|
||||
Mode uint `json:"mode"`
|
||||
URI string `json:"uri"`
|
||||
Size uint `json:"size"`
|
||||
}
|
||||
|
||||
// ParseBrowse parses Browse() responses to []File
|
||||
func ParseBrowse(browseResponse string) (files []File, err error) {
|
||||
var temp struct {
|
||||
Files []File `json:"element"`
|
||||
}
|
||||
err = json.Unmarshal([]byte(browseResponse), &temp)
|
||||
files = temp.Files
|
||||
return
|
||||
}
|
||||
|
||||
// Browse returns a File array with the items of the provided directory URI
|
||||
func (vlc *VLC) Browse(uri string) (files []File, err error) {
|
||||
var response string
|
||||
response, err = vlc.RequestMaker("/requests/browse.json?uri=" + uri)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
files, err = ParseBrowse(response)
|
||||
return
|
||||
}
|
||||
39
seanime-2.9.10/internal/mediaplayers/vlc/playlist.go
Normal file
39
seanime-2.9.10/internal/mediaplayers/vlc/playlist.go
Normal file
@@ -0,0 +1,39 @@
|
||||
package vlc
|
||||
|
||||
import "github.com/goccy/go-json"
|
||||
|
||||
// Node structure (node or leaf type) is the basic element of VLC's playlist tree representation.
|
||||
// Leafs are playlist items. Nodes are playlists or folders inside playlists.
|
||||
type Node struct {
|
||||
Ro string `json:"ro"`
|
||||
Type string `json:"type"` // node or leaf
|
||||
Name string `json:"name"`
|
||||
ID string `json:"id"`
|
||||
Duration int `json:"duration,omitempty"`
|
||||
URI string `json:"uri,omitempty"`
|
||||
Current string `json:"current,omitempty"`
|
||||
Children []Node `json:"children,omitempty"`
|
||||
}
|
||||
|
||||
// ParsePlaylist parses Playlist() responses to Node
|
||||
func ParsePlaylist(playlistResponse string) (playlist Node, err error) {
|
||||
err = json.Unmarshal([]byte(playlistResponse), &playlist)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// Playlist returns a Node object that is the root node of VLC's Playlist tree
|
||||
// Playlist tree structure: Level 0 - Root Node (Type="node"), Level 1 - Playlists (Type="node"),
|
||||
// Level 2+: Playlist Items (Type="leaf") or Folder (Type="node")
|
||||
func (vlc *VLC) Playlist() (playlist Node, err error) {
|
||||
// Make response and check for errors
|
||||
response, err := vlc.RequestMaker("/requests/playlist.json")
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
// Parse to node
|
||||
playlist, err = ParsePlaylist(response)
|
||||
return
|
||||
}
|
||||
66
seanime-2.9.10/internal/mediaplayers/vlc/start.go
Normal file
66
seanime-2.9.10/internal/mediaplayers/vlc/start.go
Normal file
@@ -0,0 +1,66 @@
|
||||
package vlc
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"runtime"
|
||||
"seanime/internal/util"
|
||||
"time"
|
||||
)
|
||||
|
||||
func (vlc *VLC) getExecutableName() string {
|
||||
switch runtime.GOOS {
|
||||
case "windows":
|
||||
return "vlc.exe"
|
||||
case "linux":
|
||||
return "vlc"
|
||||
case "darwin":
|
||||
return "vlc"
|
||||
default:
|
||||
return "vlc"
|
||||
}
|
||||
}
|
||||
|
||||
func (vlc *VLC) GetExecutablePath() string {
|
||||
|
||||
if len(vlc.Path) > 0 {
|
||||
return vlc.Path
|
||||
}
|
||||
|
||||
switch runtime.GOOS {
|
||||
case "windows":
|
||||
return "C:\\Program Files\\VideoLAN\\VLC\\vlc.exe"
|
||||
case "linux":
|
||||
return "/usr/bin/vlc" // Default path for VLC on most Linux distributions
|
||||
case "darwin":
|
||||
return "/Applications/VLC.app/Contents/MacOS/VLC" // Default path for VLC on macOS
|
||||
default:
|
||||
return "C:\\Program Files\\VideoLAN\\VLC\\vlc.exe"
|
||||
}
|
||||
}
|
||||
|
||||
func (vlc *VLC) Start() error {
|
||||
|
||||
// If the path is empty, do not check if VLC is running
|
||||
if vlc.Path == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Check if VLC is already running
|
||||
name := vlc.getExecutableName()
|
||||
if util.ProgramIsRunning(name) {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Start VLC
|
||||
exe := vlc.GetExecutablePath()
|
||||
cmd := util.NewCmd(exe)
|
||||
err := cmd.Start()
|
||||
if err != nil {
|
||||
vlc.Logger.Error().Err(err).Msg("vlc: Error starting VLC")
|
||||
return fmt.Errorf("error starting VLC: %w", err)
|
||||
}
|
||||
|
||||
time.Sleep(1 * time.Second)
|
||||
|
||||
return nil
|
||||
}
|
||||
390
seanime-2.9.10/internal/mediaplayers/vlc/status.go
Normal file
390
seanime-2.9.10/internal/mediaplayers/vlc/status.go
Normal file
@@ -0,0 +1,390 @@
|
||||
package vlc
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/url"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/goccy/go-json"
|
||||
)
|
||||
|
||||
// Status contains information related to the VLC instance status. Use parseStatus to parse the response from a
|
||||
// status.go function.
|
||||
type Status struct {
|
||||
// TODO: The Status structure is still a work in progress
|
||||
Fullscreen bool `json:"fullscreen"`
|
||||
Stats Stats `json:"stats"`
|
||||
AspectRatio string `json:"aspectratio"`
|
||||
AudioDelay float64 `json:"audiodelay"`
|
||||
APIVersion uint `json:"apiversion"`
|
||||
CurrentPlID uint `json:"currentplid"`
|
||||
Time uint `json:"time"`
|
||||
Volume uint `json:"volume"`
|
||||
Length uint `json:"length"`
|
||||
Random bool `json:"random"`
|
||||
AudioFilters map[string]string `json:"audiofilters"`
|
||||
Rate float64 `json:"rate"`
|
||||
VideoEffects VideoEffects `json:"videoeffects"`
|
||||
State string `json:"state"`
|
||||
Loop bool `json:"loop"`
|
||||
Version string `json:"version"`
|
||||
Position float64 `json:"position"`
|
||||
Information Information `json:"information"`
|
||||
Repeat bool `json:"repeat"`
|
||||
SubtitleDelay float64 `json:"subtitledelay"`
|
||||
Equalizer []Equalizer `json:"equalizer"`
|
||||
}
|
||||
|
||||
// Stats contains certain statistics of a VLC instance. A Stats variable is included in Status
|
||||
type Stats struct {
|
||||
InputBitRate float64 `json:"inputbitrate"`
|
||||
SentBytes uint `json:"sentbytes"`
|
||||
LosABuffers uint `json:"lostabuffers"`
|
||||
AveragedEMuxBitrate float64 `json:"averagedemuxbitrate"`
|
||||
ReadPackets uint `json:"readpackets"`
|
||||
DemuxReadPackets uint `json:"demuxreadpackets"`
|
||||
LostPictures uint `json:"lostpictures"`
|
||||
DisplayedPictures uint `json:"displayedpictures"`
|
||||
SentPackets uint `json:"sentpackets"`
|
||||
DemuxReadBytes uint `json:"demuxreadbytes"`
|
||||
DemuxBitRate float64 `json:"demuxbitrate"`
|
||||
PlayedABuffers uint `json:"playedabuffers"`
|
||||
DemuxDiscontinuity uint `json:"demuxdiscontinuity"`
|
||||
DecodeAudio uint `json:"decodedaudio"`
|
||||
SendBitRate float64 `json:"sendbitrate"`
|
||||
ReadBytes uint `json:"readbytes"`
|
||||
AverageInputBitRate float64 `json:"averageinputbitrate"`
|
||||
DemuxCorrupted uint `json:"demuxcorrupted"`
|
||||
DecodedVideo uint `json:"decodedvideo"`
|
||||
}
|
||||
|
||||
// VideoEffects contains the current video effects configuration. A VideoEffects variable is included in Status
|
||||
type VideoEffects struct {
|
||||
Hue int `json:"hue"`
|
||||
Saturation int `json:"saturation"`
|
||||
Contrast int `json:"contrast"`
|
||||
Brightness int `json:"brightness"`
|
||||
Gamma int `json:"gamma"`
|
||||
}
|
||||
|
||||
// Information contains information related to the item currently being played. It is also part of Status
|
||||
type Information struct {
|
||||
Chapter int `json:"chapter"`
|
||||
// TODO: Chapters definition might need to be changed
|
||||
Chapters []interface{} `json:"chapters"`
|
||||
Title int `json:"title"`
|
||||
// TODO: Category definition might need to be updated/modified
|
||||
Category map[string]struct {
|
||||
Filename string `json:"filename"`
|
||||
Codec string `json:"Codec"`
|
||||
Channels string `json:"Channels"`
|
||||
BitsPerSample string `json:"Bits_per_sample"`
|
||||
Type string `json:"Type"`
|
||||
SampleRate string `json:"Sample_rate"`
|
||||
} `json:"category"`
|
||||
Titles []interface{} `json:"titles"`
|
||||
}
|
||||
|
||||
// Equalizer contains information related to the equalizer configuration. An Equalizer variable is included in Status
|
||||
type Equalizer struct {
|
||||
Presets map[string]string `json:"presets"`
|
||||
Bands map[string]string `json:"bands"`
|
||||
Preamp int `json:"preamp"`
|
||||
}
|
||||
|
||||
// parseStatus parses GetStatus() responses to Status struct.
|
||||
func parseStatus(statusResponse string) (status *Status, err error) {
|
||||
err = json.Unmarshal([]byte(statusResponse), &status)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
return status, nil
|
||||
}
|
||||
|
||||
// GetStatus returns a Status object containing information of the instances' status
|
||||
func (vlc *VLC) GetStatus() (status *Status, err error) {
|
||||
// Make request
|
||||
var response string
|
||||
response, err = vlc.RequestMaker("/requests/status.json")
|
||||
// Error handling
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
// Parse response to Status
|
||||
status, err = parseStatus(response)
|
||||
return
|
||||
}
|
||||
|
||||
// Play playlist item with given id. If id is omitted, play last active item
|
||||
func (vlc *VLC) Play(itemID ...int) (err error) {
|
||||
// Check variadic arguments and form urlSegment
|
||||
if len(itemID) > 1 {
|
||||
err = errors.New("please provide only up to one ID")
|
||||
return
|
||||
}
|
||||
urlSegment := "/requests/status.json?command=pl_play"
|
||||
if len(itemID) == 1 {
|
||||
urlSegment = urlSegment + "&id=" + strconv.Itoa(itemID[0])
|
||||
}
|
||||
_, err = vlc.RequestMaker(urlSegment)
|
||||
return
|
||||
}
|
||||
|
||||
// Pause toggles pause: If current state was 'stop', play item with given id, if no id specified, play current item.
|
||||
// If no current item, play the first item in the playlist.
|
||||
func (vlc *VLC) Pause(itemID ...int) (err error) {
|
||||
// Check variadic arguments and form urlSegment
|
||||
if len(itemID) > 1 {
|
||||
err = errors.New("please provide only up to one ID")
|
||||
return
|
||||
}
|
||||
urlSegment := "/requests/status.json?command=pl_pause"
|
||||
if len(itemID) == 1 {
|
||||
urlSegment = urlSegment + "&id=" + strconv.Itoa(itemID[0])
|
||||
}
|
||||
_, err = vlc.RequestMaker(urlSegment)
|
||||
return
|
||||
}
|
||||
|
||||
// Stop stops playback
|
||||
func (vlc *VLC) Stop() (err error) {
|
||||
_, err = vlc.RequestMaker("/requests/status.json?command=pl_stop")
|
||||
return
|
||||
}
|
||||
|
||||
// Next skips to the next playlist item
|
||||
func (vlc *VLC) Next() (err error) {
|
||||
_, err = vlc.RequestMaker("/requests/status.json?command=pl_next")
|
||||
return
|
||||
}
|
||||
|
||||
// Previous goes back to the previous playlist item
|
||||
func (vlc *VLC) Previous() (err error) {
|
||||
_, err = vlc.RequestMaker("/requests/status.json?command=pl_previous")
|
||||
return
|
||||
}
|
||||
|
||||
// EmptyPlaylist empties the playlist
|
||||
func (vlc *VLC) EmptyPlaylist() (err error) {
|
||||
_, err = vlc.RequestMaker("/requests/status.json?command=pl_empty")
|
||||
return
|
||||
}
|
||||
|
||||
// ToggleLoop toggles Random Playback
|
||||
func (vlc *VLC) ToggleLoop() (err error) {
|
||||
_, err = vlc.RequestMaker("/requests/status.json?command=pl_random")
|
||||
return
|
||||
}
|
||||
|
||||
// ToggleRepeat toggles Playback Looping
|
||||
func (vlc *VLC) ToggleRepeat() (err error) {
|
||||
_, err = vlc.RequestMaker("/requests/status.json?command=pl_loop")
|
||||
return
|
||||
}
|
||||
|
||||
// ToggleRandom toggles Repeat
|
||||
func (vlc *VLC) ToggleRandom() (err error) {
|
||||
_, err = vlc.RequestMaker("/requests/status.json?command=pl_repeat")
|
||||
return
|
||||
}
|
||||
|
||||
// ToggleFullscreen toggles Fullscreen mode
|
||||
func (vlc *VLC) ToggleFullscreen() (err error) {
|
||||
_, err = vlc.RequestMaker("/requests/status.json?command=fullscreen")
|
||||
return
|
||||
}
|
||||
|
||||
func escapeInput(input string) string {
|
||||
if strings.HasPrefix(input, "http") {
|
||||
return url.QueryEscape(input)
|
||||
} else {
|
||||
input = filepath.FromSlash(input)
|
||||
return strings.ReplaceAll(url.QueryEscape(input), "+", "%20")
|
||||
}
|
||||
}
|
||||
|
||||
// AddAndPlay adds a URI to the playlist and starts playback.
|
||||
// The option field is optional and can have the values: noaudio, novideo
|
||||
func (vlc *VLC) AddAndPlay(uri string, option ...string) error {
|
||||
// Check variadic arguments and form urlSegment
|
||||
if len(option) > 1 {
|
||||
return errors.New("please provide only one option")
|
||||
}
|
||||
urlSegment := "/requests/status.json?command=in_play&input=" + escapeInput(uri)
|
||||
if len(option) == 1 {
|
||||
if (option[0] != "noaudio") && (option[0] != "novideo") {
|
||||
return errors.New("invalid option")
|
||||
}
|
||||
urlSegment = urlSegment + "&option=" + option[0]
|
||||
}
|
||||
_, err := vlc.RequestMaker(urlSegment)
|
||||
return err
|
||||
}
|
||||
|
||||
// Add adds a URI to the playlist
|
||||
func (vlc *VLC) Add(uri string) (err error) {
|
||||
_, err = vlc.RequestMaker("/requests/status.json?command=in_enqueue&input=" + escapeInput(uri))
|
||||
return
|
||||
}
|
||||
|
||||
// AddSubtitle adds a subtitle from URI to currently playing file
|
||||
func (vlc *VLC) AddSubtitle(uri string) (err error) {
|
||||
_, err = vlc.RequestMaker("/requests/status.json?command=addsubtitle&val=" + escapeInput(uri))
|
||||
return
|
||||
}
|
||||
|
||||
// Resume resumes playback if paused, else does nothing
|
||||
func (vlc *VLC) Resume() (err error) {
|
||||
_, err = vlc.RequestMaker("/requests/status.json?command=pl_forceresume")
|
||||
return
|
||||
}
|
||||
|
||||
// ForcePause pauses playback, does nothing if already paused
|
||||
func (vlc *VLC) ForcePause() (err error) {
|
||||
_, err = vlc.RequestMaker("/requests/status.json?command=pl_forcepause")
|
||||
return
|
||||
}
|
||||
|
||||
// Delete deletes an item with given id from playlist
|
||||
func (vlc *VLC) Delete(id int) (err error) {
|
||||
_, err = vlc.RequestMaker("/requests/status.json?command=pl_delete&id=" + strconv.Itoa(id))
|
||||
return
|
||||
}
|
||||
|
||||
// AudioDelay sets Audio Delay in seconds
|
||||
func (vlc *VLC) AudioDelay(delay float64) (err error) {
|
||||
_, err = vlc.RequestMaker("/requests/status.json?command=audiodelay&val=" + strconv.FormatFloat(delay, 'f', -1, 64))
|
||||
return
|
||||
}
|
||||
|
||||
// SubDelay sets Subtitle Delay in seconds
|
||||
func (vlc *VLC) SubDelay(delay float64) (err error) {
|
||||
_, err = vlc.RequestMaker("/requests/status.json?command=subdelay&val=" + strconv.FormatFloat(delay, 'f', -1, 64))
|
||||
return
|
||||
}
|
||||
|
||||
// PlaybackRate sets Playback Rate. Must be > 0
|
||||
func (vlc *VLC) PlaybackRate(rate float64) (err error) {
|
||||
if rate <= 0 {
|
||||
err = errors.New("rate must be greater than 0")
|
||||
return
|
||||
}
|
||||
_, err = vlc.RequestMaker("/requests/status.json?command=rate&val=" + strconv.FormatFloat(rate, 'f', -1, 64))
|
||||
return
|
||||
}
|
||||
|
||||
// AspectRatio sets aspect ratio. Must be one of the following values. Any other value will reset aspect ratio to default.
|
||||
// Valid aspect ratio values: 1:1 , 4:3 , 5:4 , 16:9 , 16:10 , 221:100 , 235:100 , 239:100
|
||||
func (vlc *VLC) AspectRatio(ratio string) (err error) {
|
||||
_, err = vlc.RequestMaker("/requests/status.json?command=aspectratio&val=" + ratio)
|
||||
return
|
||||
}
|
||||
|
||||
// Sort sorts playlist using sort mode <val> and order <id>.
|
||||
// If id=0 then items will be sorted in normal order, if id=1 they will be sorted in reverse order.
|
||||
// A non exhaustive list of sort modes: 0 Id, 1 Name, 3 Author, 5 Random, 7 Track number.
|
||||
func (vlc *VLC) Sort(id int, val int) (err error) {
|
||||
if (id != 0) && (id != 1) {
|
||||
err = errors.New("sorting order must be 0 or 1")
|
||||
return
|
||||
}
|
||||
_, err = vlc.RequestMaker("/requests/status.json?command=pl_sort&id=" + strconv.Itoa(id) + "&val=" + strconv.Itoa(val))
|
||||
return
|
||||
}
|
||||
|
||||
// ToggleSD toggle-enables service discovery module <val>.
|
||||
// Typical values are: sap shoutcast, podcast, hal
|
||||
func (vlc *VLC) ToggleSD(val string) (err error) {
|
||||
_, err = vlc.RequestMaker("/requests/status.json?command=pl_sd&val=" + val)
|
||||
return
|
||||
}
|
||||
|
||||
// Volume sets Volume level <val> (can be absolute integer, or +/- relative value).
|
||||
// Percentage isn't working at the moment. Allowed values are of the form: +<int>, -<int>, <int> or <int>%
|
||||
func (vlc *VLC) Volume(val string) (err error) {
|
||||
_, err = vlc.RequestMaker("/requests/status.json?command=volume&val=" + val)
|
||||
return
|
||||
}
|
||||
|
||||
// Seek seeks to <val>
|
||||
//
|
||||
// Allowed values are of the form:
|
||||
// [+ or -][<int><H or h>:][<int><M or m or '>:][<int><nothing or S or s or ">]
|
||||
// or [+ or -]<int>%
|
||||
// (value between [ ] are optional, value between < > are mandatory)
|
||||
// examples:
|
||||
// 1000 -> seek to the 1000th second
|
||||
// +1H:2M -> seek 1 hour and 2 minutes forward
|
||||
// -10% -> seek 10% back
|
||||
func (vlc *VLC) Seek(val string) (err error) {
|
||||
_, err = vlc.RequestMaker("/requests/status.json?command=seek&val=" + val)
|
||||
return
|
||||
}
|
||||
|
||||
// Preamp sets the preamp gain value, must be >=-20 and <=20
|
||||
func (vlc *VLC) Preamp(gain int) (err error) {
|
||||
if (gain < -20) || (gain > 20) {
|
||||
err = errors.New("preamp must be between -20 and 20")
|
||||
return
|
||||
}
|
||||
_, err = vlc.RequestMaker("/requests/status.json?command=preamp&val=" + strconv.Itoa(gain))
|
||||
return
|
||||
}
|
||||
|
||||
// SetEQ sets the gain for a specific Equalizer band
|
||||
func (vlc *VLC) SetEQ(band int, gain int) (err error) {
|
||||
if (gain < -20) || (gain > 20) {
|
||||
err = errors.New("gain must be between -20 and 20")
|
||||
return
|
||||
}
|
||||
_, err = vlc.RequestMaker("/requests/status.json?command=equalizer&band=" + strconv.Itoa(band) + "&val=" + strconv.Itoa(gain))
|
||||
return
|
||||
}
|
||||
|
||||
// ToggleEQ toggles the EQ (true to enable, false to disable)
|
||||
func (vlc *VLC) ToggleEQ(enable bool) (err error) {
|
||||
enableStr := "0"
|
||||
if enable == true {
|
||||
enableStr = "1"
|
||||
}
|
||||
_, err = vlc.RequestMaker("/requests/status.json?command=enableeq&val=" + enableStr)
|
||||
return
|
||||
}
|
||||
|
||||
// SetEQPreset sets the equalizer preset as per the id specified
|
||||
func (vlc *VLC) SetEQPreset(id int) (err error) {
|
||||
_, err = vlc.RequestMaker("/requests/status.json?command=setpreset&id=" + strconv.Itoa(id))
|
||||
return
|
||||
}
|
||||
|
||||
// SelectTitle selects the title using the title number
|
||||
func (vlc *VLC) SelectTitle(id int) (err error) {
|
||||
_, err = vlc.RequestMaker("/requests/status.json?command=title&val=" + strconv.Itoa(id))
|
||||
return
|
||||
}
|
||||
|
||||
// SelectChapter selects the chapter using the chapter number
|
||||
func (vlc *VLC) SelectChapter(id int) (err error) {
|
||||
_, err = vlc.RequestMaker("/requests/status.json?command=chapter&val=" + strconv.Itoa(id))
|
||||
return
|
||||
}
|
||||
|
||||
// SelectAudioTrack selects the audio track (use the number from the stream)
|
||||
func (vlc *VLC) SelectAudioTrack(id int) (err error) {
|
||||
_, err = vlc.RequestMaker("/requests/status.json?command=audio_track&val=" + strconv.Itoa(id))
|
||||
return
|
||||
}
|
||||
|
||||
// SelectVideoTrack selects the video track (use the number from the stream)
|
||||
func (vlc *VLC) SelectVideoTrack(id int) (err error) {
|
||||
_, err = vlc.RequestMaker("/requests/status.json?command=video_track&val=" + strconv.Itoa(id))
|
||||
return
|
||||
}
|
||||
|
||||
// SelectSubtitleTrack selects the subtitle track (use the number from the stream)
|
||||
func (vlc *VLC) SelectSubtitleTrack(id int) (err error) {
|
||||
_, err = vlc.RequestMaker("/requests/status.json?command=subtitle_track&val=" + strconv.Itoa(id))
|
||||
return
|
||||
}
|
||||
66
seanime-2.9.10/internal/mediaplayers/vlc/vlc.go
Normal file
66
seanime-2.9.10/internal/mediaplayers/vlc/vlc.go
Normal file
@@ -0,0 +1,66 @@
|
||||
package vlc
|
||||
|
||||
// https://github.com/CedArctic/go-vlc-ctrl/tree/master
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"github.com/rs/zerolog"
|
||||
"io"
|
||||
"net/http"
|
||||
"strconv"
|
||||
)
|
||||
|
||||
// VLC struct represents an http interface enabled VLC instance. Build using NewVLC()
|
||||
type VLC struct {
|
||||
Host string
|
||||
Port int
|
||||
Password string
|
||||
Path string
|
||||
Logger *zerolog.Logger
|
||||
}
|
||||
|
||||
func (vlc *VLC) url() string {
|
||||
return fmt.Sprintf("http://%s:%s", vlc.Host, strconv.Itoa(vlc.Port))
|
||||
}
|
||||
|
||||
// RequestMaker make requests to VLC using a urlSegment provided by other functions
|
||||
func (vlc *VLC) RequestMaker(urlSegment string) (response string, err error) {
|
||||
|
||||
// Form a GET Request
|
||||
client := &http.Client{}
|
||||
request, reqErr := http.NewRequest("GET", vlc.url()+urlSegment, nil)
|
||||
if reqErr != nil {
|
||||
err = fmt.Errorf("http request error: %s\n", reqErr)
|
||||
return
|
||||
}
|
||||
|
||||
// Make a GET request
|
||||
request.SetBasicAuth("", vlc.Password)
|
||||
reqResponse, resErr := client.Do(request)
|
||||
if resErr != nil {
|
||||
err = fmt.Errorf("http response error: %s\n", resErr)
|
||||
return
|
||||
}
|
||||
defer func() {
|
||||
reqResponse.Body.Close()
|
||||
}()
|
||||
|
||||
// Check HTTP status code and errors
|
||||
statusCode := reqResponse.StatusCode
|
||||
if !((statusCode >= 200) && (statusCode <= 299)) {
|
||||
err = fmt.Errorf("http error code: %d\n", statusCode)
|
||||
return "", err
|
||||
}
|
||||
|
||||
// Get byte response and http status code
|
||||
byteArr, readErr := io.ReadAll(reqResponse.Body)
|
||||
if readErr != nil {
|
||||
err = fmt.Errorf("error reading response: %s\n", readErr)
|
||||
return
|
||||
}
|
||||
|
||||
// Write response
|
||||
response = string(byteArr)
|
||||
|
||||
return
|
||||
}
|
||||
87
seanime-2.9.10/internal/mediaplayers/vlc/vlc_test.go
Normal file
87
seanime-2.9.10/internal/mediaplayers/vlc/vlc_test.go
Normal file
@@ -0,0 +1,87 @@
|
||||
package vlc
|
||||
|
||||
import (
|
||||
"github.com/davecgh/go-spew/spew"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"seanime/internal/test_utils"
|
||||
"seanime/internal/util"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestVLC_Play(t *testing.T) {
|
||||
test_utils.InitTestProvider(t, test_utils.MediaPlayer())
|
||||
|
||||
vlc := &VLC{
|
||||
Host: test_utils.ConfigData.Provider.VlcHost,
|
||||
Port: test_utils.ConfigData.Provider.VlcPort,
|
||||
Password: test_utils.ConfigData.Provider.VlcPassword,
|
||||
Path: test_utils.ConfigData.Provider.VlcPath,
|
||||
Logger: util.NewLogger(),
|
||||
}
|
||||
|
||||
err := vlc.Start()
|
||||
require.NoError(t, err)
|
||||
|
||||
err = vlc.AddAndPlay("E:\\Anime\\[Judas] Golden Kamuy (Seasons 1-2) [BD 1080p][HEVC x265 10bit][Eng-Subs]\\[Judas] Golden Kamuy - S2\\[Judas] Golden Kamuy S2 - 16.mkv")
|
||||
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
time.Sleep(400 * time.Millisecond)
|
||||
|
||||
vlc.ForcePause()
|
||||
|
||||
time.Sleep(400 * time.Millisecond)
|
||||
|
||||
status, err := vlc.GetStatus()
|
||||
require.NoError(t, err)
|
||||
|
||||
assert.Equal(t, "paused", status.State)
|
||||
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
func TestVLC_Seek(t *testing.T) {
|
||||
test_utils.InitTestProvider(t, test_utils.MediaPlayer())
|
||||
|
||||
vlc := &VLC{
|
||||
Host: test_utils.ConfigData.Provider.VlcHost,
|
||||
Port: test_utils.ConfigData.Provider.VlcPort,
|
||||
Password: test_utils.ConfigData.Provider.VlcPassword,
|
||||
Path: test_utils.ConfigData.Provider.VlcPath,
|
||||
Logger: util.NewLogger(),
|
||||
}
|
||||
|
||||
err := vlc.Start()
|
||||
require.NoError(t, err)
|
||||
|
||||
err = vlc.AddAndPlay("E:\\ANIME\\[SubsPlease] Bocchi the Rock! (01-12) (1080p) [Batch]\\[SubsPlease] Bocchi the Rock! - 01v2 (1080p) [ABDDAE16].mkv")
|
||||
|
||||
time.Sleep(400 * time.Millisecond)
|
||||
|
||||
vlc.ForcePause()
|
||||
|
||||
time.Sleep(400 * time.Millisecond)
|
||||
|
||||
vlc.Seek("100")
|
||||
|
||||
time.Sleep(400 * time.Millisecond)
|
||||
|
||||
status, err := vlc.GetStatus()
|
||||
require.NoError(t, err)
|
||||
|
||||
assert.Equal(t, "paused", status.State)
|
||||
|
||||
spew.Dump(status)
|
||||
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
}
|
||||
21
seanime-2.9.10/internal/mediaplayers/vlc/vlm.go
Normal file
21
seanime-2.9.10/internal/mediaplayers/vlc/vlm.go
Normal file
@@ -0,0 +1,21 @@
|
||||
package vlc
|
||||
|
||||
import "net/url"
|
||||
|
||||
// Vlm returns the full list of VLM elements
|
||||
func (vlc *VLC) Vlm() (response string, err error) {
|
||||
response, err = vlc.RequestMaker("/requests/vlm.xml")
|
||||
return
|
||||
}
|
||||
|
||||
// VlmCmd executes a VLM Command and returns the response. Command is internally URL percent-encoded
|
||||
func (vlc *VLC) VlmCmd(cmd string) (response string, err error) {
|
||||
response, err = vlc.RequestMaker("/requests/vlm_cmd.xml?command=" + url.QueryEscape(cmd))
|
||||
return
|
||||
}
|
||||
|
||||
// VlmCmdErr returns the last VLM Error
|
||||
func (vlc *VLC) VlmCmdErr() (response string, err error) {
|
||||
response, err = vlc.RequestMaker("/requests/vlm_cmd.xml")
|
||||
return
|
||||
}
|
||||
Reference in New Issue
Block a user