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 // Volume returns current volume level (0-100). Volume() int } // 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 } func (p *legacyPlayer) Volume() int { return 70 } // 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 vol int } 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"}}) p.send(map[string]any{"command": []any{"observe_property", 5, "volume"}}) // get initial volume p.send(map[string]any{"command": []any{"get_property", "volume"}}) 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" { 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 } case 5: if f, ok := data.(float64); ok { p.vol = int(f + 0.5) } } p.mu.Unlock() continue } // handle get_property response for initial volume if data, ok := msg["data"]; ok { if f, ok := data.(float64); ok { if req, _ := msg["request_id"].(float64); req == 0 { // rough p.mu.Lock() p.vol = int(f + 0.5) 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) Volume() int { p.mu.Lock() defer p.mu.Unlock() return p.vol } 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 }