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.
This commit is contained in:
1 parent
4bed90b489
commit
e30ca09627
44 files changed
+329
-192
No files matched your search
@@ -0,0 +1,620 @@
|
||||
"""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
|
||||
Reference in new issue
Block a user