node build fixed

This commit is contained in:
ra_ma
2025-09-20 14:08:38 +01:00
parent c6ebbe069d
commit 3d298fa434
1516 changed files with 535727 additions and 2 deletions

View File

@@ -0,0 +1,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
})
}

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

View File

@@ -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"`
}

File diff suppressed because it is too large Load Diff

View File

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

View File

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

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

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

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

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

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

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

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

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

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

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

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

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

View 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.

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

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

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

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

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

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

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

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