Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
# Binary
/cue
/cue.exe

# Build artifacts
combine-go.sh
Expand Down
1 change: 1 addition & 0 deletions go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ module github.com/SuperCoolPencil/cue
go 1.25.5

require (
github.com/Microsoft/go-winio v0.6.2
github.com/charmbracelet/bubbles v0.21.0
github.com/charmbracelet/bubbletea v1.3.10
github.com/charmbracelet/lipgloss v1.1.0
Expand Down
2 changes: 2 additions & 0 deletions go.sum
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY=
github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU=
github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4=
github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI=
github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k=
Expand Down
6 changes: 3 additions & 3 deletions internal/mediaserver/plex/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -343,7 +343,7 @@ func (c *Client) collectExternalSubtitles(part Part) []domain.Subtitle {
}
// Only external streams expose a fetchable Key. Embedded streams have
// no separate URL and the player will discover them in the container.
if s.External != 1 && s.Key == "" {
if !s.External && s.Key == "" {
continue
}
if s.Key == "" {
Expand Down Expand Up @@ -378,8 +378,8 @@ func (c *Client) collectExternalSubtitles(part Part) []domain.Subtitle {
Language: lang,
Title: title,
Codec: codec,
Default: s.Default == 1,
Forced: s.Forced == 1,
Default: bool(s.Default),
Forced: bool(s.Forced),
})
}
return subs
Expand Down
27 changes: 23 additions & 4 deletions internal/mediaserver/plex/dto.go
Original file line number Diff line number Diff line change
@@ -1,5 +1,24 @@
package plex

import "bytes"

// flexBool unmarshals a JSON field that Plex returns as either a boolean
// (`true`/`false`) or a numeric flag (`1`/`0`). Older Plex servers send the
// integer form for stream flags like `default`/`forced`/`external`; newer
// ones send booleans, which broke parsing when the field was typed as int.
type flexBool bool

func (b *flexBool) UnmarshalJSON(data []byte) error {
data = bytes.TrimSpace(data)
switch {
case bytes.Equal(data, []byte("true")), bytes.Equal(data, []byte("1")):
*b = true
default:
*b = false
}
return nil
}

// MediaContainer is the root container for Plex API responses
type MediaContainer struct {
Size int `json:"size"`
Expand Down Expand Up @@ -129,10 +148,10 @@ type Stream struct {
DisplayTitle string `json:"displayTitle,omitempty"`
ExtendedDisplayTitle string `json:"extendedDisplayTitle,omitempty"`
Title string `json:"title,omitempty"`
Default int `json:"default,omitempty"`
Forced int `json:"forced,omitempty"`
Selected int `json:"selected,omitempty"`
External int `json:"external,omitempty"`
Default flexBool `json:"default,omitempty"`
Forced flexBool `json:"forced,omitempty"`
Selected flexBool `json:"selected,omitempty"`
External flexBool `json:"external,omitempty"`
}

// APIResponse wraps the MediaContainer for JSON unmarshaling
Expand Down
31 changes: 26 additions & 5 deletions internal/player/launcher.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,6 @@ import (
"context"
"fmt"
"log/slog"
"os"
"os/exec"
"path/filepath"
"runtime"
Expand Down Expand Up @@ -44,6 +43,13 @@ var darwinPlayers = []PlayerDef{
{Binary: "vlc", SeekFlag: "--start-time=%d"},
}

// Windows detection looks up by base name; exec.LookPath consults PATHEXT
// so "mpv" resolves to mpv.exe (e.g. scoop's shim).
var windowsPlayers = []PlayerDef{
{Binary: "mpv", SeekFlag: "--start=%d"},
{Binary: "vlc", SeekFlag: "--start-time=%d"},
}

// NewLauncher creates a new Launcher
// seekFlag is optional - if empty, we look up the flag from our known players table
func NewLauncher(command string, args []string, seekFlag string, logger *slog.Logger) *Launcher {
Expand Down Expand Up @@ -141,6 +147,8 @@ func (l *Launcher) detectPlayer() (PlayerDef, bool) {
candidates = darwinPlayers
case "linux":
candidates = linuxPlayers
case "windows":
candidates = windowsPlayers
default:
return PlayerDef{}, false
}
Expand All @@ -160,7 +168,7 @@ func (l *Launcher) execPlayer(player PlayerDef, media domain.PlayableMedia, offs

// Enable IPC for mpv
if player.Binary == "mpv" {
ipcSocket = filepath.Join(os.TempDir(), fmt.Sprintf("cue-mpv-%d.sock", time.Now().UnixNano()))
ipcSocket = newMPVSocketPath()
args = append(args, "--input-ipc-server="+ipcSocket)
}

Expand Down Expand Up @@ -228,10 +236,13 @@ func (l *Launcher) launchConfigured(media domain.PlayableMedia, offsetSecs int)
}
}

// For manual config, we check if it's mpv to enable IPC
// For manual config, we check if it's mpv to enable IPC. Match by base
// name (without extension) so Windows variants like "mpv.exe" and paths
// with backslashes (`C:\tools\mpv.exe`) are recognised too.
var ipcSocket string
if l.command == "mpv" || strings.HasSuffix(l.command, "/mpv") {
ipcSocket = filepath.Join(os.TempDir(), fmt.Sprintf("cue-mpv-%d.sock", time.Now().UnixNano()))
bin := strings.ToLower(strings.TrimSuffix(filepath.Base(l.command), filepath.Ext(l.command)))
if bin == "mpv" {
ipcSocket = newMPVSocketPath()
args = append([]string{"--input-ipc-server=" + ipcSocket}, args...)
}

Expand All @@ -254,6 +265,11 @@ func (l *Launcher) lookupSeekFlag(binary string) string {
return p.SeekFlag
}
}
for _, p := range windowsPlayers {
if p.Binary == binary {
return p.SeekFlag
}
}
return ""
}

Expand All @@ -280,6 +296,11 @@ func (l *Launcher) launchDefault(url string) (*exec.Cmd, error) {
switch runtime.GOOS {
case "darwin":
cmd = exec.Command("open", url)
case "windows":
// `start` is a cmd.exe builtin, not a standalone exe. The empty "" is
// a window title — required because `start` treats the first quoted
// arg as a title and would otherwise swallow the URL.
cmd = exec.Command("cmd", "/c", "start", "", url)
default:
// Linux and other Unix-like systems
cmd = exec.Command("xdg-open", url)
Expand Down
32 changes: 4 additions & 28 deletions internal/player/mpvipc.go
Original file line number Diff line number Diff line change
@@ -1,46 +1,22 @@
package player

import (
"bufio"
"encoding/json"
"fmt"
"net"
"time"
)

// mpvConn handles JSON-RPC communication with mpv over a Unix socket.
// mpvConn handles JSON-RPC communication with mpv over its IPC channel.
// The transport is a Unix domain socket on macOS/Linux and a named pipe on
// Windows; see mpvipc_unix.go / mpvipc_windows.go for the platform-specific
// dial logic and path helpers.
type mpvConn struct {
conn net.Conn
enc *json.Encoder
dec *json.Decoder
}

// dialMPV attempts to connect to the mpv IPC socket.
// It retries for up to 3 seconds as mpv takes a moment to create the socket.
func dialMPV(socketPath string) (*mpvConn, error) {
var conn net.Conn
var err error

deadline := time.Now().Add(3 * time.Second)
for time.Now().Before(deadline) {
conn, err = net.Dial("unix", socketPath)
if err == nil {
break
}
time.Sleep(200 * time.Millisecond)
}

if err != nil {
return nil, fmt.Errorf("failed to connect to mpv IPC: %w", err)
}

return &mpvConn{
conn: conn,
enc: json.NewEncoder(conn),
dec: json.NewDecoder(bufio.NewReader(conn)),
}, nil
}

// GetTimePos queries the current playback position in seconds.
func (c *mpvConn) GetTimePos() (float64, error) {
requestID := time.Now().UnixNano()
Expand Down
52 changes: 52 additions & 0 deletions internal/player/mpvipc_unix.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
//go:build !windows

package player

import (
"bufio"
"encoding/json"
"fmt"
"net"
"os"
"path/filepath"
"time"
)

// newMPVSocketPath returns a fresh Unix-domain-socket path for an mpv IPC server.
func newMPVSocketPath() string {
return filepath.Join(os.TempDir(), fmt.Sprintf("cue-mpv-%d.sock", time.Now().UnixNano()))
}

// removeMPVSocket cleans up the socket file once playback ends.
func removeMPVSocket(path string) {
if path == "" {
return
}
_ = os.Remove(path)
}

// dialMPV connects to the mpv IPC socket, retrying for up to 3 seconds while
// mpv finishes creating it.
func dialMPV(socketPath string) (*mpvConn, error) {
var conn net.Conn
var err error

deadline := time.Now().Add(3 * time.Second)
for time.Now().Before(deadline) {
conn, err = net.Dial("unix", socketPath)
if err == nil {
break
}
time.Sleep(200 * time.Millisecond)
}

if err != nil {
return nil, fmt.Errorf("failed to connect to mpv IPC: %w", err)
}

return &mpvConn{
conn: conn,
enc: json.NewEncoder(conn),
dec: json.NewDecoder(bufio.NewReader(conn)),
}, nil
}
39 changes: 39 additions & 0 deletions internal/player/mpvipc_windows.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
//go:build windows

package player

import (
"bufio"
"encoding/json"
"fmt"
"time"

"github.com/Microsoft/go-winio"
)

// newMPVSocketPath returns a fresh Windows named-pipe path for an mpv IPC
// server. mpv on Windows uses named pipes for `--input-ipc-server`; the
// `\\.\pipe\` prefix is the kernel object namespace.
func newMPVSocketPath() string {
return fmt.Sprintf(`\\.\pipe\cue-mpv-%d`, time.Now().UnixNano())
}

// removeMPVSocket is a no-op on Windows — named pipes are kernel objects that
// disappear once no handles reference them.
func removeMPVSocket(string) {}

// dialMPV connects to the mpv IPC named pipe. winio.DialPipe handles the
// retry/wait window via its timeout argument.
func dialMPV(pipePath string) (*mpvConn, error) {
timeout := 3 * time.Second
conn, err := winio.DialPipe(pipePath, &timeout)
if err != nil {
return nil, fmt.Errorf("failed to connect to mpv IPC: %w", err)
}

return &mpvConn{
conn: conn,
enc: json.NewEncoder(conn),
dec: json.NewDecoder(bufio.NewReader(conn)),
}, nil
}
7 changes: 1 addition & 6 deletions internal/player/scrobbler.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,6 @@ import (
"context"
"fmt"
"log/slog"
"os"
"os/exec"
"time"

Expand Down Expand Up @@ -54,11 +53,7 @@ func (s *Scrobbler) Monitor(ctx context.Context, cmd *exec.Cmd, ipcSocket string
go func() {
defer close(resCh)
defer close(statusCh)
defer func() {
if ipcSocket != "" {
_ = os.Remove(ipcSocket)
}
}()
defer removeMPVSocket(ipcSocket)

var lastPosMs int64
var mpv *mpvConn
Expand Down
Loading