Files
beefy-player/somafm_tui/player.py
T

552 lines
18 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 threading
import time
from collections.abc import Callable
from dataclasses import dataclass
from pathlib import Path
from typing import Any
from .api import USER_AGENT
from .config import RUNTIME_DIR
@dataclass
class PlayerEvent:
kind: str = "state" # "state" | "volume" | "notice"
state: str = ""
detail: str = ""
volume: int | None = None
url: str = ""
class Player:
def __init__(self) -> None:
self.events: queue.Queue[PlayerEvent] = queue.Queue()
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="somafm-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=somafm-tui",
f"--user-agent={USER_AGENT}",
"--network-timeout=20",
]
extra = os.environ.get("SOMAFM_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()
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 == "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 _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