gostations/internal/player/player.go
Greg Gauthier 2b688569c7
Some checks failed
gobuild / build (push) Failing after 6s
feat: add mpv IPC player with playback controls and winamp-style UI
Implement mpv JSON IPC backend for non-blocking playback, streamed
metadata (media-title/icy-title), and runtime controls (pause, mute,
volume, next/prev). Extend Player interface and wire a two-stage TUI
that switches to a dedicated playback view with keyboard shortcuts and
a styled hint bar. Fallback to legacy player when mpv is unavailable.
2026-06-05 23:13:01 +01:00

321 lines
8.2 KiB
Go

package player
import (
"bufio"
"encoding/json"
"fmt"
"net"
"os"
"os/exec"
"path/filepath"
"runtime"
"strings"
"sync"
"time"
)
// Player abstracts execution of a media player for a stream URL.
// Implementations may use exec (legacy) or IPC (preferred for mpv).
type Player interface {
// Play starts playback of the given URL. For long-running players this blocks
// until the player exits (or returns immediately for background/IPC impls).
Play(url string, extraArgs ...string) error
Stop() error // best effort
// Metadata returns the latest stream/song title observed (e.g. from icy or media-title).
// Returns "" if unknown or not supported by the impl.
Metadata() string
// Control methods. Best-effort; return nil if unsupported.
Pause() error
Resume() error
Mute() error
Unmute() error
Next() error // e.g. playlist-next or station skip; may be no-op for single stream
Prev() error
// Volume controls (relative, best effort; mpv uses "add volume ±5" etc.)
VolumeUp() error
VolumeDown() error
}
// IsInstalled reports whether the named executable is on PATH.
// Fixed to avoid shell injection (no "/bin/sh -c 'command -v ' + name").
func IsInstalled(name string) bool {
if name == "" {
return false
}
// Direct lookpath is best; fall back to exec the name with a harmless arg.
if _, err := exec.LookPath(name); err == nil {
return true
}
// Some players may be scripts without direct lookpath in certain envs; try exec --version like.
cmd := exec.Command(name, "--version")
_ = cmd.Run() // ignore output/err; if it started at all, likely present
// More reliable: use LookPath on common variants? For now LookPath is primary.
return false
}
// NewLegacy returns a simple exec-based player (stdio inheritance for interactive controls).
// This is the cleaned version of the old subExecute pattern.
func NewLegacy(program string, baseArgs ...string) Player {
return &legacyPlayer{program: program, baseArgs: baseArgs}
}
type legacyPlayer struct {
program string
baseArgs []string
cmd *exec.Cmd
}
func (p *legacyPlayer) Play(url string, extra ...string) error {
args := append([]string{}, p.baseArgs...)
args = append(args, extra...)
args = append(args, url)
p.cmd = exec.Command(p.program, args...)
p.cmd.Stdin = os.Stdin
p.cmd.Stdout = os.Stdout
p.cmd.Stderr = os.Stderr
if err := p.cmd.Run(); err != nil {
// For interactive players the err is often just "exit status N" from 'q'.
// Surface it but don't treat as fatal for the app.
fmt.Printf("(player exited: %v)\n", err)
}
return nil // we intentionally do not try CombinedOutput after Run
}
func (p *legacyPlayer) Stop() error {
if p.cmd != nil && p.cmd.Process != nil {
_ = p.cmd.Process.Kill()
}
return nil
}
func (p *legacyPlayer) Metadata() string { return "" }
func (p *legacyPlayer) Pause() error { return nil }
func (p *legacyPlayer) Resume() error { return nil }
func (p *legacyPlayer) Mute() error { return nil }
func (p *legacyPlayer) Unmute() error { return nil }
func (p *legacyPlayer) Next() error { return nil }
func (p *legacyPlayer) Prev() error { return nil }
func (p *legacyPlayer) VolumeUp() error { return nil }
func (p *legacyPlayer) VolumeDown() error { return nil }
// isShellMeta reports if name looks like it contains shell metachars (defense in depth).
func isShellMeta(name string) bool {
return strings.ContainsAny(name, ";|&`$(){}[]<>\n\r\t ")
}
// isInstalledLegacy is the old body (kept only for reference / windows where.exe).
// New code should use the top-level IsInstalled.
func isInstalledLegacy(name string) bool {
if runtime.GOOS == "windows" {
cmd := exec.Command("where.exe", name)
return cmd.Run() == nil
}
if isShellMeta(name) {
return false // refuse obviously bad names
}
cmd := exec.Command("/bin/sh", "-c", "command -v "+name) // still used on some old paths; prefer LookPath above
return cmd.Run() == nil
}
// NewMpv returns an mpv-based player using JSON IPC over a unix socket for
// non-blocking background playback, control (pause/mute/etc), and streamed
// metadata observation (media-title / metadata). Falls back gracefully to
// audio-only if IPC socket cannot be connected (still starts the mpv process).
func NewMpv(program string, baseArgs ...string) Player {
return &mpvPlayer{
program: program,
baseArgs: baseArgs,
}
}
type mpvPlayer struct {
program string
baseArgs []string
socket string
cmd *exec.Cmd
conn net.Conn
mu sync.Mutex
title string
paused bool
muted bool
}
func (p *mpvPlayer) Play(url string, extra ...string) error {
// unique socket per run
p.socket = filepath.Join(os.TempDir(), fmt.Sprintf("gostations-mpv-%d-%d.sock", os.Getpid(), time.Now().UnixNano()))
_ = os.Remove(p.socket)
args := append([]string{}, p.baseArgs...)
args = append(args, extra...)
args = append(args,
"--no-terminal",
"--really-quiet",
"--vo=null",
"--input-ipc-server="+p.socket,
"--idle=no",
url,
)
p.cmd = exec.Command(p.program, args...)
// detached: no stdio attach so TUI remains responsive
if err := p.cmd.Start(); err != nil {
return err
}
// give mpv a moment to create the socket
time.Sleep(150 * time.Millisecond)
for i := 0; i < 30; i++ {
if _, err := os.Stat(p.socket); err == nil {
break
}
time.Sleep(30 * time.Millisecond)
}
conn, err := net.DialTimeout("unix", p.socket, 500*time.Millisecond)
if err != nil {
// audio may still be playing; controls/metadata unavailable
return nil
}
p.conn = conn
// observe properties we care about
p.send(map[string]any{"command": []any{"observe_property", 1, "media-title"}})
p.send(map[string]any{"command": []any{"observe_property", 2, "metadata"}})
p.send(map[string]any{"command": []any{"observe_property", 3, "pause"}})
p.send(map[string]any{"command": []any{"observe_property", 4, "mute"}})
go p.readLoop()
return nil
}
func (p *mpvPlayer) send(cmd map[string]any) {
if p.conn == nil {
return
}
b, _ := json.Marshal(cmd)
_, _ = p.conn.Write(append(b, '\n'))
}
func (p *mpvPlayer) readLoop() {
sc := bufio.NewScanner(p.conn)
for sc.Scan() {
var msg map[string]any
if json.Unmarshal(sc.Bytes(), &msg) != nil {
continue
}
if msg["event"] != "property-change" {
continue
}
id, _ := msg["id"].(float64)
data := msg["data"]
p.mu.Lock()
switch id {
case 1:
if s, ok := data.(string); ok {
p.title = s
}
case 2:
if m, ok := data.(map[string]any); ok {
if t, ok := m["title"].(string); ok && t != "" {
p.title = t
} else if t, ok := m["icy-title"].(string); ok && t != "" {
p.title = t
}
}
case 3:
if b, ok := data.(bool); ok {
p.paused = b
}
case 4:
if b, ok := data.(bool); ok {
p.muted = b
}
}
p.mu.Unlock()
}
}
func (p *mpvPlayer) Metadata() string {
p.mu.Lock()
defer p.mu.Unlock()
return p.title
}
func (p *mpvPlayer) Pause() error {
p.mu.Lock()
p.paused = true
p.mu.Unlock()
p.send(map[string]any{"command": []any{"set_property", "pause", true}})
return nil
}
func (p *mpvPlayer) Resume() error {
p.mu.Lock()
p.paused = false
p.mu.Unlock()
p.send(map[string]any{"command": []any{"set_property", "pause", false}})
return nil
}
func (p *mpvPlayer) Mute() error {
p.mu.Lock()
p.muted = true
p.mu.Unlock()
p.send(map[string]any{"command": []any{"set_property", "mute", true}})
return nil
}
func (p *mpvPlayer) Unmute() error {
p.mu.Lock()
p.muted = false
p.mu.Unlock()
p.send(map[string]any{"command": []any{"set_property", "mute", false}})
return nil
}
func (p *mpvPlayer) Next() error {
p.send(map[string]any{"command": []any{"playlist-next"}})
return nil
}
func (p *mpvPlayer) Prev() error {
p.send(map[string]any{"command": []any{"playlist-prev"}})
return nil
}
func (p *mpvPlayer) VolumeUp() error {
p.send(map[string]any{"command": []any{"add", "volume", 5}})
return nil
}
func (p *mpvPlayer) VolumeDown() error {
p.send(map[string]any{"command": []any{"add", "volume", -5}})
return nil
}
func (p *mpvPlayer) Stop() error {
if p.conn != nil {
p.send(map[string]any{"command": []any{"quit"}})
_ = p.conn.Close()
p.conn = nil
}
if p.cmd != nil && p.cmd.Process != nil {
_ = p.cmd.Process.Kill()
p.cmd = nil
}
_ = os.Remove(p.socket)
p.mu.Lock()
p.title = ""
p.paused = false
p.muted = false
p.mu.Unlock()
return nil
}