"""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