Files
gonic/jukebox/jukebox.go
2024-03-11 19:21:05 +01:00

410 lines
9.1 KiB
Go

// author: AlexKraak (https://github.com/alexkraak/)
// author: sentriz (https://github.com/sentriz/)
package jukebox
import (
"context"
"errors"
"fmt"
"os"
"os/exec"
"path/filepath"
"regexp"
"strconv"
"strings"
"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
mu sync.RWMutex
}
func New() *Jukebox {
return &Jukebox{}
}
func (j *Jukebox) Start(ctx context.Context, 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.CommandContext(ctx, 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)
}
var exitError *exec.ExitError
if err := j.cmd.Wait(); err != nil && !errors.As(err, &exitError) {
return fmt.Errorf("wait jukebox: %w", err)
}
if err := j.conn.Close(); err != nil {
return fmt.Errorf("close: %w", err)
}
j.conn.WaitUntilClosed()
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)
}
currentPlayingIndex := slices.IndexFunc(playlist, func(pl mpvPlaylistItem) bool {
return pl.Current
})
tmp, cleanup, err := tmp()
if err != nil {
return fmt.Errorf("create temp file: %w", err)
}
defer cleanup()
var newPlayingIndex = -1
for i, item := range items {
if !strings.HasPrefix(item, "http") {
item, _ = filepath.Abs(item)
}
if currentPlayingIndex >= 0 && playlist[currentPlayingIndex].Filename == item {
newPlayingIndex = i
continue // don't add current track to loadlist
}
fmt.Fprintln(tmp, item)
}
if newPlayingIndex < 0 {
if _, err := j.conn.Call("loadlist", tmp.Name(), "replace"); err != nil {
return fmt.Errorf("load list: %w", err)
}
return nil
}
if _, err := j.conn.Call("playlist-clear"); err != nil { // leaves current at index 0
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 newPlayingIndex > 0 {
if _, err := j.conn.Call("playlist-move", 0, newPlayingIndex+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)
}
}
ch, stop := j.conn.NewEventListener()
defer close(stop)
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(ch, 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 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()
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 lock(mu *sync.RWMutex) func() {
mu.Lock()
return func() {
// let mpv "settle" since it seems to return from ipc calls before everything is sorted out
time.Sleep(200 * time.Millisecond)
mu.Unlock()
}
}
func lockr(mu *sync.RWMutex) func() {
mu.RLock()
return mu.RUnlock
}
var mpvVersionExpr = regexp.MustCompile(`mpv\sv?(\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
}