Files
gonic/jukebox/jukebox.go
2023-11-24 21:59:10 +00:00

441 lines
9.7 KiB
Go

// author: AlexKraak (https://github.com/alexkraak/)
// author: sentriz (https://github.com/sentriz/)
package jukebox
import (
"errors"
"fmt"
"os"
"os/exec"
"path/filepath"
"regexp"
"strconv"
"sync"
"time"
"github.com/dexterlb/mpvipc"
"github.com/mitchellh/mapstructure"
"golang.org/x/exp/slices"
)
var (
ErrMPVTimeout = fmt.Errorf("mpv not responding")
ErrMPVNeverStarted = fmt.Errorf("mpv never started")
ErrMPVTooOld = fmt.Errorf("mpv too old")
)
func MPVArg(k string, v any) string {
if v, ok := v.(bool); ok {
if v {
return fmt.Sprintf("%s=yes", k)
}
return fmt.Sprintf("%s=no", k)
}
return fmt.Sprintf("%s=%v", k, v)
}
type Jukebox struct {
cmd *exec.Cmd
conn *mpvipc.Connection
events <-chan *mpvipc.Event
mu sync.RWMutex
}
func New() *Jukebox {
return &Jukebox{}
}
func (j *Jukebox) Start(sockPath string, mpvExtraArgs []string) error {
const mpvName = "mpv"
if _, err := exec.LookPath(mpvName); err != nil {
return fmt.Errorf("look path: %w. did you forget to install it?", err)
}
var mpvArgs []string
mpvArgs = append(mpvArgs, "--idle", "--no-config", "--no-video", MPVArg("--audio-display", "no"), MPVArg("--input-ipc-server", sockPath))
mpvArgs = append(mpvArgs, mpvExtraArgs...)
j.cmd = exec.Command(mpvName, mpvArgs...)
if err := j.cmd.Start(); err != nil {
return fmt.Errorf("start mpv process: %w", err)
}
ok := waitUntil(5*time.Second, func() bool {
_, err := os.Stat(sockPath)
return err == nil
})
if !ok {
_ = j.cmd.Process.Kill()
return ErrMPVNeverStarted
}
j.conn = mpvipc.NewConnection(sockPath)
if err := j.conn.Open(); err != nil {
return fmt.Errorf("open connection: %w", err)
}
var mpvVersionStr string
if err := getDecode(j.conn, &mpvVersionStr, "mpv-version"); err != nil {
return fmt.Errorf("get mpv version: %w", err)
}
if major, minor, patch := parseMPVVersion(mpvVersionStr); major == 0 && minor < 34 {
return fmt.Errorf("%w: v0.34.0+ required, found v%d.%d.%d", ErrMPVTooOld, major, minor, patch)
}
if _, err := j.conn.Call("observe_property", 0, "seekable"); err != nil {
return fmt.Errorf("observe property: %w", err)
}
j.events, _ = j.conn.NewEventListener()
return nil
}
func (j *Jukebox) Wait() error {
var exitError *exec.ExitError
if err := j.cmd.Wait(); err != nil && !errors.As(err, &exitError) {
return fmt.Errorf("wait jukebox: %w", err)
}
return nil
}
func (j *Jukebox) GetPlaylist() ([]string, error) {
defer lockr(&j.mu)()
var playlist mpvPlaylist
if err := getDecode(j.conn, &playlist, "playlist"); err != nil {
return nil, fmt.Errorf("get playlist: %w", err)
}
var items []string
for _, item := range playlist {
items = append(items, item.Filename)
}
return items, nil
}
func (j *Jukebox) SetPlaylist(items []string) error {
defer lock(&j.mu)()
var playlist mpvPlaylist
if err := getDecode(j.conn, &playlist, "playlist"); err != nil {
return fmt.Errorf("get playlist: %w", err)
}
current, currentIndex := find(playlist, func(item mpvPlaylistItem) bool {
return item.Current
})
filteredItems, foundExistingTrack := filter(items, func(filename string) bool {
return filename != current.Filename
})
tmp, cleanup, err := tmp()
if err != nil {
return fmt.Errorf("create temp file: %w", err)
}
defer cleanup()
for _, item := range filteredItems {
item, _ = filepath.Abs(item)
fmt.Fprintln(tmp, item)
}
if !foundExistingTrack {
// easy case - a brand new set of tracks that we can overwrite
if _, err := j.conn.Call("loadlist", tmp.Name(), "replace"); err != nil {
return fmt.Errorf("load list: %w", err)
}
return nil
}
// not so easy, we need to clear the playlist except what's playing, load everything
// except for what we're playing, then move what's playing back to its original index
// clear all items except what's playing (will be at index 0)
if _, err := j.conn.Call("playlist-clear"); err != nil {
return fmt.Errorf("clear playlist: %w", err)
}
if _, err := j.conn.Call("loadlist", tmp.Name(), "append-play"); err != nil {
return fmt.Errorf("load list: %w", err)
}
if _, err := j.conn.Call("playlist-move", 0, currentIndex+1); err != nil {
return fmt.Errorf("playlist move: %w", err)
}
return nil
}
func (j *Jukebox) AppendToPlaylist(items []string) error {
defer lock(&j.mu)()
tmp, cleanup, err := tmp()
if err != nil {
return fmt.Errorf("create temp file: %w", err)
}
defer cleanup()
for _, item := range items {
fmt.Fprintln(tmp, item)
}
if _, err := j.conn.Call("loadlist", tmp.Name(), "append"); err != nil {
return fmt.Errorf("load list: %w", err)
}
return nil
}
func (j *Jukebox) RemovePlaylistIndex(i int) error {
defer lock(&j.mu)()
if _, err := j.conn.Call("playlist-remove", i); err != nil {
return fmt.Errorf("playlist remove: %w", err)
}
return nil
}
func (j *Jukebox) SkipToPlaylistIndex(i int, offsetSecs int) error {
defer lock(&j.mu)()
matchEventSeekable := func(e *mpvipc.Event) bool {
seekable, _ := e.Data.(bool)
return e.Name == "property-change" &&
e.ExtraData["name"] == "seekable" &&
seekable
}
if offsetSecs > 0 {
if err := j.conn.Set("pause", true); err != nil {
return fmt.Errorf("pause: %w", err)
}
}
if _, err := j.conn.Call("playlist-play-index", i); err != nil {
return fmt.Errorf("playlist play index: %w", err)
}
if offsetSecs > 0 {
if err := waitFor(j.events, matchEventSeekable); err != nil {
return fmt.Errorf("waiting for file load: %w", err)
}
if _, err := j.conn.Call("seek", offsetSecs, "absolute"); err != nil {
return fmt.Errorf("seek: %w", err)
}
if err := j.conn.Set("pause", false); err != nil {
return fmt.Errorf("play: %w", err)
}
}
return nil
}
func (j *Jukebox) ClearPlaylist() error {
defer lock(&j.mu)()
if _, err := j.conn.Call("playlist-clear"); err != nil {
return fmt.Errorf("seek: %w", err)
}
return nil
}
func (j *Jukebox) Pause() error {
defer lock(&j.mu)()
if err := j.conn.Set("pause", true); err != nil {
return fmt.Errorf("pause: %w", err)
}
return nil
}
func (j *Jukebox) Play() error {
defer lock(&j.mu)()
if err := j.conn.Set("pause", false); err != nil {
return fmt.Errorf("pause: %w", err)
}
return nil
}
func (j *Jukebox) SetVolumePct(v int) error {
defer lock(&j.mu)()
if err := j.conn.Set("volume", v); err != nil {
return fmt.Errorf("set volume: %w", err)
}
return nil
}
func (j *Jukebox) GetVolumePct() (float64, error) {
defer lockr(&j.mu)()
var volume float64
if err := getDecode(j.conn, &volume, "volume"); err != nil {
return 0, fmt.Errorf("get volume: %w", err)
}
return volume, nil
}
type Status struct {
CurrentIndex int
CurrentFilename string
Length int
Playing bool
GainPct int
Position int
}
func (j *Jukebox) GetStatus() (*Status, error) {
defer lockr(&j.mu)()
var status Status
_ = getDecode(j.conn, &status.Position, "time-pos") // property may not always be there
_ = getDecode(j.conn, &status.GainPct, "volume") // property may not always be there
var playlist mpvPlaylist
_ = getDecode(j.conn, &playlist, "playlist")
status.CurrentIndex = slices.IndexFunc(playlist, func(pl mpvPlaylistItem) bool {
return pl.Current
})
status.Length = len(playlist)
if status.CurrentIndex < 0 {
return &status, nil
}
status.CurrentFilename = playlist[status.CurrentIndex].Filename
var paused bool
_ = getDecode(j.conn, &paused, "pause") // property may not always be there
status.Playing = !paused
return &status, nil
}
func (j *Jukebox) Quit() error {
defer lock(&j.mu)()
if j.conn == nil || j.conn.IsClosed() {
return nil
}
go func() {
_, _ = j.conn.Call("quit")
}()
time.Sleep(250 * time.Millisecond)
_ = j.cmd.Process.Kill()
if err := j.conn.Close(); err != nil {
return fmt.Errorf("close: %w", err)
}
j.conn.WaitUntilClosed()
return nil
}
func getDecode(conn *mpvipc.Connection, dest any, property string) error {
raw, err := conn.Get(property)
if err != nil {
return fmt.Errorf("get property: %w", err)
}
if err := mapstructure.Decode(raw, dest); err != nil {
return fmt.Errorf("decode: %w", err)
}
return nil
}
type (
mpvPlaylist []mpvPlaylistItem
mpvPlaylistItem struct {
ID int
Filename string
Current bool
Playing bool
}
)
func waitUntil(timeout time.Duration, f func() bool) bool {
quit := time.NewTicker(timeout)
defer quit.Stop()
check := time.NewTicker(100 * time.Millisecond)
defer check.Stop()
for {
select {
case <-quit.C:
return false
case <-check.C:
if f() {
return true
}
}
}
}
func waitFor[T any](ch <-chan T, match func(e T) bool) error {
quit := time.NewTicker(1 * time.Second)
defer quit.Stop()
defer time.Sleep(350 * time.Millisecond)
for {
select {
case <-quit.C:
return ErrMPVTimeout
case ev := <-ch:
if match(ev) {
return nil
}
}
}
}
func tmp() (*os.File, func(), error) {
tmp, err := os.CreateTemp("", "")
if err != nil {
return nil, nil, fmt.Errorf("create temp file: %w", err)
}
cleanup := func() {
os.Remove(tmp.Name())
tmp.Close()
}
return tmp, cleanup, nil
}
func find[T any](items []T, f func(T) bool) (T, int) {
for i, item := range items {
if f(item) {
return item, i
}
}
var t T
return t, -1
}
func filter[T comparable](items []T, f func(T) bool) ([]T, bool) {
var found bool
var ret []T
for _, item := range items {
if !f(item) {
found = true
continue
}
ret = append(ret, item)
}
return ret, found
}
func lock(mu *sync.RWMutex) func() {
mu.Lock()
return mu.Unlock
}
func lockr(mu *sync.RWMutex) func() {
mu.RLock()
return mu.RUnlock
}
var mpvVersionExpr = regexp.MustCompile(`mpv\s(\d+)\.(\d+)\.(\d+)`)
func parseMPVVersion(version string) (major, minor, patch int) {
m := mpvVersionExpr.FindStringSubmatch(version)
if len(m) != 4 {
return
}
major, _ = strconv.Atoi(m[1])
minor, _ = strconv.Atoi(m[2])
patch, _ = strconv.Atoi(m[3])
return
}