Files
beefcarver e30ca09627 refactor!: rename somafm-tui to beefy-player
The app is now a general internet-radio player rather than a SomaFM client, so the package, console script, MPRIS identity, mpv namespaces, macOS tap names, XDG directories and docs all move to beefy-player.

Environment variables are now BEEFY_* (the old SOMAFM_* spellings are still honoured) and an existing ~/.config/somafm-tui/config.json is migrated on first run, so favourites and volume survive the rename.

The visible interface is untouched: same layout, same logo, same keys.
2026-11-03 16:10:25 -05:00

621 lines
21 KiB
Python

"""Audio playback.
Primary backend is **mpv** driven over its JSON IPC socket (play/pause/volume/
mirror failover/stop). If mpv is missing we fall back to **ffplay**, which can
still play but offers no live control beyond SIGSTOP/SIGCONT.
The player runs in its own thread with a private asyncio loop. The UI talks to
it through :meth:`Player.play` / :meth:`Player.pause` / ... (thread safe) and
reads :attr:`Player.events` (a plain ``queue.Queue``) from its ticker.
"""
from __future__ import annotations
import asyncio
import contextlib
import json
import os
import queue
import shlex
import shutil
import signal
import subprocess
import sys
import threading
import time
from collections.abc import Callable
from dataclasses import dataclass
from pathlib import Path
from typing import Any
from .config import RUNTIME_DIR, USER_AGENT, atomic_write_text, env_var
#: mpv input bindings that turn the system media keys into TUI commands.
#:
#: On macOS mpv's Cocoa backend implements MPRemoteCommandCenter, so the
#: hardware/keyboard media keys arrive as these special key names. Instead of
#: letting mpv handle them (which would fight the TUI and desync the station
#: list, the spectrum and the desktop "now playing" state) we bounce them back
#: over the IPC socket as ``client-message`` events.
MEDIA_KEY_BINDINGS = """\
# Generated by beefy-player -- do not edit; it is rewritten on every launch.
PLAY script-message beefy toggle
PLAYONLY script-message beefy play
PAUSEONLY script-message beefy pause
STOP script-message beefy stop
NEXT script-message beefy next
PREV script-message beefy prev
"""
#: Commands a media key may ask for; everything else is ignored.
MEDIA_COMMANDS = frozenset({"play", "pause", "toggle", "stop", "next", "prev"})
#: Prefix of the ``client-message`` namespace we listen on.
MEDIA_MESSAGE_PREFIX = "beefy"
@dataclass
class PlayerEvent:
kind: str = "state" # "state" | "volume" | "notice" | "media"
state: str = ""
detail: str = ""
volume: int | None = None
url: str = ""
class Player:
def __init__(self, media_keys: bool = True) -> None:
self.events: queue.Queue[PlayerEvent] = queue.Queue()
self.media_keys = media_keys
self.backend: str | None = None
self._loop: asyncio.AbstractEventLoop | None = None
self._thread: threading.Thread | None = None
self._ready = threading.Event()
self._stopping = threading.Event()
self._proc: subprocess.Popen[bytes] | None = None
self._reader: asyncio.StreamReader | None = None
self._writer: asyncio.StreamWriter | None = None
self._urls: list[str] = []
self._url_index = 0
self._volume = 70
self._paused = False
self._has_file = False
self._state = "stopped"
self._sock_path: Path | None = None
self._ffplay_proc: subprocess.Popen[bytes] | None = None
self._retry_scheduled = False
# ------------------------------------------------------------------ #
# setup
# ------------------------------------------------------------------ #
@staticmethod
def detect_backend() -> str | None:
if shutil.which("mpv"):
return "mpv"
if shutil.which("ffplay"):
return "ffplay"
return None
def start(self) -> None:
if self._thread is not None:
return
self.backend = self.detect_backend()
if self.backend is None:
self._state = "error"
self.events.put(
PlayerEvent(
kind="state",
state="error",
detail="no audio backend found — install mpv or ffmpeg/ffplay",
)
)
return
self._thread = threading.Thread(
target=self._thread_main, name="beefy-player", daemon=True
)
self._thread.start()
self._ready.wait(10)
def _thread_main(self) -> None:
loop = asyncio.new_event_loop()
asyncio.set_event_loop(loop)
self._loop = loop
self._ready.set()
try:
loop.run_until_complete(self._run())
except Exception as exc: # pragma: no cover - defensive
self.events.put(
PlayerEvent(kind="state", state="error", detail=f"player stopped: {exc}")
)
finally:
try:
pending = asyncio.all_tasks(loop)
for task in pending:
task.cancel()
if pending:
loop.run_until_complete(
asyncio.gather(*pending, return_exceptions=True)
)
except Exception:
pass
loop.close()
self._loop = None
async def _run(self) -> None:
if self.backend == "mpv":
await self._run_mpv()
else:
await self._run_ffplay()
# ------------------------------------------------------------------ #
# public, thread safe API
# ------------------------------------------------------------------ #
def play(self, urls: list[str]) -> None:
if not urls:
self._set_state("error", "no playable stream found")
return
self._submit(self._do_play, list(urls))
def pause(self) -> None:
self._submit(self._do_pause)
def resume(self) -> None:
self._submit(self._do_resume)
def toggle_pause(self) -> None:
self._submit(self._do_toggle)
def stop(self) -> None:
self._submit(self._do_stop)
def set_volume(self, value: int) -> None:
self._submit(self._do_volume, int(value))
def shutdown(self, timeout: float = 3.0) -> None:
if self._stopping.is_set():
return
self._stopping.set()
loop = self._loop
if loop is None or loop.is_closed():
return
try:
future = asyncio.run_coroutine_threadsafe(self._do_shutdown(), loop)
future.result(timeout)
except Exception:
pass
@property
def state(self) -> str:
return self._state
# ------------------------------------------------------------------ #
# internals
# ------------------------------------------------------------------ #
def _submit(self, factory: Callable[..., Any], *args: Any) -> None:
loop = self._loop
if loop is None or loop.is_closed() or self._stopping.is_set():
return
with contextlib.suppress(RuntimeError):
loop.call_soon_threadsafe(lambda: asyncio.ensure_future(factory(*args)))
def _set_state(self, state: str, detail: str = "", url: str = "") -> None:
self._state = state
self.events.put(
PlayerEvent(kind="state", state=state, detail=detail, url=url)
)
def _current_url(self) -> str:
if 0 <= self._url_index < len(self._urls):
return self._urls[self._url_index]
return ""
# ----- commands ---------------------------------------------------- #
async def _do_play(self, urls: list[str]) -> None:
self._urls = urls
self._url_index = 0
self._paused = False
self._has_file = False
self._retry_scheduled = False
if self.backend == "mpv":
await self._mpv_load()
else:
await self._kill_ffplay()
async def _do_pause(self) -> None:
if self.backend == "mpv":
await self._mpv_send(["set_property", "pause", True])
elif self.backend == "ffplay" and self._ffplay_proc is not None:
self._ffplay_signal(signal.SIGSTOP)
self._paused = True
self._set_state("paused", "", self._current_url())
async def _do_resume(self) -> None:
if self.backend == "mpv":
await self._mpv_send(["set_property", "pause", False])
elif self.backend == "ffplay" and self._ffplay_proc is not None:
self._ffplay_signal(signal.SIGCONT)
self._paused = False
self._set_state("playing", "", self._current_url())
async def _do_toggle(self) -> None:
if self._paused:
await self._do_resume()
else:
await self._do_pause()
async def _do_stop(self) -> None:
self._urls = []
self._url_index = 0
self._paused = False
self._has_file = False
if self.backend == "mpv":
await self._mpv_send(["stop"])
else:
await self._kill_ffplay()
self._set_state("stopped")
async def _do_volume(self, value: int) -> None:
value = max(0, min(100, value))
self._volume = value
if self.backend == "mpv":
await self._mpv_send(["set_property", "volume", value])
self.events.put(PlayerEvent(kind="volume", volume=value))
async def _do_shutdown(self) -> None:
if self.backend == "mpv":
await self._mpv_send(["quit"])
await self._kill_ffplay()
await self._stop_mpv()
# ----- mpv --------------------------------------------------------- #
async def _run_mpv(self) -> None:
while not self._stopping.is_set():
try:
await self._start_mpv()
except Exception as exc:
if self._stopping.is_set():
return
self._set_state("error", f"mpv failed to start: {exc}")
await asyncio.sleep(3.0)
continue
try:
await self._read_mpv()
except (OSError, ValueError, ConnectionError, asyncio.IncompleteReadError):
pass
finally:
await self._stop_mpv()
if self._stopping.is_set():
return
self._set_state("buffering", "restarting audio engine…")
await asyncio.sleep(1.5)
async def _start_mpv(self) -> None:
RUNTIME_DIR.mkdir(parents=True, exist_ok=True)
self._sock_path = RUNTIME_DIR / f"mpv-{os.getpid()}.sock"
with contextlib.suppress(OSError):
self._sock_path.unlink()
command = [
"mpv",
"--no-config",
"--no-video",
"--really-quiet",
"--no-terminal",
"--idle=yes",
"--force-window=no",
f"--input-ipc-server={self._sock_path}",
f"--volume={self._volume}",
"--cache=yes",
"--cache-secs=20",
"--demuxer-max-bytes=32MiB",
"--audio-client-name=beefy-player",
f"--user-agent={USER_AGENT}",
"--network-timeout=20",
]
command.extend(self._platform_options())
extra = env_var("MPV_ARGS")
if extra:
command.extend(shlex.split(extra))
self._proc = subprocess.Popen(
command,
stdin=subprocess.DEVNULL,
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
start_new_session=True,
)
deadline = time.monotonic() + 6.0
while True:
if self._stopping.is_set():
raise RuntimeError("shutting down")
if self._proc.poll() is not None:
raise RuntimeError(f"mpv exited with code {self._proc.returncode}")
try:
self._reader, self._writer = await asyncio.open_unix_connection(
str(self._sock_path)
)
break
except (FileNotFoundError, ConnectionRefusedError, OSError):
if time.monotonic() > deadline:
raise RuntimeError(
"timed out waiting for mpv IPC socket"
) from None
await asyncio.sleep(0.05)
await self._mpv_send(["observe_property", 1, "pause"])
await self._mpv_send(["observe_property", 2, "volume"])
if self._urls:
await self._mpv_load()
def _platform_options(self, platform: str | None = None) -> list[str]:
"""mpv options that only exist (or only make sense) on some platforms.
Keeping them here means the Linux command line stays exactly what it
always was -- mpv aborts on unknown options, so never pass a macOS-only
flag unconditionally.
"""
system = sys.platform if platform is None else platform
if system != "darwin":
return []
return self._macos_media_key_options()
def _macos_media_key_options(self) -> list[str]:
"""Let mpv publish Now Playing, and route its media keys into the TUI."""
if not self.media_keys:
return ["--input-media-keys=no"]
options = ["--input-media-keys=yes"]
try:
RUNTIME_DIR.mkdir(parents=True, exist_ok=True)
conf = RUNTIME_DIR / "mpv-input.conf"
atomic_write_text(conf, MEDIA_KEY_BINDINGS)
except OSError:
# Now Playing still works; only next/prev/stop stay inside mpv.
return options
return [*options, f"--input-conf={conf}"]
async def _mpv_send(self, command: list[Any]) -> None:
writer = self._writer
if writer is None:
return
try:
writer.write((json.dumps({"command": command}) + "\n").encode("utf-8"))
await writer.drain()
except (OSError, RuntimeError):
pass
async def _mpv_load(self) -> None:
url = self._current_url()
if not url:
return
self._has_file = False
await self._mpv_send(["loadfile", url, "replace"])
await self._mpv_send(["set_property", "pause", False])
self._paused = False
self._set_state("loading", "tuning in…", url)
async def _read_mpv(self) -> None:
reader = self._reader
if reader is None:
return
while not self._stopping.is_set():
line = await reader.readline()
if not line:
return
try:
message = json.loads(line)
except ValueError:
continue
if isinstance(message, dict):
self._handle_mpv_message(message)
def _handle_mpv_message(self, message: dict[str, Any]) -> None:
event = message.get("event")
if event == "client-message":
self._handle_client_message(message.get("args"))
return
if event == "property-change":
name = message.get("name")
value = message.get("data")
if name == "pause":
self._paused = bool(value)
if self._has_file:
state = "paused" if self._paused else "playing"
self._set_state(state, "", self._current_url())
elif name == "volume" and isinstance(value, (int, float)):
self._volume = max(0, min(100, round(value)))
self.events.put(PlayerEvent(kind="volume", volume=self._volume))
return
if event == "start-file":
self._has_file = True
self._set_state("loading", "buffering stream…", self._current_url())
return
if event == "file-loaded":
self._has_file = True
state = "paused" if self._paused else "playing"
self._set_state(state, "", self._current_url())
return
if event == "end-file":
self._has_file = False
reason = str(message.get("reason") or "")
if reason == "stop" or self._stopping.is_set():
self._set_state("stopped")
elif reason in ("eof", "error"):
self._schedule_retry(reason)
return
if event == "idle":
if not self._urls:
self._set_state("stopped")
return
error = message.get("error")
if error and error != "success":
self.events.put(PlayerEvent(kind="notice", detail=str(error)))
def _handle_client_message(self, args: Any) -> None:
"""Turn ``script-message beefy <command>`` into a player event.
mpv's macOS backend feeds the system media keys (and Control Center
buttons) into its input system; ``MEDIA_KEY_BINDINGS`` maps them onto
these messages, which arrive over the JSON IPC socket.
"""
if not isinstance(args, list) or len(args) < 2:
return
namespace, command = str(args[0]), str(args[1])
if namespace != MEDIA_MESSAGE_PREFIX or command not in MEDIA_COMMANDS:
return
self.events.put(PlayerEvent(kind="media", detail=command))
def _schedule_retry(self, reason: str) -> None:
if self._retry_scheduled or not self._urls or self._stopping.is_set():
return
self._retry_scheduled = True
self._submit(self._retry_stream, reason)
async def _retry_stream(self, reason: str) -> None:
self._retry_scheduled = False
if not self._urls or self._stopping.is_set():
return
if self._url_index + 1 >= len(self._urls):
self._set_state(
"error", f"stream failed ({reason}) — press enter to retry"
)
return
self._url_index += 1
self._set_state(
"buffering",
f"reconnecting via mirror {self._url_index + 1}/{len(self._urls)}…",
self._current_url(),
)
await asyncio.sleep(1.0)
if self.backend == "mpv":
await self._mpv_load()
else:
await self._kill_ffplay()
async def _stop_mpv(self) -> None:
writer = self._writer
self._writer = None
self._reader = None
if writer is not None:
try:
writer.close()
await writer.wait_closed()
except Exception:
pass
proc = self._proc
self._proc = None
if proc is not None:
try:
if proc.poll() is None:
proc.terminate()
try:
await asyncio.to_thread(proc.wait, 2)
except Exception:
proc.kill()
except Exception:
pass
if self._sock_path is not None:
with contextlib.suppress(OSError):
self._sock_path.unlink()
self._sock_path = None
# ----- ffplay fallback --------------------------------------------- #
async def _run_ffplay(self) -> None:
while not self._stopping.is_set():
if not self._urls:
if self._ffplay_proc is not None:
await self._kill_ffplay()
if self._state != "stopped":
self._set_state("stopped")
await asyncio.sleep(0.2)
continue
if self._ffplay_proc is None:
self._start_ffplay()
elif self._ffplay_proc.poll() is not None:
self._ffplay_proc = None
if not self._paused and not self._stopping.is_set():
if self._url_index + 1 < len(self._urls):
self._url_index += 1
self._set_state(
"buffering",
f"reconnecting via mirror {self._url_index + 1}/{len(self._urls)}…",
self._current_url(),
)
else:
self._set_state(
"error", "stream ended — press enter to retry"
)
self._urls = []
await asyncio.sleep(0.2)
def _start_ffplay(self) -> None:
url = self._current_url()
if not url:
return
self._set_state("loading", "tuning in…", url)
command = [
"ffplay",
"-nodisp",
"-autoexit",
"-loglevel",
"quiet",
"-volume",
str(self._volume),
url,
]
try:
self._ffplay_proc = subprocess.Popen(
command,
stdin=subprocess.DEVNULL,
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
start_new_session=True,
)
except OSError as exc:
self._set_state("error", f"ffplay failed: {exc}")
self._urls = []
return
self._paused = False
self._set_state(
"playing", "ffplay fallback (volume changes apply on next tune-in)", url
)
def _ffplay_signal(self, sig: int) -> None:
proc = self._ffplay_proc
if proc is not None and proc.poll() is None:
with contextlib.suppress(OSError):
proc.send_signal(sig)
async def _kill_ffplay(self) -> None:
proc = self._ffplay_proc
self._ffplay_proc = None
if proc is None:
return
try:
if proc.poll() is None:
proc.send_signal(signal.SIGCONT)
proc.terminate()
try:
await asyncio.to_thread(proc.wait, 1)
except Exception:
proc.kill()
except OSError:
pass