From 12eb4b9d01098e5d874426112f53de5c3f091172 Mon Sep 17 00:00:00 2001 From: Owen Rummage Date: Sun, 23 Aug 2026 00:27:59 -0500 Subject: [PATCH] Add real mpv-backed jukebox controller (JSON IPC), tested against real mpv --- internal/jukebox/jukebox.go | 315 +++++++++++++++++++++++++++++++ internal/jukebox/jukebox_test.go | 216 +++++++++++++++++++++ internal/jukebox/mpv.go | 185 ++++++++++++++++++ 3 files changed, 716 insertions(+) create mode 100644 internal/jukebox/jukebox.go create mode 100644 internal/jukebox/jukebox_test.go create mode 100644 internal/jukebox/mpv.go diff --git a/internal/jukebox/jukebox.go b/internal/jukebox/jukebox.go new file mode 100644 index 0000000..138cdd8 --- /dev/null +++ b/internal/jukebox/jukebox.go @@ -0,0 +1,315 @@ +package jukebox + +import ( + "errors" + "math/rand" + "os/exec" + "sync" +) + +// ErrUnavailable is returned by every action when no mpv binary was +// found at startup — the jukebox is administratively unavailable rather +// than pretending to control playback it can't. +var ErrUnavailable = errors.New("jukebox: no playback backend available (mpv not found)") + +// State mirrors what the Subsonic jukeboxControl "status"/"get" actions +// report. +type State struct { + CurrentIndex int + Playing bool + Gain float64 + PositionSecs int + SongIDs []string // playlist order, VibeSonic song IDs +} + +// Jukebox is a real server-side playback controller backed by mpv. One +// instance serves the whole server (Subsonic's jukebox is inherently a +// single shared "device", not per-user). +type Jukebox struct { + Available bool + + mpv *mpvClient + + mu sync.Mutex + songIDs []string + gain float64 +} + +// New probes for an mpv binary and returns a Jukebox. If mpv isn't +// found, Available is false and every action returns ErrUnavailable — +// callers (the Subsonic handler) should surface that as a clear "not +// configured" error, never as fake success. +func New(mpvPath string) *Jukebox { + if mpvPath == "" { + mpvPath = "mpv" + } + j := &Jukebox{gain: 1.0} + if _, err := exec.LookPath(mpvPath); err != nil { + return j + } + j.Available = true + j.mpv = newMPVClient(mpvPath) + return j +} + +// Close stops the backing mpv process, if one was started. +func (j *Jukebox) Close() { + if j.mpv != nil { + j.mpv.close() + } +} + +func (j *Jukebox) requireAvailable() error { + if !j.Available { + return ErrUnavailable + } + return nil +} + +// Status returns the current playback state without changing anything. +func (j *Jukebox) Status() (State, error) { + if err := j.requireAvailable(); err != nil { + return State{}, err + } + j.mu.Lock() + defer j.mu.Unlock() + return j.stateLocked() +} + +func (j *Jukebox) stateLocked() (State, error) { + pauseVal, err := j.mpv.getProperty("pause") + if err != nil { + return State{}, err + } + paused, _ := pauseVal.(bool) + + idx := 0 + if v, err := j.mpv.getProperty("playlist-pos"); err == nil { + if f, ok := v.(float64); ok && f >= 0 { + idx = int(f) + } + } + pos := 0 + if v, err := j.mpv.getProperty("time-pos"); err == nil { + if f, ok := v.(float64); ok { + pos = int(f) + } + } + + return State{ + CurrentIndex: idx, + Playing: !paused && len(j.songIDs) > 0, + Gain: j.gain, + PositionSecs: pos, + SongIDs: append([]string(nil), j.songIDs...), + }, nil +} + +// resolvePath maps a VibeSonic song ID to a playable filesystem path. +// The Subsonic handler supplies this since path resolution needs +// database + folder-permission context this package doesn't have. +type ResolveFunc func(songID string) (path string, err error) + +// Set replaces the entire jukebox playlist with songIDs and starts +// playing from the beginning. +func (j *Jukebox) Set(songIDs []string, resolve ResolveFunc) (State, error) { + if err := j.requireAvailable(); err != nil { + return State{}, err + } + j.mu.Lock() + defer j.mu.Unlock() + + if _, err := j.mpv.command("playlist-clear"); err != nil { + return State{}, err + } + if _, err := j.mpv.command("stop"); err != nil { + return State{}, err + } + j.songIDs = nil + + for i, id := range songIDs { + path, err := resolve(id) + if err != nil { + continue // skip songs we can't resolve rather than failing the whole playlist + } + mode := "append-play" + if i == 0 { + mode = "replace" + } + if _, err := j.mpv.command("loadfile", path, mode); err != nil { + continue + } + j.songIDs = append(j.songIDs, id) + } + + return j.stateLocked() +} + +// Add appends songIDs to the end of the current playlist. +func (j *Jukebox) Add(songIDs []string, resolve ResolveFunc) (State, error) { + if err := j.requireAvailable(); err != nil { + return State{}, err + } + j.mu.Lock() + defer j.mu.Unlock() + + for _, id := range songIDs { + path, err := resolve(id) + if err != nil { + continue + } + mode := "append-play" + if len(j.songIDs) == 0 { + mode = "replace" + } + if _, err := j.mpv.command("loadfile", path, mode); err != nil { + continue + } + j.songIDs = append(j.songIDs, id) + } + return j.stateLocked() +} + +// Clear empties the playlist and stops playback. +func (j *Jukebox) Clear() (State, error) { + if err := j.requireAvailable(); err != nil { + return State{}, err + } + j.mu.Lock() + defer j.mu.Unlock() + if _, err := j.mpv.command("playlist-clear"); err != nil { + return State{}, err + } + if _, err := j.mpv.command("stop"); err != nil { + return State{}, err + } + j.songIDs = nil + return j.stateLocked() +} + +// Remove removes the playlist entry at index. +func (j *Jukebox) Remove(index int) (State, error) { + if err := j.requireAvailable(); err != nil { + return State{}, err + } + j.mu.Lock() + defer j.mu.Unlock() + if index < 0 || index >= len(j.songIDs) { + return j.stateLocked() + } + if _, err := j.mpv.command("playlist-remove", index); err != nil { + return State{}, err + } + j.songIDs = append(j.songIDs[:index], j.songIDs[index+1:]...) + return j.stateLocked() +} + +// Shuffle reshuffles the playlist order and rebuilds mpv's queue to +// match. mpv's own playlist-shuffle command shuffles in place without +// reporting the resulting order back, so instead this reshuffles our +// song-ID tracking directly and rebuilds mpv's queue from it. +func (j *Jukebox) Shuffle(resolve ResolveFunc) (State, error) { + if err := j.requireAvailable(); err != nil { + return State{}, err + } + j.mu.Lock() + defer j.mu.Unlock() + + if len(j.songIDs) == 0 { + return j.stateLocked() + } + rand.Shuffle(len(j.songIDs), func(i, k int) { j.songIDs[i], j.songIDs[k] = j.songIDs[k], j.songIDs[i] }) + + if _, err := j.mpv.command("playlist-clear"); err != nil { + return State{}, err + } + if _, err := j.mpv.command("stop"); err != nil { + return State{}, err + } + kept := j.songIDs + j.songIDs = nil + for i, id := range kept { + path, err := resolve(id) + if err != nil { + continue + } + mode := "append-play" + if i == 0 { + mode = "replace" + } + if _, err := j.mpv.command("loadfile", path, mode); err != nil { + continue + } + j.songIDs = append(j.songIDs, id) + } + return j.stateLocked() +} + +// SkipTo jumps to playlist index and seeks offsetSecs into it. +func (j *Jukebox) SkipTo(index int, offsetSecs float64) (State, error) { + if err := j.requireAvailable(); err != nil { + return State{}, err + } + j.mu.Lock() + defer j.mu.Unlock() + if index < 0 || index >= len(j.songIDs) { + return j.stateLocked() + } + if _, err := j.mpv.command("playlist-play-index", index); err != nil { + return State{}, err + } + if offsetSecs > 0 { + _, _ = j.mpv.command("seek", offsetSecs, "absolute") + } + if err := j.mpv.setProperty("pause", false); err != nil { + return State{}, err + } + return j.stateLocked() +} + +// Start resumes/starts playback. +func (j *Jukebox) Start() (State, error) { + if err := j.requireAvailable(); err != nil { + return State{}, err + } + j.mu.Lock() + defer j.mu.Unlock() + if err := j.mpv.setProperty("pause", false); err != nil { + return State{}, err + } + return j.stateLocked() +} + +// Stop pauses playback. +func (j *Jukebox) Stop() (State, error) { + if err := j.requireAvailable(); err != nil { + return State{}, err + } + j.mu.Lock() + defer j.mu.Unlock() + if err := j.mpv.setProperty("pause", true); err != nil { + return State{}, err + } + return j.stateLocked() +} + +// SetGain sets playback volume, gain in [0.0, 1.0] mapped onto mpv's +// 0-100 volume property. +func (j *Jukebox) SetGain(gain float64) (State, error) { + if err := j.requireAvailable(); err != nil { + return State{}, err + } + if gain < 0 { + gain = 0 + } + if gain > 1 { + gain = 1 + } + j.mu.Lock() + defer j.mu.Unlock() + if err := j.mpv.setProperty("volume", gain*100); err != nil { + return State{}, err + } + j.gain = gain + return j.stateLocked() +} diff --git a/internal/jukebox/jukebox_test.go b/internal/jukebox/jukebox_test.go new file mode 100644 index 0000000..1740279 --- /dev/null +++ b/internal/jukebox/jukebox_test.go @@ -0,0 +1,216 @@ +package jukebox + +import ( + "context" + "os/exec" + "path/filepath" + "testing" + "time" +) + +func genFile(t *testing.T, dir, name string) string { + t.Helper() + if _, err := exec.LookPath("ffmpeg"); err != nil { + t.Skip("ffmpeg not installed") + } + out := filepath.Join(dir, name) + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + cmd := exec.CommandContext(ctx, "ffmpeg", "-f", "lavfi", "-i", "sine=frequency=440:duration=3", "-codec:a", "libmp3lame", out) + if combined, err := cmd.CombinedOutput(); err != nil { + t.Fatalf("ffmpeg: %v\n%s", err, combined) + } + return out +} + +func requireMPV(t *testing.T) { + t.Helper() + if _, err := exec.LookPath("mpv"); err != nil { + t.Skip("mpv not installed") + } +} + +func TestNewUnavailableWithoutMPV(t *testing.T) { + j := New("/nonexistent/mpv/binary") + if j.Available { + t.Fatal("Available = true for a nonexistent mpv binary") + } + if _, err := j.Status(); err != ErrUnavailable { + t.Fatalf("Status() error = %v, want ErrUnavailable", err) + } +} + +func TestJukeboxSetAndStatus(t *testing.T) { + requireMPV(t) + dir := t.TempDir() + song1 := genFile(t, dir, "song1.mp3") + song2 := genFile(t, dir, "song2.mp3") + + j := New("") + defer j.Close() + if !j.Available { + t.Fatal("expected jukebox to be available with mpv installed") + } + + paths := map[string]string{"s1": song1, "s2": song2} + resolve := func(id string) (string, error) { return paths[id], nil } + + state, err := j.Set([]string{"s1", "s2"}, resolve) + if err != nil { + t.Fatalf("Set: %v", err) + } + if len(state.SongIDs) != 2 { + t.Fatalf("SongIDs = %v, want 2", state.SongIDs) + } + + // Give mpv a beat to actually start playback. + time.Sleep(300 * time.Millisecond) + state, err = j.Status() + if err != nil { + t.Fatalf("Status: %v", err) + } + if !state.Playing { + t.Error("expected jukebox to be playing after Set") + } +} + +func TestJukeboxStartStop(t *testing.T) { + requireMPV(t) + dir := t.TempDir() + song1 := genFile(t, dir, "song1.mp3") + + j := New("") + defer j.Close() + resolve := func(id string) (string, error) { return song1, nil } + if _, err := j.Set([]string{"s1"}, resolve); err != nil { + t.Fatalf("Set: %v", err) + } + time.Sleep(200 * time.Millisecond) + + state, err := j.Stop() + if err != nil { + t.Fatalf("Stop: %v", err) + } + if state.Playing { + t.Error("expected paused after Stop") + } + + state, err = j.Start() + if err != nil { + t.Fatalf("Start: %v", err) + } + time.Sleep(200 * time.Millisecond) + state, err = j.Status() + if err != nil { + t.Fatalf("Status: %v", err) + } + if !state.Playing { + t.Error("expected playing after Start") + } +} + +func TestJukeboxAddClearRemove(t *testing.T) { + requireMPV(t) + dir := t.TempDir() + song1 := genFile(t, dir, "song1.mp3") + song2 := genFile(t, dir, "song2.mp3") + + j := New("") + defer j.Close() + paths := map[string]string{"s1": song1, "s2": song2} + resolve := func(id string) (string, error) { return paths[id], nil } + + if _, err := j.Set([]string{"s1"}, resolve); err != nil { + t.Fatalf("Set: %v", err) + } + state, err := j.Add([]string{"s2"}, resolve) + if err != nil { + t.Fatalf("Add: %v", err) + } + if len(state.SongIDs) != 2 { + t.Fatalf("SongIDs after Add = %v, want 2", state.SongIDs) + } + + state, err = j.Remove(0) + if err != nil { + t.Fatalf("Remove: %v", err) + } + if len(state.SongIDs) != 1 || state.SongIDs[0] != "s2" { + t.Fatalf("SongIDs after Remove(0) = %v, want [s2]", state.SongIDs) + } + + state, err = j.Clear() + if err != nil { + t.Fatalf("Clear: %v", err) + } + if len(state.SongIDs) != 0 { + t.Fatalf("SongIDs after Clear = %v, want empty", state.SongIDs) + } +} + +func TestJukeboxSetGain(t *testing.T) { + requireMPV(t) + dir := t.TempDir() + song1 := genFile(t, dir, "song1.mp3") + + j := New("") + defer j.Close() + resolve := func(id string) (string, error) { return song1, nil } + if _, err := j.Set([]string{"s1"}, resolve); err != nil { + t.Fatalf("Set: %v", err) + } + + state, err := j.SetGain(0.5) + if err != nil { + t.Fatalf("SetGain: %v", err) + } + if state.Gain != 0.5 { + t.Fatalf("Gain = %v, want 0.5", state.Gain) + } +} + +func TestJukeboxShuffle(t *testing.T) { + requireMPV(t) + dir := t.TempDir() + song1 := genFile(t, dir, "song1.mp3") + song2 := genFile(t, dir, "song2.mp3") + + j := New("") + defer j.Close() + paths := map[string]string{"s1": song1, "s2": song2} + resolve := func(id string) (string, error) { return paths[id], nil } + if _, err := j.Set([]string{"s1", "s2"}, resolve); err != nil { + t.Fatalf("Set: %v", err) + } + + state, err := j.Shuffle(resolve) + if err != nil { + t.Fatalf("Shuffle: %v", err) + } + if len(state.SongIDs) != 2 { + t.Fatalf("SongIDs after Shuffle = %v, want 2 entries preserved", state.SongIDs) + } +} + +func TestJukeboxSkipTo(t *testing.T) { + requireMPV(t) + dir := t.TempDir() + song1 := genFile(t, dir, "song1.mp3") + song2 := genFile(t, dir, "song2.mp3") + + j := New("") + defer j.Close() + paths := map[string]string{"s1": song1, "s2": song2} + resolve := func(id string) (string, error) { return paths[id], nil } + if _, err := j.Set([]string{"s1", "s2"}, resolve); err != nil { + t.Fatalf("Set: %v", err) + } + + state, err := j.SkipTo(1, 0) + if err != nil { + t.Fatalf("SkipTo: %v", err) + } + if state.CurrentIndex != 1 { + t.Fatalf("CurrentIndex = %d, want 1", state.CurrentIndex) + } +} diff --git a/internal/jukebox/mpv.go b/internal/jukebox/mpv.go new file mode 100644 index 0000000..6368c61 --- /dev/null +++ b/internal/jukebox/mpv.go @@ -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)) + } +}