552 lines
18 KiB
Python
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
|