Files
vibesonic/internal/jukebox/jukebox.go
T

316 lines
7.5 KiB
Go

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()
}