589 lines
13 KiB
Go
589 lines
13 KiB
Go
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
|
|
})
|
|
}
|