engine.py 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350
  1. """Drive an external ``mpv`` process from Python over its JSON IPC unix socket.
  2. The whole playback backend is a single mpv subprocess spawned with
  3. ``--terminal=no --really-quiet --input-ipc-server=<socket>`` and controlled
  4. purely through JSON lines. No Python dependency beyond the system mpv binary;
  5. status (position / length / state), seeking, pause, mute and volume all come
  6. from the official mpv client API.
  7. """
  8. from __future__ import annotations
  9. import dataclasses
  10. import json
  11. import os
  12. import shutil
  13. import select
  14. import socket
  15. import subprocess
  16. import tempfile
  17. import time
  18. from dataclasses import dataclass
  19. from pathlib import Path
  20. class EngineError(RuntimeError):
  21. """The playback backend failed at runtime."""
  22. class MpvNotFoundError(EngineError):
  23. pass
  24. def find_mpv(binary: str | None = None) -> str:
  25. """Locate an executable mpv. Raises MpvNotFoundError with install hints."""
  26. candidates: list[Path] = []
  27. if binary:
  28. candidates.append(Path(binary).expanduser())
  29. p = shutil.which("mpv")
  30. if p:
  31. candidates.append(Path(p))
  32. for d in (Path.home() / ".local" / "bin", Path("/usr/local/bin"), Path("/opt")):
  33. if d.exists():
  34. for c in sorted(d.glob("mpv")):
  35. if c.is_file():
  36. candidates.append(c)
  37. for c in candidates:
  38. try:
  39. if c.is_file() and os.access(c, os.X_OK):
  40. return str(c)
  41. except OSError:
  42. continue
  43. raise MpvNotFoundError(
  44. "could not find an 'mpv' binary — please install mpv then retry:\n"
  45. " Arch Linux: sudo pacman -S mpv\n"
  46. " Debian / Ubuntu: sudo apt-get install -y mpv\n"
  47. " Fedora: sudo dnf install mpv\n"
  48. " macOS (Homebrew): brew install --cask mpv\n"
  49. "\n(or run the player with: asot --mpv-binary /path/to/mpv)"
  50. )
  51. @dataclass(frozen=True)
  52. class Status:
  53. """Snapshot of playback state."""
  54. position: float # seconds since episode start
  55. length: float # total length in seconds; -1.0 if unknown
  56. state: str # playing / paused / buffering / eof / stop
  57. paused: bool
  58. muted: bool
  59. volume: float # 0.0 – 1.0
  60. class MpEngine:
  61. """An mpv process controlled through its JSON IPC unix socket."""
  62. def __init__(self, binary: str | None = None, no_config: bool = False,
  63. sock_path: str | None = None):
  64. self.bin = find_mpv(binary)
  65. self.no_config = no_config
  66. self.sock_path = sock_path or os.path.join(
  67. tempfile.gettempdir(), f".asot-ipc-{os.getpid()}.sock"
  68. )
  69. self.proc: subprocess.Popen | None = None
  70. self._sock: socket.socket | None = None
  71. self._cid = 0
  72. self.url = ""
  73. self.last_event: dict = {}
  74. # ----------------------------------------------------------------------
  75. def alive(self) -> bool:
  76. return self.proc is not None and self.proc.poll() is None
  77. def play(self, url: str, seek_to: float = 0.0, muted: bool = False,
  78. volume: float = 0.8):
  79. """(Re)start playback of ``url``; resume at ``seek_to`` seconds if set."""
  80. self.stop()
  81. try:
  82. os.unlink(self.sock_path)
  83. except OSError:
  84. pass
  85. args = [
  86. self.bin,
  87. "--no-video",
  88. "--terminal=no", # fully disable mpv terminal use (including keys)
  89. "--really-quiet",
  90. f"--input-ipc-server={self.sock_path}",
  91. "--loop=0",
  92. ]
  93. if self.no_config:
  94. args.append("--no-config")
  95. self.url = url
  96. self.proc = subprocess.Popen(
  97. args,
  98. stdout=subprocess.DEVNULL,
  99. stderr=subprocess.DEVNULL,
  100. stdin=subprocess.DEVNULL,
  101. )
  102. self._connect()
  103. try:
  104. self.set_mute(muted)
  105. self.set_volume(volume)
  106. self._wait_ready()
  107. except EngineError:
  108. self.stop()
  109. raise
  110. if seek_to > 1.0:
  111. try:
  112. self.seek(seek_to)
  113. except EngineError:
  114. pass
  115. def _connect(self):
  116. """Open the IPC socket (mpv creates it at startup)."""
  117. self._sock = None
  118. deadline = time.time() + 15.0
  119. while time.time() < deadline:
  120. if self.proc is not None and self.proc.poll() is not None:
  121. rc = self.proc.returncode
  122. raise EngineError(
  123. f"mpv exited before playback could start "
  124. f"(return code {rc}) — the stream may be unavailable."
  125. )
  126. try:
  127. self._sock = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
  128. self._sock.connect(self.sock_path)
  129. return
  130. except OSError:
  131. pass
  132. time.sleep(0.2)
  133. self.stop()
  134. raise EngineError("timed out waiting for mpv to create its IPC socket")
  135. def _wait_ready(self):
  136. """Block until playback begins (or fails)."""
  137. deadline = time.time() + 18.0
  138. while time.time() < deadline:
  139. state = self._prop("playback-state")
  140. if isinstance(state, str) and state in ("playing", "buffering", "paused", "eof"):
  141. return
  142. if self.proc is not None and self.proc.poll() is not None:
  143. rc = self.proc.returncode
  144. raise EngineError(
  145. f"mpv failed to load '{self.url}' (return code {rc}) — "
  146. "check your network or the stream URL."
  147. )
  148. time.sleep(0.35)
  149. raise EngineError("timed out waiting for mpv to become ready")
  150. # ----------------------------------------------------------------------
  151. def status(self) -> Status:
  152. state = self._prop("playback-state")
  153. paused = self._prop("pause")
  154. muted = self._prop("mute")
  155. volume = self._prop("volume")
  156. pos = self._prop("playback-time")
  157. length = self._prop("length")
  158. def _num(x) -> float:
  159. if x is None or isinstance(x, bool):
  160. return -1.0
  161. try:
  162. return float(x)
  163. except (TypeError, ValueError):
  164. return -1.0
  165. def _bool(x) -> bool:
  166. if isinstance(x, bool):
  167. return x
  168. try:
  169. return bool(float(x))
  170. except (TypeError, ValueError):
  171. return False
  172. state_s = state if isinstance(state, str) else "stop"
  173. return Status(
  174. position=_num(pos),
  175. length=_num(length),
  176. state=state_s,
  177. paused=_bool(paused),
  178. muted=_bool(muted),
  179. volume=_num(volume),
  180. )
  181. def seek(self, seconds: float):
  182. """Seek to ``seconds`` from episode start (absolute)."""
  183. self._cmd(["seek", round(max(0.0, min(seconds, 9_999_999.0)), 3), "absolute"])
  184. def set_pause(self, paused: bool):
  185. self._cmd(["set_property", "pause", paused])
  186. def set_mute(self, muted: bool):
  187. self._cmd(["set_property", "mute", muted])
  188. def set_volume(self, volume: float):
  189. self._cmd(["set_property", "volume", max(0.0, min(1.0, float(volume)))])
  190. # ----------------------------------------------------------------------
  191. def _prop(self, name: str):
  192. return self._cmd(["get_property", name])
  193. def _cmd(self, cmd, timeout: float = 4.0):
  194. """Send one JSON command; return its ``data`` (raises on failure)."""
  195. if not self.alive():
  196. raise EngineError("mpv process is dead")
  197. self._cid += 1
  198. cmd_id = self._cid
  199. self._sock.sendall((json.dumps({"command": cmd, "command_id": cmd_id}) + "\n").encode())
  200. deadline = time.time() + timeout
  201. while True:
  202. remaining = max(0.01, deadline - time.time())
  203. rlist, _, _ = select.select([self._sock], [], remaining)
  204. if rlist:
  205. try:
  206. data = self._sock.recv(65536)
  207. for lin in data.split(b"\n"):
  208. if not lin.strip():
  209. continue
  210. try:
  211. obj = json.loads(lin.decode())
  212. except json.JSONDecodeError:
  213. continue
  214. if obj.get("event") is not None:
  215. self.last_event = obj
  216. else:
  217. if obj.get("command_id") == cmd_id:
  218. return obj.get("data")
  219. except (ConnectionResetError, OSError):
  220. raise EngineError("mpv IPC socket closed unexpectedly")
  221. if time.time() >= deadline:
  222. if self.proc and self.proc.poll() is None:
  223. self.proc.kill()
  224. self.proc.wait()
  225. raise EngineError(f"no reply from mpv ({timeout:.1f}s timeout)")
  226. def stop(self):
  227. try:
  228. self._cmd(["set_property", "stop", True], timeout=1.5)
  229. except Exception:
  230. pass
  231. try:
  232. if self.proc:
  233. self.proc.terminate()
  234. self.proc.wait(timeout=3.0)
  235. except (AttributeError, subprocess.SubprocessError):
  236. if self.proc is not None:
  237. self.proc.kill()
  238. self.proc.wait()
  239. try:
  240. if self._sock:
  241. self._sock.close()
  242. except OSError:
  243. pass
  244. try:
  245. os.unlink(self.sock_path)
  246. except OSError:
  247. pass
  248. self.proc = None
  249. self._sock = None
  250. # ---------------------------------------------------------------------------
  251. # Fake engine: lets the full player (UI + state + key loop) run without mpv
  252. # or a live stream. Used for tests and `asot --fake`.
  253. # ---------------------------------------------------------------------------
  254. class FakeEngine:
  255. def __init__(self, url: str, length: float = 75 * 60.0, speed: float = 1.0):
  256. self.url = url
  257. self._length = float(length)
  258. self._speed = speed
  259. self._pos = 0.0
  260. self.paused = False
  261. self.muted = False
  262. self.volume = 0.8
  263. self.state = "stop"
  264. self.played_url = None
  265. self.seek_count = 0
  266. def play(self, url, seek_to=0.0, muted=False, volume=0.8):
  267. self.url = url
  268. self.played_url = url
  269. self._pos = max(0.0, min(seek_to, self._length))
  270. self.paused = False
  271. self.muted = bool(muted)
  272. self.volume = max(0.0, min(1.0, float(volume)))
  273. self.state = "buffering" if self._length < 120 else "playing"
  274. def status(self) -> Status:
  275. if not self.paused and self._length > 0:
  276. self._pos += self._speed
  277. if self._pos >= self._length:
  278. self.state = "eof"
  279. self._pos = self._length
  280. elif self.played_url == self.url and self._pos <= 1.5:
  281. self.state = "buffering"
  282. else:
  283. self.state = "playing"
  284. return Status(
  285. position=self._pos,
  286. length=self._length,
  287. state=self.state,
  288. paused=self.paused,
  289. muted=self.muted,
  290. volume=self.volume,
  291. )
  292. def seek(self, pos_seconds):
  293. self.seek_count += 1
  294. self._pos = max(0.0, min(pos_seconds, self._length))
  295. def set_pause(self, paused):
  296. self.paused = paused
  297. def set_mute(self, muted):
  298. self.muted = muted
  299. def set_volume(self, volume):
  300. self.volume = max(0.0, min(1.0, float(volume)))
  301. def stop(self):
  302. self.state = "stop"
  303. self.played_url = None
  304. def alive(self) -> bool:
  305. return True