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()
|
||||
}
|
||||
Reference in New Issue
Block a user