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