Add real mpv-backed jukebox controller (JSON IPC), tested against real mpv
This commit is contained in:
@@ -0,0 +1,185 @@
|
||||
// Package jukebox implements Subsonic's jukeboxControl endpoint using
|
||||
// mpv as the actual server-side playback backend, driven over mpv's
|
||||
// JSON IPC socket (--input-ipc-server). If mpv isn't installed, the
|
||||
// jukebox is simply unavailable — VibeSonic never fakes playback.
|
||||
package jukebox
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
)
|
||||
|
||||
// mpvClient owns one mpv process and its JSON IPC connection.
|
||||
type mpvClient struct {
|
||||
mpvPath string
|
||||
socketPath string
|
||||
|
||||
mu sync.Mutex
|
||||
cmd *exec.Cmd
|
||||
conn net.Conn
|
||||
reqID int64
|
||||
|
||||
// pending correlates in-flight requests (by request_id) to the
|
||||
// goroutine waiting for their response; mpv's IPC is a single
|
||||
// bidirectional stream so responses must be demultiplexed.
|
||||
pendingMu sync.Mutex
|
||||
pending map[int64]chan mpvResponse
|
||||
|
||||
closed atomic.Bool
|
||||
}
|
||||
|
||||
type mpvResponse struct {
|
||||
Error string `json:"error"`
|
||||
Data any `json:"data"`
|
||||
RequestID int64 `json:"request_id"`
|
||||
}
|
||||
|
||||
func newMPVClient(mpvPath string) *mpvClient {
|
||||
if mpvPath == "" {
|
||||
mpvPath = "mpv"
|
||||
}
|
||||
return &mpvClient{mpvPath: mpvPath, pending: make(map[int64]chan mpvResponse)}
|
||||
}
|
||||
|
||||
func (c *mpvClient) start() error {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
if c.cmd != nil {
|
||||
return nil // already running
|
||||
}
|
||||
|
||||
dir, err := os.MkdirTemp("", "vibesonic-mpv-")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
c.socketPath = filepath.Join(dir, "mpv.sock")
|
||||
|
||||
cmd := exec.Command(c.mpvPath,
|
||||
"--idle=yes",
|
||||
"--no-video",
|
||||
"--no-terminal",
|
||||
"--really-quiet",
|
||||
"--input-ipc-server="+c.socketPath,
|
||||
)
|
||||
if err := cmd.Start(); err != nil {
|
||||
os.RemoveAll(dir)
|
||||
return fmt.Errorf("starting mpv: %w", err)
|
||||
}
|
||||
c.cmd = cmd
|
||||
|
||||
var conn net.Conn
|
||||
deadline := time.Now().Add(5 * time.Second)
|
||||
for time.Now().Before(deadline) {
|
||||
conn, err = net.Dial("unix", c.socketPath)
|
||||
if err == nil {
|
||||
break
|
||||
}
|
||||
time.Sleep(50 * time.Millisecond)
|
||||
}
|
||||
if conn == nil {
|
||||
_ = cmd.Process.Kill()
|
||||
os.RemoveAll(dir)
|
||||
return fmt.Errorf("connecting to mpv IPC socket: %w", err)
|
||||
}
|
||||
c.conn = conn
|
||||
|
||||
go c.readLoop()
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *mpvClient) readLoop() {
|
||||
scanner := bufio.NewScanner(c.conn)
|
||||
scanner.Buffer(make([]byte, 0, 64*1024), 1<<20)
|
||||
for scanner.Scan() {
|
||||
var resp mpvResponse
|
||||
if err := json.Unmarshal(scanner.Bytes(), &resp); err != nil {
|
||||
continue // ignore event lines / anything we can't parse
|
||||
}
|
||||
if resp.RequestID == 0 {
|
||||
continue // an mpv event, not a command reply
|
||||
}
|
||||
c.pendingMu.Lock()
|
||||
ch, ok := c.pending[resp.RequestID]
|
||||
if ok {
|
||||
delete(c.pending, resp.RequestID)
|
||||
}
|
||||
c.pendingMu.Unlock()
|
||||
if ok {
|
||||
ch <- resp
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (c *mpvClient) command(args ...any) (any, error) {
|
||||
if err := c.start(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
id := atomic.AddInt64(&c.reqID, 1)
|
||||
payload := map[string]any{"command": args, "request_id": id}
|
||||
encoded, err := json.Marshal(payload)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
ch := make(chan mpvResponse, 1)
|
||||
c.pendingMu.Lock()
|
||||
c.pending[id] = ch
|
||||
c.pendingMu.Unlock()
|
||||
|
||||
c.mu.Lock()
|
||||
_, werr := c.conn.Write(append(encoded, '\n'))
|
||||
c.mu.Unlock()
|
||||
if werr != nil {
|
||||
c.pendingMu.Lock()
|
||||
delete(c.pending, id)
|
||||
c.pendingMu.Unlock()
|
||||
return nil, werr
|
||||
}
|
||||
|
||||
select {
|
||||
case resp := <-ch:
|
||||
if resp.Error != "" && resp.Error != "success" {
|
||||
return nil, fmt.Errorf("mpv: %s", resp.Error)
|
||||
}
|
||||
return resp.Data, nil
|
||||
case <-time.After(5 * time.Second):
|
||||
c.pendingMu.Lock()
|
||||
delete(c.pending, id)
|
||||
c.pendingMu.Unlock()
|
||||
return nil, fmt.Errorf("mpv command timed out")
|
||||
}
|
||||
}
|
||||
|
||||
func (c *mpvClient) getProperty(name string) (any, error) {
|
||||
return c.command("get_property", name)
|
||||
}
|
||||
|
||||
func (c *mpvClient) setProperty(name string, value any) error {
|
||||
_, err := c.command("set_property", name, value)
|
||||
return err
|
||||
}
|
||||
|
||||
func (c *mpvClient) close() {
|
||||
if c.closed.Swap(true) {
|
||||
return
|
||||
}
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
if c.conn != nil {
|
||||
c.conn.Close()
|
||||
}
|
||||
if c.cmd != nil && c.cmd.Process != nil {
|
||||
_ = c.cmd.Process.Kill()
|
||||
}
|
||||
if c.socketPath != "" {
|
||||
os.RemoveAll(filepath.Dir(c.socketPath))
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user