Add real mpv-backed jukebox controller (JSON IPC), tested against real mpv
This commit is contained in:
@@ -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()
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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